App.tsx
⎇
Raw
1import {
2 AudioCodec,
3 type Directory,
4 MediaContainer,
5 type MediaFile,
6 VideoCodec,
7 VideoEncodingSetting,
8} from "music-server-shared/types";
9import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
10import { batch, createEffect, createResource, createSignal, on, onMount } from "solid-js";
11import type { Component } from "solid-js";
12import { createStore, reconcile } from "solid-js/store";
13import { BUILD_HASH } from "./build-info";
14import DownloadManager from "./components/DownloadManager";
15import FileBrowser from "./components/FileBrowser";
16import OptionsModal from "./components/OptionsModal";
17import PlayerControls from "./components/PlayerControls";
18import Playlist from "./components/Playlist";
19import PlaylistManager from "./components/PlaylistManager";
20import {
21 ClosedFolderSVG,
22 CogSVG,
23 DownloadManagerSVG,
24 FullscreenSVG,
25 LoginSVG,
26 OpenFolderSVG,
27 PlaylistSVG,
28} from "./components/SVG";
29import { SignIn } from "./components/SignIn";
30import { getCover, getSrc, listFiles } from "./offline";
31import { type AppOptions, FileDisplay, type PlayerState, type PlaylistItem } from "./types";
32import { LocalStorageValues, formatFilename, toast } from "./utils";
33
34//TODO: bypass music-metadata if it fails, maybe only use for fallback for files with weird encodings
35//TODO: more file display options
36//TODO: keep synced-aborted in downloadmanager
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 languages: "",
66 disableChunkedTranscoding: false,
67 maxTranscodes: 4,
68 maxDownloads: 2,
69 transcodeCovers: false,
70
71 hideVolume: false,
72 hideCover: false,
73 hidePlaylistCover: false,
74 fontResolution: 12,
75 filebrowserDisplay: FileDisplay.TRACKNO_TITLE,
76 playlistDisplay: FileDisplay.TRACKNO_TITLE,
77 controlsDisplay: FileDisplay.TITLE,
78 progressBarHeight: 10,
79
80 useMSE: "MediaSource" in window,
81 rememberDir: false,
82 duplicateWarning: true,
83
84 videoSettings: {
85 container: MediaContainer.mp4,
86 videoCodec: VideoCodec.av1,
87 videoBitrate: 8000000, //8mbit
88 videoEncodingSetting: VideoEncodingSetting.fast,
89 audioCodec: AudioCodec.opus,
90 audioBitrate: 128000, //128kbit
91 },
92
93 audioSettings: {
94 container: MediaContainer.webm,
95 audioBitrate: 128000, //128kbit
96 audioCodec: AudioCodec.opus,
97 },
98 });
99 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
100 const [isShuffled, setShuffled] = createSignal(false);
101 const [isRepeated, setRepeated] = createSignal(false);
102 const [isOffline, setOffline] = createSignal(true);
103 const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
104 () => ({ dir: currentDir(), offline: isOffline() }),
105 async (args) => {
106 return Array.from(await listFiles(args.dir, args.offline, false));
107 },
108 { initialValue: [] },
109 );
110 const [videoShown, setVideoShown] = createSignal(false);
111
112 createEffect(() => {
113 const parts = currentDir().split("/").filter(Boolean);
114 setBreadcrumbs(["root", ...parts]);
115 // Update URL fragment when directory changes
116 window.location.hash = encodePath(currentDir());
117 });
118
119 createEffect(
120 on(currentDir, () => {
121 setShowFileBrowser(true);
122 }),
123 );
124
125 createEffect(() => {
126 const new_font = new FontFace(
127 "ark-latin",
128 `url(assets/ark-pixel-${options.fontResolution}px-proportional-latin.woff2) format(woff2)`,
129 );
130 document.fonts.clear();
131 document.fonts.add(new_font);
132 });
133
134 createEffect(() => {
135 videoElement.style.display = videoShown() ? "block" : "none";
136 });
137
138 //incremented on every playTrack call so a slow/older invocation can detect it has been superseded
139 //and bail before clobbering the newer track's state
140 let playGeneration = 0;
141
142 function stopPlaying() {
143 //invalidate any in-flight playTrack so a stale invocation bails instead of restarting playback
144 playGeneration++;
145 batch(() => {
146 setPlayerState("isPlaying", false);
147 setPlayerState("currentTime", 0);
148 setPlayerState("currentTrack", null);
149 setPlayerState("waitingForStart", false);
150 });
151 videoElement.pause();
152
153 //tear down any in-flight MSE stream, otherwise the fetch + server-side ffmpeg transcode keeps running
154 mediaSourceAbortController?.abort();
155 mediaSourceAbortController = null;
156 if (mediaSource) {
157 try {
158 mediaSource.endOfStream();
159 } catch {}
160 mediaSource = null;
161 }
162
163 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
164 //remove the src before clearing srcObject, otherwise the media load algorithm
165 //re-fetches the just-revoked blob url and parks the element in an error state
166 videoElement.removeAttribute("src");
167 videoElement.srcObject = null;
168 }
169
170 createEffect(
171 on(playlist, () => {
172 if (playlist().length === 0) {
173 stopPlaying();
174 }
175 }),
176 );
177
178 if ("mediaSession" in navigator) {
179 createEffect(async () => {
180 if (!playerState.currentTrack) {
181 navigator.mediaSession.metadata = new MediaMetadata();
182 navigator.mediaSession.setActionHandler("pause", null);
183 navigator.mediaSession.setActionHandler("play", null);
184 navigator.mediaSession.setActionHandler("stop", null);
185 navigator.mediaSession.setActionHandler("previoustrack", null);
186 navigator.mediaSession.setActionHandler("nexttrack", null);
187 return;
188 }
189 const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
190 if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
191 const artwork = await getCover(playerState.currentTrack.path, options, false, isOffline());
192 navigator.mediaSession.metadata = new MediaMetadata({
193 title: formatFilename(playerState.currentTrack, options.controlsDisplay),
194 album: playerState.currentTrack.metadata.album,
195 artist: playerState.currentTrack.metadata.artist,
196 artwork: [
197 {
198 src: artwork ?? "",
199 },
200 ],
201 });
202
203 navigator.mediaSession.setActionHandler("nexttrack", playNext);
204 navigator.mediaSession.setActionHandler("previoustrack", playPrevious);
205 navigator.mediaSession.setActionHandler("stop", stopPlaying);
206 });
207 createEffect(() => {
208 if (!playerState.currentTrack) {
209 navigator.mediaSession.setActionHandler("pause", null);
210 navigator.mediaSession.setActionHandler("play", null);
211 return;
212 }
213 if (playerState.isPlaying) {
214 navigator.mediaSession.setActionHandler("pause", togglePlay);
215 navigator.mediaSession.setActionHandler("play", null);
216 } else {
217 navigator.mediaSession.setActionHandler("pause", null);
218 navigator.mediaSession.setActionHandler("play", togglePlay);
219 }
220 });
221 }
222
223 let videoElement!: HTMLVideoElement;
224
225 function handlePlayerError(e: unknown) {
226 if (e instanceof Error && e.name === "AbortError") return;
227 if (e instanceof Error && e.name === "NotSupportedError") {
228 console.error(e);
229 toast(
230 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
231 "error",
232 );
233 } else {
234 toast("Playing track failed", "error", e);
235 }
236 }
237
238 let mediaSource: MediaSource | null = null;
239 let mediaSourceAbortController: AbortController | null = null;
240 async function getMSEUrl(srcUrl: string, offsetSeconds: number, isVideo: boolean) {
241 let mimeType = undefined;
242 if (isVideo) {
243 mimeType = createRFC6381String(
244 options.videoSettings.container,
245 options.videoSettings.videoCodec,
246 options.videoSettings.audioCodec,
247 );
248 } else {
249 mimeType = createRFC6381String(
250 options.audioSettings.container,
251 VideoCodec.none,
252 options.audioSettings.audioCodec,
253 );
254 }
255
256 if (!MediaSource.isTypeSupported(mimeType)) return null;
257 mediaSource = new MediaSource();
258 const thisMediaSource = mediaSource;
259 const onSourceOpen = async () => {
260 if (!thisMediaSource) return;
261
262 const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
263 thisMediaSource.duration = playerState.currentTrack?.metadata.duration || 0;
264 sourceBuffer.mode = "sequence";
265 sourceBuffer.timestampOffset = offsetSeconds;
266
267 try {
268 mediaSourceAbortController = new AbortController();
269 const signal = mediaSourceAbortController.signal;
270 const response = await fetch(srcUrl, { credentials: "same-origin", signal });
271 if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
272 const reader = response.body.getReader();
273
274 function waitUntilUpdateDone() {
275 return new Promise((resolve) => {
276 if (!sourceBuffer.updating) return resolve(undefined);
277 sourceBuffer.addEventListener("updateend", resolve, { once: true });
278 });
279 }
280
281 //wait until playback time is progressed far enough to appending to the buffer again
282 //keep only 30 seconds of buffer ahead
283 function waitForBufferLimit() {
284 return new Promise((resolve) => {
285 //shared cleanup so whichever of timeupdate/abort fires first removes both listeners
286 const cleanup = () => {
287 videoElement.removeEventListener("timeupdate", cb);
288 signal.removeEventListener("abort", onAbort);
289 };
290 //after stopPlaying pauses the element, timeupdate never fires again, so also resolve on abort
291 //to avoid parking the append loop forever holding the reader/SourceBuffer
292 const onAbort = () => {
293 cleanup();
294 resolve(undefined);
295 };
296 const cb = () => {
297 try {
298 if (
299 sourceBuffer.buffered.length === 0 ||
300 sourceBuffer.buffered.end(0) - videoElement.currentTime < 30
301 ) {
302 cleanup();
303 resolve(undefined);
304 return true;
305 }
306 return false;
307 } catch (e) {
308 cleanup();
309 resolve(undefined);
310 }
311 };
312 //if already aborted, don't wait for a timeupdate that will never come
313 if (signal.aborted) {
314 resolve(undefined);
315 return;
316 }
317 //check once immediately before starting the event listener
318 if (!cb()) {
319 videoElement.addEventListener("timeupdate", cb);
320 signal.addEventListener("abort", onAbort, { once: true });
321 }
322 });
323 }
324
325 async function saveUpdate(action: () => void) {
326 while (true) {
327 // bail if the stream is gone or the media element entered a fatal error state:
328 // when videoElement.error is set the MediaSource stays "open" but every
329 // appendBuffer throws InvalidStateError, which would otherwise spin forever
330 if (thisMediaSource.readyState !== "open" || videoElement.error) return;
331 try {
332 await waitUntilUpdateDone();
333 action();
334 return;
335 } catch (e) {
336 if (!(e instanceof Error && e.name === "InvalidStateError")) throw e;
337 // an InvalidStateError while we are not updating is unrecoverable
338 // (e.g. element error / detached buffer) - don't retry into an infinite loop
339 if (!sourceBuffer.updating) return;
340 }
341 }
342 }
343
344 while (true) {
345 const { done, value } = await reader.read();
346 if (done || thisMediaSource.readyState !== "open") {
347 //release the connection (and the server-side ffmpeg transcode) when we stop early
348 if (!done) reader.cancel().catch(() => {});
349 break;
350 }
351
352 const CHUNK_SIZE = 1000000; // 1MB chunks
353 for (let offset = 0; offset < value.length; offset += CHUNK_SIZE) {
354 const chunk = value.slice(offset, offset + CHUNK_SIZE);
355 await saveUpdate(() => sourceBuffer.appendBuffer(chunk));
356
357 //keep last 20 seconds of buffer
358 await saveUpdate(() => {
359 if (sourceBuffer.buffered.length > 0 && videoElement.currentTime - sourceBuffer.buffered.start(0) > 20) {
360 return sourceBuffer.remove(0, videoElement.currentTime - 20);
361 }
362 });
363 await waitForBufferLimit();
364 }
365 }
366 // Wait for buffer to finish updating
367 await waitUntilUpdateDone();
368 } catch (error) {
369 if (!(error instanceof Error && error.name === "AbortError")) {
370 console.error(error);
371 }
372 } finally {
373 try {
374 thisMediaSource.endOfStream();
375 } catch {}
376 }
377 };
378 //once:true - a single MediaSource must only run one sourceopen handler
379 mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
380 return URL.createObjectURL(thisMediaSource);
381 }
382
383 //without this, seeking via mse will cause an infinite loop
384 let ignoreSeek = false;
385 // Play a track from the playlist
386 async function playTrack(track: PlaylistItem, seekTo?: number) {
387 const generation = ++playGeneration;
388 try {
389 const { src, isVideo } = await getSrc(track.path, track.metadata, options, isOffline(), seekTo || 0);
390 if (!src || generation !== playGeneration) return;
391 //Cleanup
392 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
393 if (mediaSource) {
394 //without this abort, the old fetch keeps running and the old ffmpeg instance keeps transcoding
395 mediaSourceAbortController?.abort();
396 mediaSourceAbortController = null;
397 //null out before endOfStream: it throws when readyState isn't "open", which would
398 //otherwise leave a stale non-null mediaSource and make needMSESeek misfire on later tracks
399 const oldMediaSource = mediaSource;
400 mediaSource = null;
401 try {
402 oldMediaSource.endOfStream();
403 } catch {}
404 }
405
406 //need both of these lines to work without logging an error
407 //just removeAttribute doesn't work at all
408 //just srcObject = null works, but logs a weird csp error
409 videoElement.removeAttribute("src");
410 videoElement.srcObject = null;
411 const mseURL =
412 options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
413 ? await getMSEUrl(src, seekTo || 0, isVideo === true)
414 : null;
415 if (generation !== playGeneration) {
416 //superseded while getMSEUrl ran: it created a detached MediaSource + object URL, but its
417 //sourceopen handler never fired (we never set videoElement.src), so no fetch/transcode was
418 //started and there's nothing to abort. Just revoke the URL and drop the now-stale module
419 //reference so a later needMSESeek can't misfire on it.
420 if (mseURL) {
421 URL.revokeObjectURL(mseURL);
422 mediaSource = null;
423 }
424 return;
425 }
426 setVideoShown(false);
427 videoElement.src = mseURL || src;
428 if (mseURL && seekTo) {
429 ignoreSeek = true;
430 videoElement.currentTime = seekTo;
431 }
432 batch(() => {
433 setPlayerState("isPlaying", true);
434 setPlayerState("currentTime", videoElement.currentTime);
435 setPlayerState("buffered", []);
436 //clone, otherwise it shares the reference
437 setPlayerState("currentTrack", { ...track });
438 setPlayerState("waitingForStart", true);
439 });
440
441 await videoElement.play();
442 } catch (e) {
443 setPlayerState("isPlaying", false);
444 handlePlayerError(e);
445 }
446 }
447
448 // Play next track
449 function playNext() {
450 const currentTrack = playerState.currentTrack;
451 if (!currentTrack) return;
452
453 let nextTrack: PlaylistItem | null = null;
454
455 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
456 if (isShuffled()) {
457 // Pick a different random track
458 if (playlist().length > 1) {
459 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
460 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
461 } else {
462 nextTrack = isRepeated() ? currentTrack : null;
463 }
464 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
465 nextTrack = playlist()[currentIndex + 1];
466 } else if (isRepeated()) {
467 nextTrack = playlist()[0];
468 }
469 if (nextTrack) {
470 playTrack(nextTrack);
471 } else {
472 stopPlaying();
473 }
474 }
475
476 // Play previous track
477 function playPrevious() {
478 const currentTrack = playerState.currentTrack;
479 if (!currentTrack) return;
480 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
481 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
482 }
483
484 // Toggle play/pause
485 function togglePlay() {
486 if (playerState.currentTrack === null) return;
487 const newIsPlaying = !playerState.isPlaying;
488 setPlayerState("isPlaying", newIsPlaying);
489
490 if (newIsPlaying) {
491 setPlayerState("waitingForStart", true);
492 videoElement
493 .play()
494 .catch((e) => {
495 setPlayerState("isPlaying", false);
496 setPlayerState("currentTime", 0);
497 handlePlayerError(e);
498 })
499 .finally(() => {
500 setPlayerState("waitingForStart", false);
501 });
502 } else {
503 videoElement.pause();
504 }
505 }
506
507 function timeRangesToArray(timeRanges: TimeRanges) {
508 const result = [];
509 for (let i = 0; i < timeRanges.length; i++) {
510 result.push({
511 start: timeRanges.start(i),
512 end: timeRanges.end(i),
513 });
514 }
515 return result;
516 }
517
518 function updateTime() {
519 setPlayerState("currentTime", videoElement.currentTime);
520 //also call updateProgress here, because some browsers send the "progress" events too slow
521 updateProgress();
522 }
523 function updateProgress() {
524 setPlayerState("buffered", reconcile(timeRangesToArray(videoElement.buffered)));
525 }
526
527 function needMSESeek(seekTo: number) {
528 function isInTimeRanges(timeRanges: TimeRanges, targetTime: number): boolean {
529 for (let i = 0; i < timeRanges.length; i++) {
530 if (targetTime >= timeRanges.start(i) && targetTime <= timeRanges.end(i)) {
531 return true;
532 }
533 }
534
535 // Special case: if seeking to exactly 0 and the first buffered range starts very close to 0
536 // (accounting for common MSE container overhead), treat it as within range
537 if (targetTime === 0 && timeRanges.length > 0 && timeRanges.start(0) < 0.1) {
538 return true;
539 }
540 return false;
541 }
542 return mediaSource !== null && !isInTimeRanges(videoElement.buffered, seekTo);
543 }
544
545 // Initialize app
546 onMount(async () => {
547 // Load from localStorage
548 try {
549 const savedOptions = localStorage.getItem(LocalStorageValues.options);
550 if (savedOptions) {
551 //keep default settings when no override exists
552 setOptions({ ...options, ...JSON.parse(savedOptions) });
553 }
554 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
555 if (savedVolume && !Number.isNaN(savedVolume)) {
556 setPlayerState("volume", savedVolume);
557 }
558 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
559 if (savedPlaylist) {
560 setPlaylist(JSON.parse(savedPlaylist));
561 }
562 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
563 setShuffled(savedShuffle === "true");
564 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
565 setRepeated(savedRepeat === "true");
566 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
567 setOffline(savedOffline === "true");
568 const savedPath = localStorage.getItem(LocalStorageValues.path);
569 if (options.rememberDir && savedPath !== null && !window.location.hash) {
570 setCurrentDir(savedPath);
571 }
572 } catch (e) {
573 toast("loading values from localStorage failed", "error", e);
574 localStorage.clear();
575 }
576 // Sync to localStorage
577 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
578 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
579 createEffect(() => {
580 //a large playlist can exceed the localStorage quota; swallow the error so it doesn't
581 //propagate out of the reactive flush and break every subsequent playlist update
582 try {
583 localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist()));
584 } catch (e) {
585 console.error("Persisting playlist to localStorage failed", e);
586 }
587 });
588 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
589 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
590 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
591 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
592
593 // Set initial video element volume
594 videoElement.volume = playerState.volume;
595
596 // Set up video element event handlers
597 videoElement.addEventListener("ended", playNext);
598 videoElement.addEventListener("timeupdate", updateTime);
599 videoElement.addEventListener("progress", updateProgress);
600 videoElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
601 videoElement.addEventListener("play", () => setPlayerState("isPlaying", true));
602 videoElement.addEventListener("volumechange", () => setPlayerState("volume", videoElement.volume));
603 videoElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
604 videoElement.addEventListener("seeking", () => {
605 if (!ignoreSeek && playerState.currentTrack && needMSESeek(videoElement.currentTime)) {
606 playTrack(playerState.currentTrack, videoElement.currentTime);
607 }
608 ignoreSeek = false;
609 });
610 videoElement.addEventListener("loadedmetadata", () => {
611 setVideoShown(videoElement.videoWidth !== 0 || videoElement.videoHeight !== 0);
612 });
613 videoElement.addEventListener("click", (e) => {
614 e.preventDefault();
615 togglePlay();
616 });
617 videoElement.addEventListener("fullscreenchange", () => {
618 if (document.fullscreenElement !== videoElement) {
619 videoElement.controls = false;
620 }
621 });
622
623 window.addEventListener("hashchange", () => {
624 setCurrentDir(decodePath(window.location.hash.slice(1)));
625 });
626
627 document.addEventListener("keydown", (e) => {
628 const activeElementTag = document.activeElement?.tagName.toLowerCase();
629 if (activeElementTag === "input") {
630 return;
631 }
632 if (e.key === " " && document.fullscreenElement !== videoElement) {
633 togglePlay();
634 e.preventDefault();
635 } else if (e.key === "ArrowLeft") {
636 videoElement.currentTime -= 10;
637 e.preventDefault();
638 } else if (e.key === "ArrowRight") {
639 videoElement.currentTime += 10;
640 e.preventDefault();
641 } else if (e.key === "ArrowUp") {
642 videoElement.volume += 0.1;
643 e.preventDefault();
644 } else if (e.key === "ArrowDown") {
645 videoElement.volume -= 0.1;
646 e.preventDefault();
647 }
648 });
649 });
650
651 createEffect(() => {
652 videoElement.volume = playerState.volume;
653 videoElement.playbackRate = playerState.rate;
654 });
655
656 return (
657 <div class="app-container">
658 <div class="app-header">
659 <div style={{ "min-width": 0 }}>
660 <div class="title">Pico Pixel Player</div>
661 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
662 </div>
663
664 <div class="spacer" />
665 <div class="app-buttons">
666 <div class="signin-offline-group">
667 <SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} />
668 <button
669 style={{ display: isOffline() ? "none" : undefined }}
670 title="Sign in"
671 onclick={() => setShowSignIn(true)}
672 >
673 <LoginSVG />
674 </button>
675
676 <button
677 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
678 class="offline-button"
679 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
680 textContent={isOffline() ? "Go online" : "Go offline"}
681 onclick={() => setOffline(!isOffline())}
682 />
683 </div>
684 <div>
685 <button
686 title="Toggle File Browser"
687 class="filebrowser-toggle"
688 onClick={() => setShowFileBrowser((prev) => !prev)}
689 >
690 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
691 </button>
692 <button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
693 <DownloadManagerSVG />
694 </button>
695 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
696 <PlaylistSVG />
697 </button>
698 <button
699 title="Toggle Fullscreen"
700 onClick={() =>
701 document.fullscreenElement
702 ? document.exitFullscreen()
703 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
704 }
705 >
706 <FullscreenSVG />
707 </button>
708 <button title="Settings" onClick={() => setShowOptions(true)}>
709 <CogSVG />
710 </button>
711 </div>
712 </div>
713 </div>
714
715 <div class="main-content">
716 <FileBrowser
717 breadcrumbs={breadcrumbs}
718 currentDir={currentDir}
719 setCurrentDir={setCurrentDir}
720 playTrack={playTrack}
721 playlist={playlist}
722 setPlaylist={setPlaylist}
723 showFileBrowser={showFileBrowser}
724 setShowFileBrowser={setShowFileBrowser}
725 options={options}
726 isOffline={isOffline}
727 files={files}
728 fetchFiles={fetchFiles}
729 />
730
731 <Playlist
732 playlist={playlist}
733 setPlaylist={setPlaylist}
734 playerState={playerState}
735 setPlayerState={setPlayerState}
736 playTrack={playTrack}
737 playNext={playNext}
738 videoElement={(el) => {
739 videoElement = el;
740 }}
741 videoShown={videoShown}
742 options={options}
743 fetchFiles={fetchFiles}
744 isOffline={isOffline}
745 />
746 </div>
747
748 <PlayerControls
749 playerState={playerState}
750 setPlayerState={setPlayerState}
751 togglePlay={togglePlay}
752 playPrevious={playPrevious}
753 playNext={playNext}
754 isShuffled={isShuffled}
755 setShuffled={setShuffled}
756 isRepeated={isRepeated}
757 setRepeated={setRepeated}
758 videoElement={videoElement}
759 needMSESeek={(seekTo: number) => needMSESeek(seekTo)}
760 seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
761 options={options}
762 />
763
764 <OptionsModal
765 showOptions={showOptions}
766 setShowOptions={setShowOptions}
767 options={options}
768 setOptions={setOptions}
769 />
770
771 <PlaylistManager
772 showPlaylistManager={showPlaylistManager}
773 setShowPlaylistManager={setShowPlaylistManager}
774 playlist={playlist}
775 setPlaylist={setPlaylist}
776 playTrack={playTrack}
777 options={options}
778 />
779 <DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
780 </div>
781 );
782};
783
784export default App;
785