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 { createRFC6381String, 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 maxTranscodes: 4,
67 maxDownloads: 2,
68 transcodeCovers: false,
69
70 hideVolume: false,
71 hideCover: false,
72 hidePlaylistCover: false,
73 fontResolution: 12,
74 filebrowserDisplay: FileDisplay.TRACKNO_TITLE,
75 playlistDisplay: FileDisplay.TRACKNO_TITLE,
76 controlsDisplay: FileDisplay.TITLE,
77 progressBarHeight: 10,
78
79 useMSE: "MediaSource" in window,
80 rememberDir: false,
81 duplicateWarning: true,
82
83 videoSettings: {
84 container: MediaContainer.mp4,
85 videoCodec: VideoCodec.av1,
86 videoBitrate: 8000000, //8mbit
87 videoEncodingSetting: VideoEncodingSetting.fast,
88 audioCodec: AudioCodec.opus,
89 audioBitrate: 128000, //128kbit
90 },
91
92 audioSettings: {
93 container: MediaContainer.webm,
94 audioBitrate: 128000, //128kbit
95 audioCodec: AudioCodec.opus,
96 },
97 });
98 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
99 const [isShuffled, setShuffled] = createSignal(false);
100 const [isRepeated, setRepeated] = createSignal(false);
101 const [isOffline, setOffline] = createSignal(true);
102 const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
103 () => ({ dir: currentDir(), offline: isOffline() }),
104 async (args) => {
105 return Array.from(await listFiles(args.dir, args.offline, false));
106 },
107 { initialValue: [] },
108 );
109 const [videoShown, setVideoShown] = createSignal(false);
110
111 createEffect(() => {
112 const parts = currentDir().split("/").filter(Boolean);
113 setBreadcrumbs(["root", ...parts]);
114 // Update URL fragment when directory changes
115 window.location.hash = encodePath(currentDir());
116 });
117
118 createEffect(
119 on(currentDir, () => {
120 setShowFileBrowser(true);
121 }),
122 );
123
124 createEffect(() => {
125 const new_font = new FontFace(
126 "ark-latin",
127 `url(assets/ark-pixel-${options.fontResolution}px-proportional-latin.woff2) format(woff2)`,
128 );
129 document.fonts.clear();
130 document.fonts.add(new_font);
131 });
132
133 createEffect(() => {
134 videoElement.style.display = videoShown() ? "block" : "none";
135 });
136
137 function stopPlaying() {
138 batch(() => {
139 setPlayerState("isPlaying", false);
140 setPlayerState("currentTime", 0);
141 setPlayerState("currentTrack", null);
142 });
143 videoElement.pause();
144
145 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
146 videoElement.srcObject = null;
147 }
148
149 createEffect(
150 on(playlist, () => {
151 if (playlist().length === 0) {
152 stopPlaying();
153 }
154 }),
155 );
156
157 if ("mediaSession" in navigator) {
158 createEffect(async () => {
159 if (!playerState.currentTrack) {
160 navigator.mediaSession.metadata = new MediaMetadata();
161 navigator.mediaSession.setActionHandler("pause", null);
162 navigator.mediaSession.setActionHandler("play", null);
163 navigator.mediaSession.setActionHandler("stop", null);
164 navigator.mediaSession.setActionHandler("previoustrack", null);
165 navigator.mediaSession.setActionHandler("nexttrack", null);
166 return;
167 }
168 const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
169 if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
170 const artwork = await getCover(playerState.currentTrack.path, options, false, isOffline());
171 navigator.mediaSession.metadata = new MediaMetadata({
172 title: formatFilename(playerState.currentTrack, options.controlsDisplay),
173 album: playerState.currentTrack.metadata.album,
174 artist: playerState.currentTrack.metadata.artist,
175 artwork: [
176 {
177 src: artwork ?? "",
178 },
179 ],
180 });
181
182 navigator.mediaSession.setActionHandler("nexttrack", playNext);
183 navigator.mediaSession.setActionHandler("previoustrack", playPrevious);
184 navigator.mediaSession.setActionHandler("stop", stopPlaying);
185 });
186 createEffect(() => {
187 if (!playerState.currentTrack) {
188 navigator.mediaSession.setActionHandler("pause", null);
189 navigator.mediaSession.setActionHandler("play", null);
190 return;
191 }
192 if (playerState.isPlaying) {
193 navigator.mediaSession.setActionHandler("pause", togglePlay);
194 navigator.mediaSession.setActionHandler("play", null);
195 } else {
196 navigator.mediaSession.setActionHandler("pause", null);
197 navigator.mediaSession.setActionHandler("play", togglePlay);
198 }
199 });
200 }
201
202 let videoElement!: HTMLVideoElement;
203
204 function handlePlayerError(e: unknown) {
205 if (e instanceof Error && e.name === "AbortError") return;
206 if (e instanceof Error && e.name === "NotSupportedError") {
207 console.error(e);
208 toast(
209 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
210 "error",
211 );
212 } else {
213 toast("Playing track failed", "error", e);
214 }
215 }
216
217 let mediaSource: MediaSource | null = null;
218 let mediaSourceAbortController: AbortController | null = null;
219 async function getMSEUrl(srcUrl: string, offsetSeconds: number, isVideo: boolean) {
220 let mimeType = undefined;
221 if (isVideo) {
222 mimeType = createRFC6381String(
223 options.videoSettings.container,
224 options.videoSettings.videoCodec,
225 options.videoSettings.audioCodec,
226 );
227 } else {
228 mimeType = createRFC6381String(
229 options.audioSettings.container,
230 VideoCodec.none,
231 options.audioSettings.audioCodec,
232 );
233 }
234
235 if (!MediaSource.isTypeSupported(mimeType)) return null;
236 mediaSource = new MediaSource();
237 const thisMediaSource = mediaSource;
238 mediaSource.addEventListener("sourceopen", async () => {
239 if (!thisMediaSource) return;
240
241 const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
242 thisMediaSource.duration = playerState.currentTrack?.metadata.duration || 0;
243 sourceBuffer.mode = "sequence";
244 sourceBuffer.timestampOffset = offsetSeconds;
245
246 const pendingBuffer: Uint8Array[] = [];
247 let isBuffering = false;
248
249 const processQueue = () => {
250 if (isBuffering || pendingBuffer.length === 0) return;
251
252 const buffer = pendingBuffer.shift() as Uint8Array;
253 if (thisMediaSource.readyState !== "open") return;
254 isBuffering = true;
255 sourceBuffer.appendBuffer(buffer);
256 };
257
258 sourceBuffer.addEventListener("updateend", () => {
259 isBuffering = false;
260 processQueue();
261 });
262
263 try {
264 mediaSourceAbortController = new AbortController();
265 const response = await fetch(srcUrl, { credentials: "same-origin", signal: mediaSourceAbortController.signal });
266 if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
267 const reader = response.body.getReader();
268
269 while (true) {
270 const { done, value } = await reader.read();
271 if (done) break;
272 if (value) {
273 // Copy value to prevent buffer overwriting
274 pendingBuffer.push(Uint8Array.from(value));
275 processQueue();
276 }
277 }
278 // Wait for queue to drain
279 while (pendingBuffer.length > 0 || isBuffering) {
280 await new Promise((resolve) => setTimeout(resolve, 50));
281 }
282 } catch (error) {
283 if (!(error instanceof Error && error.name === "AbortError")) {
284 console.error(error);
285 }
286 } finally {
287 try {
288 thisMediaSource.endOfStream();
289 } catch {}
290 }
291 });
292 return URL.createObjectURL(thisMediaSource);
293 }
294
295 //without this, seeking via mse will cause an infinite loop
296 let ignoreSeek = false;
297 // Play a track from the playlist
298 async function playTrack(track: PlaylistItem, seekTo?: number) {
299 try {
300 const { src, isVideo } = await getSrc(track.path, options, isOffline(), seekTo || 0);
301 if (!src) return;
302 //Cleanup
303 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
304 if (mediaSource) {
305 //without this abort, the old fetch keeps running and the old ffmpeg instance keeps transcoding
306 mediaSourceAbortController?.abort();
307 try {
308 mediaSource.endOfStream();
309 mediaSource = null;
310 } catch {}
311 }
312
313 //need both of these lines to work without logging an error
314 //just removeAttribute doesn't work at all
315 //just srcObject = null works, but logs a weird csp error
316 videoElement.removeAttribute("src");
317 videoElement.srcObject = null;
318 const mseURL =
319 options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
320 ? await getMSEUrl(src, seekTo || 0, isVideo === true)
321 : null;
322 setVideoShown(false);
323 videoElement.src = mseURL || src;
324 if (mseURL && seekTo) {
325 ignoreSeek = true;
326 videoElement.currentTime = seekTo;
327 }
328 batch(() => {
329 setPlayerState("isPlaying", true);
330 setPlayerState("currentTime", videoElement.currentTime);
331 setPlayerState("buffered", []);
332 //clone, otherwise it shares the reference
333 setPlayerState("currentTrack", { ...track });
334 setPlayerState("waitingForStart", true);
335 });
336
337 await videoElement.play();
338 } catch (e) {
339 setPlayerState("isPlaying", false);
340 handlePlayerError(e);
341 }
342 }
343
344 // Play next track
345 function playNext() {
346 const currentTrack = playerState.currentTrack;
347 if (!currentTrack) return;
348
349 let nextTrack: PlaylistItem | null = null;
350
351 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
352 if (isShuffled()) {
353 // Pick a different random track
354 if (playlist().length > 1) {
355 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
356 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
357 } else {
358 nextTrack = isRepeated() ? currentTrack : null;
359 }
360 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
361 nextTrack = playlist()[currentIndex + 1];
362 } else if (isRepeated()) {
363 nextTrack = playlist()[0];
364 }
365 if (nextTrack) {
366 playTrack(nextTrack);
367 } else {
368 stopPlaying();
369 }
370 }
371
372 // Play previous track
373 function playPrevious() {
374 const currentTrack = playerState.currentTrack;
375 if (!currentTrack) return;
376 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
377 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
378 }
379
380 // Toggle play/pause
381 function togglePlay() {
382 if (playerState.currentTrack === null) return;
383 const newIsPlaying = !playerState.isPlaying;
384 setPlayerState("isPlaying", newIsPlaying);
385
386 if (newIsPlaying) {
387 setPlayerState("waitingForStart", true);
388 videoElement
389 .play()
390 .catch((e) => {
391 setPlayerState("isPlaying", false);
392 setPlayerState("currentTime", 0);
393 handlePlayerError(e);
394 })
395 .finally(() => {
396 setPlayerState("waitingForStart", false);
397 });
398 } else {
399 videoElement.pause();
400 }
401 }
402
403 function timeRangesToArray(timeRanges: TimeRanges) {
404 const result = [];
405 for (let i = 0; i < timeRanges.length; i++) {
406 result.push({
407 start: timeRanges.start(i),
408 end: timeRanges.end(i),
409 });
410 }
411 return result;
412 }
413
414 function updateTime() {
415 setPlayerState("currentTime", videoElement.currentTime);
416 //also call updateProgress here, because some browsers send the "progress" events too slow
417 updateProgress();
418 }
419 function updateProgress() {
420 setPlayerState("buffered", reconcile(timeRangesToArray(videoElement.buffered)));
421 }
422
423 function needMSESeek(seekTo: number) {
424 function isInTimeRanges(timeRanges: TimeRanges, targetTime: number): boolean {
425 for (let i = 0; i < timeRanges.length; i++) {
426 if (targetTime >= timeRanges.start(i) && targetTime <= timeRanges.end(i)) {
427 return true;
428 }
429 }
430
431 // Special case: if seeking to exactly 0 and the first buffered range starts very close to 0
432 // (accounting for common MSE container overhead), treat it as within range
433 if (targetTime === 0 && timeRanges.length > 0 && timeRanges.start(0) < 0.1) {
434 return true;
435 }
436 return false;
437 }
438 return mediaSource !== null && !isInTimeRanges(videoElement.buffered, seekTo);
439 }
440
441 // Initialize app
442 onMount(async () => {
443 // Load from localStorage
444 try {
445 const savedOptions = localStorage.getItem(LocalStorageValues.options);
446 if (savedOptions) {
447 //keep default settings when no override exists
448 setOptions({ ...options, ...JSON.parse(savedOptions) });
449 }
450 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
451 if (savedVolume && !Number.isNaN(savedVolume)) {
452 setPlayerState("volume", savedVolume);
453 }
454 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
455 if (savedPlaylist) {
456 setPlaylist(JSON.parse(savedPlaylist));
457 }
458 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
459 setShuffled(savedShuffle === "true");
460 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
461 setRepeated(savedRepeat === "true");
462 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
463 setOffline(savedOffline === "true");
464 const savedPath = localStorage.getItem(LocalStorageValues.path);
465 if (options.rememberDir && savedPath !== null && !window.location.hash) {
466 setCurrentDir(savedPath);
467 }
468 } catch (e) {
469 toast("loading values from localStorage failed", "error", e);
470 localStorage.clear();
471 }
472 // Sync to localStorage
473 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
474 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
475 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
476 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
477 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
478 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
479 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
480
481 // Set initial video element volume
482 videoElement.volume = playerState.volume;
483
484 // Set up video element event handlers
485 videoElement.addEventListener("ended", playNext);
486 videoElement.addEventListener("timeupdate", updateTime);
487 videoElement.addEventListener("progress", updateProgress);
488 videoElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
489 videoElement.addEventListener("play", () => setPlayerState("isPlaying", true));
490 videoElement.addEventListener("volumechange", () => setPlayerState("volume", videoElement.volume));
491 videoElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
492 videoElement.addEventListener("seeking", () => {
493 if (!ignoreSeek && playerState.currentTrack && needMSESeek(videoElement.currentTime)) {
494 playTrack(playerState.currentTrack, videoElement.currentTime);
495 }
496 ignoreSeek = false;
497 });
498 videoElement.addEventListener("loadedmetadata", () => {
499 setVideoShown(videoElement.videoWidth !== 0 || videoElement.videoHeight !== 0);
500 });
501 videoElement.addEventListener("click", (e) => {
502 e.preventDefault();
503 togglePlay();
504 });
505 videoElement.addEventListener("fullscreenchange", () => {
506 if (document.fullscreenElement !== videoElement) {
507 videoElement.controls = false;
508 }
509 });
510
511 window.addEventListener("hashchange", () => {
512 setCurrentDir(decodePath(window.location.hash.slice(1)));
513 });
514
515 document.addEventListener("keydown", (e) => {
516 const activeElementTag = document.activeElement?.tagName.toLowerCase();
517 if (activeElementTag === "input") {
518 return;
519 }
520 if (e.key === " ") {
521 togglePlay();
522 e.preventDefault();
523 } else if (e.key === "ArrowLeft") {
524 videoElement.currentTime -= 10;
525 e.preventDefault();
526 } else if (e.key === "ArrowRight") {
527 videoElement.currentTime += 10;
528 e.preventDefault();
529 } else if (e.key === "ArrowUp") {
530 videoElement.volume += 0.1;
531 e.preventDefault();
532 } else if (e.key === "ArrowDown") {
533 videoElement.volume -= 0.1;
534 e.preventDefault();
535 }
536 });
537 });
538
539 createEffect(() => {
540 videoElement.volume = playerState.volume;
541 videoElement.playbackRate = playerState.rate;
542 });
543
544 return (
545 <div class="app-container">
546 <div class="app-header">
547 <div style={{ "min-width": 0 }}>
548 <div class="title">Pico Pixel Player</div>
549 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
550 </div>
551
552 <div class="spacer" />
553 <div class="app-buttons">
554 <div class="signin-offline-group">
555 <SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} />
556 <button
557 style={{ display: isOffline() ? "none" : undefined }}
558 title="Sign in"
559 onclick={() => setShowSignIn(true)}
560 >
561 <LoginSVG />
562 </button>
563
564 <button
565 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
566 class="offline-button"
567 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
568 textContent={isOffline() ? "Go online" : "Go offline"}
569 onclick={() => setOffline(!isOffline())}
570 />
571 </div>
572 <div>
573 <button
574 title="Toggle File Browser"
575 class="filebrowser-toggle"
576 onClick={() => setShowFileBrowser((prev) => !prev)}
577 >
578 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
579 </button>
580 <button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
581 <DownloadManagerSVG />
582 </button>
583 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
584 <PlaylistSVG />
585 </button>
586 <button
587 title="Toggle Fullscreen"
588 onClick={() =>
589 document.fullscreenElement
590 ? document.exitFullscreen()
591 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
592 }
593 >
594 <FullscreenSVG />
595 </button>
596 <button title="Settings" onClick={() => setShowOptions(true)}>
597 <CogSVG />
598 </button>
599 </div>
600 </div>
601 </div>
602
603 <div class="main-content">
604 <FileBrowser
605 breadcrumbs={breadcrumbs}
606 currentDir={currentDir}
607 setCurrentDir={setCurrentDir}
608 playTrack={playTrack}
609 playlist={playlist}
610 setPlaylist={setPlaylist}
611 showFileBrowser={showFileBrowser}
612 setShowFileBrowser={setShowFileBrowser}
613 options={options}
614 isOffline={isOffline}
615 files={files}
616 fetchFiles={fetchFiles}
617 />
618
619 <Playlist
620 playlist={playlist}
621 setPlaylist={setPlaylist}
622 playerState={playerState}
623 setPlayerState={setPlayerState}
624 playTrack={playTrack}
625 playNext={playNext}
626 videoElement={(el) => {
627 videoElement = el;
628 }}
629 videoShown={videoShown}
630 options={options}
631 fetchFiles={fetchFiles}
632 isOffline={isOffline}
633 />
634 </div>
635
636 <PlayerControls
637 playerState={playerState}
638 setPlayerState={setPlayerState}
639 togglePlay={togglePlay}
640 playPrevious={playPrevious}
641 playNext={playNext}
642 isShuffled={isShuffled}
643 setShuffled={setShuffled}
644 isRepeated={isRepeated}
645 setRepeated={setRepeated}
646 videoElement={videoElement}
647 needMSESeek={(seekTo: number) => needMSESeek(seekTo)}
648 seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
649 options={options}
650 />
651
652 <OptionsModal
653 showOptions={showOptions}
654 setShowOptions={setShowOptions}
655 options={options}
656 setOptions={setOptions}
657 />
658
659 <PlaylistManager
660 showPlaylistManager={showPlaylistManager}
661 setShowPlaylistManager={setShowPlaylistManager}
662 playlist={playlist}
663 setPlaylist={setPlaylist}
664 playTrack={playTrack}
665 options={options}
666 />
667 <DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
668 </div>
669 );
670};
671
672export default App;
673