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, FileDisplay } from "./types";
4import OptionsModal from "./components/OptionsModal";
5import PlayerControls from "./components/PlayerControls";
6import FileBrowser from "./components/FileBrowser";
7import {
8 ClosedFolderSVG,
9 CogSVG,
10 DownloadManagerSVG,
11 FullscreenSVG,
12 LoginSVG,
13 OpenFolderSVG,
14 PlaylistSVG,
15} from "./components/SVG";
16import { formatFilename, LocalStorageValues, toast } from "./utils";
17import Playlist from "./components/Playlist";
18import { getCover, getSrc, listFiles } from "./offline";
19import {
20 AudioCodec,
21 MediaContainer,
22 type Directory,
23 type MediaFile,
24 VideoCodec,
25 VideoEncodingSetting,
26} from "music-server-shared/types";
27import PlaylistManager from "./components/PlaylistManager";
28import { BUILD_HASH } from "./build-info";
29import { createStore, reconcile } from "solid-js/store";
30import { decodePath, encodePath } from "music-server-shared/utils";
31import DownloadManager from "./components/DownloadManager";
32import { SignIn } from "./components/SignIn";
33
34//TODO: more file display options
35//TODO: keep synced-aborted in downloadmanager
36//TODO: bypass music-metadata if it fails
37//TODO: Add documentation to functions
38//TODO: allow syncing playlists to server
39//TODO: video support
40//TODO: virtual rendering for playlist
41//TODO: custom serialization?
42
43export type FlatFileListing = [string, Directory | MediaFile][];
44
45const App: Component = () => {
46 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
47 const [currentDir, setCurrentDir] = createSignal(decodePath(window.location.hash.slice(1)));
48 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
49 const [playerState, setPlayerState] = createStore<PlayerState>({
50 isPlaying: false,
51 currentTrack: null,
52 currentTime: 0,
53 volume: 0.5,
54 buffered: [],
55 rate: 1,
56 waitingForStart: false,
57 });
58 const [showOptions, setShowOptions] = createSignal(false);
59 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
60 const [showDownloadManager, setShowDownloadManager] = createSignal(false);
61 const [showSignIn, setShowSignIn] = createSignal(false);
62 const [options, setOptions] = createStore<AppOptions>({
63 //default settings
64 enableTranscoding: true,
65 disableChunkedTranscoding: false,
66 audioBitrate: 128000, //128kbit
67 videoBitrate: 1500000, //1.5mbit
68 container: MediaContainer.webm,
69 audioCodec: AudioCodec.opus,
70 videoCodec: VideoCodec.vp9,
71 videoEncodingSettings: VideoEncodingSetting.fast,
72 maxTranscodes: 4,
73 maxDownloads: 2,
74 transcodeCovers: false,
75
76 hideVolume: false,
77 hideCover: false,
78 hidePlaylistCover: false,
79 fontResolution: 12,
80 filebrowserDisplay: FileDisplay.TRACKNO_TITLE,
81 playlistDisplay: FileDisplay.TRACKNO_TITLE,
82 controlsDisplay: FileDisplay.TITLE,
83 progressBarHeight: 10,
84
85 useMSE: "MediaSource" in window,
86 rememberDir: false,
87 duplicateWarning: true,
88 });
89 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
90 const [isShuffled, setShuffled] = createSignal(false);
91 const [isRepeated, setRepeated] = createSignal(false);
92 const [isOffline, setOffline] = createSignal(true);
93 const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
94 () => ({ dir: currentDir(), offline: isOffline() }),
95 async (args) => {
96 return Array.from(await listFiles(args.dir, args.offline, false));
97 },
98 { initialValue: [] },
99 );
100
101 createEffect(() => {
102 const parts = currentDir().split("/").filter(Boolean);
103 setBreadcrumbs(["root", ...parts]);
104 // Update URL fragment when directory changes
105 window.location.hash = encodePath(currentDir());
106 });
107
108 createEffect(
109 on(currentDir, () => {
110 setShowFileBrowser(true);
111 }),
112 );
113
114 createEffect(() => {
115 const new_font = new FontFace(
116 "ark-latin",
117 `url(assets/ark-pixel-${options.fontResolution}px-proportional-latin.woff2) format(woff2)`,
118 );
119 document.fonts.clear();
120 document.fonts.add(new_font);
121 });
122
123 function stopPlaying() {
124 batch(() => {
125 setPlayerState("isPlaying", false);
126 setPlayerState("currentTime", 0);
127 setPlayerState("currentTrack", null);
128 });
129 audioElement.pause();
130 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
131 audioElement.srcObject = null;
132 }
133
134 createEffect(
135 on(playlist, () => {
136 if (playlist().length === 0) {
137 stopPlaying();
138 }
139 }),
140 );
141
142 if ("mediaSession" in navigator) {
143 createEffect(async () => {
144 if (!playerState.currentTrack) {
145 navigator.mediaSession.metadata = new MediaMetadata();
146 navigator.mediaSession.setActionHandler("pause", null);
147 navigator.mediaSession.setActionHandler("play", null);
148 navigator.mediaSession.setActionHandler("stop", null);
149 navigator.mediaSession.setActionHandler("previoustrack", null);
150 navigator.mediaSession.setActionHandler("nexttrack", null);
151 return;
152 }
153 const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
154 if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
155 const artwork = await getCover(playerState.currentTrack.path, options, false, isOffline());
156 navigator.mediaSession.metadata = new MediaMetadata({
157 title: formatFilename(playerState.currentTrack, options.controlsDisplay),
158 album: playerState.currentTrack.metadata.album,
159 artist: playerState.currentTrack.metadata.artist,
160 artwork: [
161 {
162 src: artwork ?? "",
163 },
164 ],
165 });
166
167 navigator.mediaSession.setActionHandler("nexttrack", playNext);
168 navigator.mediaSession.setActionHandler("previoustrack", playPrevious);
169 navigator.mediaSession.setActionHandler("stop", stopPlaying);
170 });
171 createEffect(() => {
172 if (!playerState.currentTrack) {
173 navigator.mediaSession.setActionHandler("pause", null);
174 navigator.mediaSession.setActionHandler("play", null);
175 return;
176 }
177 if (playerState.isPlaying) {
178 navigator.mediaSession.setActionHandler("pause", togglePlay);
179 navigator.mediaSession.setActionHandler("play", null);
180 } else {
181 navigator.mediaSession.setActionHandler("pause", null);
182 navigator.mediaSession.setActionHandler("play", togglePlay);
183 }
184 });
185 }
186
187 let audioElement!: HTMLAudioElement;
188
189 function handlePlayerError(e: unknown) {
190 if (e instanceof Error && e.name === "AbortError") return;
191 if (e instanceof Error && e.name === "NotSupportedError") {
192 toast(
193 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
194 "error",
195 );
196 } else {
197 toast("Playing track failed", "error", e);
198 }
199 }
200
201 let mediaSource: MediaSource | null = null;
202
203 function getMSEUrl(srcUrl: string, mimeType: string, offsetSeconds: number) {
204 if (!MediaSource.isTypeSupported(mimeType)) return null;
205 mediaSource = new MediaSource();
206 const thisMediaSource = mediaSource;
207 mediaSource.addEventListener("sourceopen", async () => {
208 if (!thisMediaSource) return;
209
210 const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
211 sourceBuffer.timestampOffset = offsetSeconds;
212
213 try {
214 const response = await fetch(srcUrl, { credentials: "same-origin" });
215 if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
216 const reader = response.body.getReader();
217
218 while (true) {
219 const { done, value } = await reader.read();
220 if (done) break;
221 const push = () => {
222 // If buffer updating, wait
223 if (sourceBuffer.updating) {
224 sourceBuffer.addEventListener("updateend", push, { once: true });
225 return;
226 }
227 if (thisMediaSource.readyState !== "open") return;
228 // thisMediaSource.duration = 100;
229 sourceBuffer.appendBuffer(value.slice(0).buffer);
230 };
231 push();
232 }
233 if (sourceBuffer.updating) {
234 sourceBuffer.addEventListener(
235 "updateend",
236 () => {
237 try {
238 thisMediaSource.endOfStream();
239 } catch {}
240 },
241 { once: true },
242 );
243 } else {
244 try {
245 thisMediaSource.endOfStream();
246 } catch {}
247 }
248 } catch (error) {
249 console.error(error);
250 try {
251 thisMediaSource.endOfStream();
252 } catch {}
253 }
254 });
255 return URL.createObjectURL(thisMediaSource);
256 }
257
258 // Play a track from the playlist
259 async function playTrack(track: PlaylistItem, seekTo?: number) {
260 try {
261 const src = await getSrc(track.path, options, isOffline(), seekTo || 0);
262 if (!src) return;
263 //Cleanup
264 if (audioElement.src.startsWith("blob:")) URL.revokeObjectURL(audioElement.src);
265 if (mediaSource) {
266 try {
267 mediaSource.endOfStream();
268 mediaSource = null;
269 } catch {}
270 }
271
272 //need both of these lines to work without logging an error
273 //just removeAttribute doesn't work at all
274 //just srcObject = null works, but logs a weird csp error
275 audioElement.removeAttribute("src");
276 audioElement.srcObject = null;
277 //TODO: get correct mimetype
278 const mseURL =
279 options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
280 ? getMSEUrl(src, 'audio/webm; codecs="opus"', seekTo || 0)
281 : null;
282 audioElement.src = mseURL || src;
283 audioElement.currentTime = (mseURL && seekTo) || 0;
284 batch(() => {
285 setPlayerState("isPlaying", true);
286 setPlayerState("currentTime", audioElement.currentTime);
287 setPlayerState("buffered", []);
288 //clone, otherwise it shares the reference
289 setPlayerState("currentTrack", { ...track });
290 setPlayerState("waitingForStart", true);
291 });
292
293 await audioElement.play();
294 } catch (e) {
295 setPlayerState("isPlaying", false);
296 handlePlayerError(e);
297 }
298 }
299
300 // Play next track
301 function playNext() {
302 const currentTrack = playerState.currentTrack;
303 if (!currentTrack) return;
304
305 let nextTrack: PlaylistItem | null = null;
306
307 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
308 if (isShuffled()) {
309 // Pick a different random track
310 if (playlist().length > 1) {
311 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
312 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
313 } else {
314 nextTrack = isRepeated() ? currentTrack : null;
315 }
316 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
317 nextTrack = playlist()[currentIndex + 1];
318 } else if (isRepeated()) {
319 nextTrack = playlist()[0];
320 }
321 if (nextTrack) {
322 playTrack(nextTrack);
323 } else {
324 stopPlaying();
325 }
326 }
327
328 // Play previous track
329 function playPrevious() {
330 const currentTrack = playerState.currentTrack;
331 if (!currentTrack) return;
332 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
333 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
334 }
335
336 // Toggle play/pause
337 function togglePlay() {
338 if (playerState.currentTrack === null) return;
339 const newIsPlaying = !playerState.isPlaying;
340 setPlayerState("isPlaying", newIsPlaying);
341
342 if (newIsPlaying) {
343 setPlayerState("waitingForStart", true);
344 audioElement
345 .play()
346 .catch((e) => {
347 setPlayerState("isPlaying", false);
348 setPlayerState("currentTime", 0);
349 handlePlayerError(e);
350 })
351 .finally(() => {
352 setPlayerState("waitingForStart", false);
353 });
354 } else {
355 audioElement.pause();
356 }
357 }
358
359 function timeRangesToArray(timeRanges: TimeRanges) {
360 const result = [];
361 for (let i = 0; i < timeRanges.length; i++) {
362 result.push({
363 start: timeRanges.start(i),
364 end: timeRanges.end(i),
365 });
366 }
367 return result;
368 }
369
370 function updateTime() {
371 setPlayerState("currentTime", audioElement.currentTime);
372 //also call updateProgress here, because some browsers send the "progress" events too slow
373 updateProgress();
374 }
375 function updateProgress() {
376 setPlayerState("buffered", reconcile(timeRangesToArray(audioElement.buffered)));
377 }
378
379 // Initialize app
380 onMount(async () => {
381 // Load from localStorage
382 try {
383 const savedOptions = localStorage.getItem(LocalStorageValues.options);
384 if (savedOptions) {
385 //keep default settings when no override exists
386 setOptions({ ...options, ...JSON.parse(savedOptions) });
387 }
388 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
389 if (savedVolume && !Number.isNaN(savedVolume)) {
390 setPlayerState("volume", savedVolume);
391 }
392 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
393 if (savedPlaylist) {
394 setPlaylist(JSON.parse(savedPlaylist));
395 }
396 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
397 setShuffled(savedShuffle === "true");
398 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
399 setRepeated(savedRepeat === "true");
400 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
401 setOffline(savedOffline === "true");
402 const savedPath = localStorage.getItem(LocalStorageValues.path);
403 if (options.rememberDir && savedPath !== null && !window.location.hash) {
404 setCurrentDir(savedPath);
405 }
406 } catch (e) {
407 toast("loading values from localStorage failed", "error", e);
408 localStorage.clear();
409 }
410 // Sync to localStorage
411 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
412 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
413 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
414 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
415 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
416 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
417 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
418
419 // Set initial audio element volume
420 audioElement.volume = playerState.volume;
421
422 // Set up audio element event handlers
423 audioElement.addEventListener("ended", playNext);
424 audioElement.addEventListener("timeupdate", updateTime);
425 audioElement.addEventListener("progress", updateProgress);
426 audioElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
427 audioElement.addEventListener("play", () => setPlayerState("isPlaying", true));
428 audioElement.addEventListener("volumechange", () => setPlayerState("volume", audioElement.volume));
429 audioElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
430
431 window.addEventListener("hashchange", () => {
432 setCurrentDir(decodePath(window.location.hash.slice(1)));
433 });
434
435 document.addEventListener("keydown", (e) => {
436 const activeElementTag = document.activeElement?.tagName.toLowerCase();
437 if (activeElementTag === "input") {
438 return;
439 }
440 if (e.key === " ") {
441 togglePlay();
442 e.preventDefault();
443 }
444 });
445 });
446
447 createEffect(() => {
448 audioElement.volume = playerState.volume;
449 audioElement.playbackRate = playerState.rate;
450 });
451
452 return (
453 <div class="app-container">
454 <div class="app-header">
455 <div style={{ "min-width": 0 }}>
456 <div class="title">Pico Pixel Player</div>
457 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
458 </div>
459
460 <div class="spacer" />
461 <div class="app-buttons">
462 <div class="signin-offline-group">
463 <SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} />
464 <button
465 style={{ display: isOffline() ? "none" : undefined }}
466 title="Sign in"
467 onclick={() => setShowSignIn(true)}
468 >
469 <LoginSVG />
470 </button>
471
472 <button
473 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
474 class="offline-button"
475 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
476 textContent={isOffline() ? "Go online" : "Go offline"}
477 onclick={() => setOffline(!isOffline())}
478 />
479 </div>
480 <div>
481 <button
482 title="Toggle File Browser"
483 class="filebrowser-toggle"
484 onClick={() => setShowFileBrowser((prev) => !prev)}
485 >
486 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
487 </button>
488 <button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
489 <DownloadManagerSVG />
490 </button>
491 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
492 <PlaylistSVG />
493 </button>
494 <button
495 title="Toggle Fullscreen"
496 onClick={() =>
497 document.fullscreenElement
498 ? document.exitFullscreen()
499 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
500 }
501 >
502 <FullscreenSVG />
503 </button>
504 <button title="Settings" onClick={() => setShowOptions(true)}>
505 <CogSVG />
506 </button>
507 </div>
508 </div>
509 </div>
510
511 <div class="main-content">
512 <FileBrowser
513 breadcrumbs={breadcrumbs}
514 currentDir={currentDir}
515 setCurrentDir={setCurrentDir}
516 playTrack={playTrack}
517 playlist={playlist}
518 setPlaylist={setPlaylist}
519 showFileBrowser={showFileBrowser}
520 setShowFileBrowser={setShowFileBrowser}
521 options={options}
522 isOffline={isOffline}
523 files={files}
524 fetchFiles={fetchFiles}
525 />
526
527 <Playlist
528 playlist={playlist}
529 setPlaylist={setPlaylist}
530 playerState={playerState}
531 setPlayerState={setPlayerState}
532 playTrack={playTrack}
533 playNext={playNext}
534 audioElement={audioElement}
535 options={options}
536 fetchFiles={fetchFiles}
537 isOffline={isOffline}
538 />
539 </div>
540
541 <audio ref={audioElement} preload="auto" />
542
543 <PlayerControls
544 playerState={playerState}
545 setPlayerState={setPlayerState}
546 togglePlay={togglePlay}
547 playPrevious={playPrevious}
548 playNext={playNext}
549 isShuffled={isShuffled}
550 setShuffled={setShuffled}
551 isRepeated={isRepeated}
552 setRepeated={setRepeated}
553 audioElement={audioElement}
554 isMSE={() => mediaSource !== null}
555 seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
556 options={options}
557 />
558
559 <OptionsModal
560 showOptions={showOptions}
561 setShowOptions={setShowOptions}
562 options={options}
563 setOptions={setOptions}
564 />
565
566 <PlaylistManager
567 showPlaylistManager={showPlaylistManager}
568 setShowPlaylistManager={setShowPlaylistManager}
569 playlist={playlist}
570 setPlaylist={setPlaylist}
571 playTrack={playTrack}
572 options={options}
573 />
574 <DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
575 </div>
576 );
577};
578
579export default App;
580