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