App.tsx
⎇
Raw
1import { createSignal, createEffect, onMount, on, createResource, batch } from "solid-js";
2import type { Component } from "solid-js";
3import { type PlaylistItem, type PlayerState, type AppOptions, getItemName } from "./types";
4import OptionsModal from "./components/OptionsModal";
5import PlayerControls from "./components/PlayerControls";
6import FileBrowser from "./components/FileBrowser";
7import { ClosedFolderSVG, CogSVG, FullscreenSVG, OpenFolderSVG, PlaylistSVG } from "./components/SVG";
8import { LocalStorageValues, toast } from "./utils";
9import Playlist from "./components/Playlist";
10import { getCover, getSrc, listFiles } from "./offline";
11import { MediaFormat } from "music-server-shared/types";
12import PlaylistManager from "./components/PlaylistManager";
13import { BUILD_HASH } from "./build-info";
14import { createStore } from "solid-js/store";
15
16//TODO: show offline-only files in online mode as well
17//TODO: add loading state to playercontrols
18//TODO: search
19//TODO: better touch controls?
20//TODO: Add documentation to functions
21//TODO: allow syncing playlists to server
22//TODO: button to show metadata
23//TODO: show cover of playing item
24//TODO: delete confirmation?
25//TODO: setting export/import
26//TODO: video support
27//TODO: virtual rendering for playlist
28//TODO: custom serialization?
29
30const App: Component = () => {
31 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
32 const [currentDir, setCurrentDir] = createSignal(decodeURI(window.location.hash.slice(1)));
33 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
34 const [playerState, setPlayerState] = createStore<PlayerState>({
35 isPlaying: false,
36 currentTrack: null,
37 currentTime: 0,
38 volume: 0.5,
39 buffered: 0,
40 rate: 1,
41 });
42 const [showOptions, setShowOptions] = createSignal(false);
43 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
44 const [options, setOptions] = createStore<AppOptions>({
45 bitrate: 128000,
46 enableTranscoding: true,
47 disableChunkedTranscoding: false,
48 maxTranscodes: 4,
49 maxDownloads: 2,
50 rememberDir: false,
51 format: MediaFormat["opus/webm"],
52 hideVolume: false,
53 duplicateWarning: true,
54 });
55 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
56 const [isShuffled, setShuffled] = createSignal(false);
57 const [isRepeated, setRepeated] = createSignal(false);
58 const [isOffline, setOffline] = createSignal(true);
59 const [files, fetchFiles] = createResource(
60 () => ({ dir: currentDir(), offline: isOffline() }),
61 async (args) => {
62 //new map to force rerender
63 return new Map(await listFiles(args.dir, args.offline, false));
64 },
65 { initialValue: new Map() },
66 );
67
68 createEffect(() => {
69 const parts = currentDir().split("/").filter(Boolean);
70 setBreadcrumbs(["root", ...parts]);
71 // Update URL fragment when directory changes
72 window.location.hash = encodeURI(currentDir());
73 });
74
75 function stopPlaying() {
76 batch(() => {
77 setPlayerState("isPlaying", false);
78 setPlayerState("currentTime", 0);
79 setPlayerState("currentTrack", null);
80 });
81 audioElement.pause();
82 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
83 audioElement.srcObject = null;
84 }
85
86 createEffect(
87 on(playlist, () => {
88 if (playlist().length === 0) {
89 stopPlaying();
90 }
91 }),
92 );
93
94 if ("mediaSession" in navigator) {
95 createEffect(async () => {
96 if (!playerState.currentTrack) {
97 navigator.mediaSession.metadata = new MediaMetadata();
98 return;
99 }
100 const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
101 if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
102 const artwork = await getCover(playerState.currentTrack.path.split("/").slice(0, -1).join("/"), isOffline());
103 navigator.mediaSession.metadata = new MediaMetadata({
104 title: getItemName(playerState.currentTrack),
105 artwork: [
106 {
107 src: artwork ?? "",
108 },
109 ],
110 });
111 });
112 }
113
114 let audioElement!: HTMLAudioElement;
115
116 function handlePlayerError(e: unknown) {
117 if (e instanceof Error && e.name === "AbortError") return;
118 if (e instanceof Error && e.name === "NotSupportedError") {
119 toast(
120 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
121 "error",
122 );
123 } else {
124 toast("Playing track failed", "error", e);
125 }
126 }
127
128 // Play a track from the playlist
129 async function playTrack(track: PlaylistItem) {
130 try {
131 const src = await getSrc(track.path, options, isOffline());
132 if (!src) return;
133 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
134
135 //need both of these lines to work without logging an error
136 //just removeAttribute doesn't work at all
137 //just srcObject = null works, but logs a weird csp error
138 audioElement.removeAttribute("src");
139 audioElement.srcObject = null;
140 audioElement.src = src;
141 batch(() => {
142 setPlayerState("isPlaying", true);
143 setPlayerState("currentTime", 0);
144 setPlayerState("buffered", 0);
145 //clone, otherwise it shares the reference
146 setPlayerState("currentTrack", { ...track });
147 });
148
149 await audioElement.play();
150 } catch (e) {
151 setPlayerState("isPlaying", false);
152 handlePlayerError(e);
153 }
154 }
155
156 // Play next track
157 function playNext() {
158 const currentTrack = playerState.currentTrack;
159 if (!currentTrack) return;
160
161 let nextTrack: PlaylistItem | null = null;
162
163 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
164 if (isShuffled()) {
165 // Pick a different random track
166 if (playlist().length > 1) {
167 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
168 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
169 } else {
170 nextTrack = isRepeated() ? currentTrack : null;
171 }
172 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
173 nextTrack = playlist()[currentIndex + 1];
174 } else if (isRepeated()) {
175 nextTrack = playlist()[0];
176 }
177 if (nextTrack) {
178 playTrack(nextTrack);
179 } else {
180 stopPlaying();
181 }
182 }
183
184 // Play previous track
185 function playPrevious() {
186 const currentTrack = playerState.currentTrack;
187 if (!currentTrack) return;
188 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
189 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
190 }
191
192 // Toggle play/pause
193 function togglePlay() {
194 if (playerState.currentTrack === null) return;
195 const newIsPlaying = !playerState.isPlaying;
196 setPlayerState("isPlaying", newIsPlaying);
197
198 if (newIsPlaying) {
199 audioElement.play().catch((e) => {
200 setPlayerState("isPlaying", false);
201 setPlayerState("currentTime", 0);
202 handlePlayerError(e);
203 });
204 } else {
205 audioElement.pause();
206 }
207 }
208
209 // Update progress when the time updates
210 function updateProgress() {
211 setPlayerState("currentTime", audioElement.currentTime);
212 setPlayerState(
213 "buffered",
214 audioElement.buffered.length > 0 ? audioElement.buffered.end(audioElement.buffered.length - 1) : 0,
215 );
216 }
217
218 // Initialize app
219 onMount(async () => {
220 // Load from localStorage
221 try {
222 const savedOptions = localStorage.getItem(LocalStorageValues.options);
223 if (savedOptions) {
224 setOptions({ ...options, ...JSON.parse(savedOptions) });
225 }
226 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
227 if (savedVolume && !Number.isNaN(savedVolume)) {
228 setPlayerState("volume", savedVolume);
229 }
230 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
231 if (savedPlaylist) {
232 setPlaylist(JSON.parse(savedPlaylist));
233 }
234 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
235 setShuffled(savedShuffle === "true");
236 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
237 setRepeated(savedRepeat === "true");
238 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
239 setOffline(savedOffline === "true");
240 const savedPath = localStorage.getItem(LocalStorageValues.path);
241 if (options.rememberDir && savedPath !== null && !window.location.hash) {
242 setCurrentDir(savedPath);
243 }
244 } catch (e) {
245 toast("loading values from localStorage failed", "error", e);
246 localStorage.clear();
247 }
248 // Sync to localStorage
249 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
250 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
251 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
252 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
253 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
254 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
255
256 // Set initial audio element volume
257 audioElement.volume = playerState.volume;
258
259 // Set up audio element event handlers
260 audioElement.addEventListener("ended", playNext);
261 audioElement.addEventListener("timeupdate", updateProgress);
262 audioElement.addEventListener("durationchange", updateProgress);
263 audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
264 audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
265 audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
266
267 window.addEventListener("hashchange", () => {
268 setCurrentDir(decodeURI(window.location.hash.slice(1)));
269 });
270
271 document.addEventListener("keydown", (e) => {
272 if (e.key === " ") togglePlay();
273 });
274 });
275
276 createEffect(() => {
277 audioElement.volume = playerState.volume;
278 audioElement.playbackRate = playerState.rate;
279 });
280
281 return (
282 <div class="app-container">
283 <div class="app-header">
284 <div style={{ "min-width": 0 }}>
285 <div class="title">Pico Pixel Player</div>
286 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
287 </div>
288
289 <div class="spacer" />
290 <div class="app-buttons">
291 <button
292 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
293 class="offline-button"
294 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
295 textContent={isOffline() ? "Go online" : "Go offline"}
296 onclick={() => setOffline(!isOffline())}
297 />
298 <div>
299 <button
300 title="Toggle File Browser"
301 class="filebrowser-toggle"
302 onClick={() => setShowFileBrowser((prev) => !prev)}
303 >
304 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
305 </button>
306 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
307 <PlaylistSVG />
308 </button>
309 <button
310 title="Toggle Fullscreen"
311 onClick={() =>
312 document.fullscreenElement
313 ? document.exitFullscreen()
314 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
315 }
316 >
317 <FullscreenSVG />
318 </button>
319 <button title="Settings" onClick={() => setShowOptions(true)}>
320 <CogSVG />
321 </button>
322 </div>
323 </div>
324 </div>
325
326 <div class="main-content">
327 <FileBrowser
328 breadcrumbs={breadcrumbs}
329 currentDir={currentDir}
330 setCurrentDir={setCurrentDir}
331 playTrack={playTrack}
332 playlist={playlist}
333 setPlaylist={setPlaylist}
334 showFileBrowser={showFileBrowser}
335 setShowFileBrowser={setShowFileBrowser}
336 options={options}
337 isOffline={isOffline}
338 files={files}
339 fetchFiles={fetchFiles}
340 />
341
342 <Playlist
343 currentDir={currentDir}
344 playlist={playlist}
345 setPlaylist={setPlaylist}
346 playerState={playerState}
347 setPlayerState={setPlayerState}
348 playTrack={playTrack}
349 playNext={playNext}
350 audioElement={audioElement}
351 options={options}
352 fetchFiles={fetchFiles}
353 />
354 </div>
355
356 <audio ref={audioElement} preload="auto" />
357
358 <PlayerControls
359 playerState={playerState}
360 setPlayerState={setPlayerState}
361 togglePlay={togglePlay}
362 playPrevious={playPrevious}
363 playNext={playNext}
364 isShuffled={isShuffled}
365 setShuffled={setShuffled}
366 isRepeated={isRepeated}
367 setRepeated={setRepeated}
368 audioElement={audioElement}
369 options={options}
370 />
371
372 <OptionsModal
373 showOptions={showOptions}
374 setShowOptions={setShowOptions}
375 options={options}
376 setOptions={setOptions}
377 />
378
379 <PlaylistManager
380 showPlaylistManager={showPlaylistManager}
381 setShowPlaylistManager={setShowPlaylistManager}
382 playlist={playlist}
383 setPlaylist={setPlaylist}
384 playTrack={playTrack}
385 />
386 </div>
387 );
388};
389
390export default App;
391