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 try {
247 mediaSourceAbortController = new AbortController();
248 const response = await fetch(srcUrl, { credentials: "same-origin", signal: mediaSourceAbortController.signal });
249 if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
250 const reader = response.body.getReader();
251
252 function waitUntilUpdateDone() {
253 return new Promise((resolve) => {
254 if (!sourceBuffer.updating) resolve(undefined);
255 return sourceBuffer.addEventListener("updateend", resolve, { once: true });
256 });
257 }
258
259 //wait until playback time is progressed far enough to appending to the buffer again
260 //keep only 30 seconds of buffer ahead
261 function waitForBufferLimit() {
262 return new Promise((resolve) => {
263 const cb = () => {
264 try {
265 if (
266 sourceBuffer.buffered.length === 0 ||
267 sourceBuffer.buffered.end(0) - videoElement.currentTime < 30
268 ) {
269 resolve(undefined);
270 videoElement.removeEventListener("timeupdate", cb);
271 return true;
272 }
273 return false;
274 } catch (e) {
275 videoElement.removeEventListener("timeupdate", cb);
276 resolve(undefined);
277 }
278 };
279 //check once immediately before starting the event listener
280 if (!cb()) {
281 videoElement.addEventListener("timeupdate", cb);
282 }
283 });
284 }
285
286 async function saveUpdate(action: () => void) {
287 while (true) {
288 try {
289 if (thisMediaSource.readyState !== "open") return;
290 await waitUntilUpdateDone();
291 action();
292 return;
293 } catch (e) {
294 if (!(e instanceof Error && e.name === "InvalidStateError")) throw e;
295 }
296 }
297 }
298
299 while (true) {
300 const { done, value } = await reader.read();
301 if (done || thisMediaSource.readyState !== "open") break;
302
303 const CHUNK_SIZE = 1000000; // 1MB chunks
304 for (let offset = 0; offset < value.length; offset += CHUNK_SIZE) {
305 const chunk = value.slice(offset, offset + CHUNK_SIZE);
306
307 await saveUpdate(() => sourceBuffer.appendBuffer(chunk));
308
309 //keep last 20 seconds of buffer
310 if (sourceBuffer.buffered.length > 0 && videoElement.currentTime - sourceBuffer.buffered.start(0) > 20) {
311 await saveUpdate(() => sourceBuffer.remove(0, videoElement.currentTime - 20));
312 }
313 await waitForBufferLimit();
314 }
315 }
316 // Wait for buffer to finish updating
317 await waitUntilUpdateDone();
318 } catch (error) {
319 if (!(error instanceof Error && error.name === "AbortError")) {
320 console.error(error);
321 }
322 } finally {
323 try {
324 thisMediaSource.endOfStream();
325 } catch {}
326 }
327 });
328 return URL.createObjectURL(thisMediaSource);
329 }
330
331 //without this, seeking via mse will cause an infinite loop
332 let ignoreSeek = false;
333 // Play a track from the playlist
334 async function playTrack(track: PlaylistItem, seekTo?: number) {
335 try {
336 const { src, isVideo } = await getSrc(track.path, options, isOffline(), seekTo || 0);
337 if (!src) return;
338 //Cleanup
339 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
340 if (mediaSource) {
341 //without this abort, the old fetch keeps running and the old ffmpeg instance keeps transcoding
342 mediaSourceAbortController?.abort();
343 try {
344 mediaSource.endOfStream();
345 mediaSource = null;
346 } catch {}
347 }
348
349 //need both of these lines to work without logging an error
350 //just removeAttribute doesn't work at all
351 //just srcObject = null works, but logs a weird csp error
352 videoElement.removeAttribute("src");
353 videoElement.srcObject = null;
354 const mseURL =
355 options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
356 ? await getMSEUrl(src, seekTo || 0, isVideo === true)
357 : null;
358 setVideoShown(false);
359 videoElement.src = mseURL || src;
360 if (mseURL && seekTo) {
361 ignoreSeek = true;
362 videoElement.currentTime = seekTo;
363 }
364 batch(() => {
365 setPlayerState("isPlaying", true);
366 setPlayerState("currentTime", videoElement.currentTime);
367 setPlayerState("buffered", []);
368 //clone, otherwise it shares the reference
369 setPlayerState("currentTrack", { ...track });
370 setPlayerState("waitingForStart", true);
371 });
372
373 await videoElement.play();
374 } catch (e) {
375 setPlayerState("isPlaying", false);
376 handlePlayerError(e);
377 }
378 }
379
380 // Play next track
381 function playNext() {
382 const currentTrack = playerState.currentTrack;
383 if (!currentTrack) return;
384
385 let nextTrack: PlaylistItem | null = null;
386
387 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
388 if (isShuffled()) {
389 // Pick a different random track
390 if (playlist().length > 1) {
391 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
392 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
393 } else {
394 nextTrack = isRepeated() ? currentTrack : null;
395 }
396 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
397 nextTrack = playlist()[currentIndex + 1];
398 } else if (isRepeated()) {
399 nextTrack = playlist()[0];
400 }
401 if (nextTrack) {
402 playTrack(nextTrack);
403 } else {
404 stopPlaying();
405 }
406 }
407
408 // Play previous track
409 function playPrevious() {
410 const currentTrack = playerState.currentTrack;
411 if (!currentTrack) return;
412 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
413 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
414 }
415
416 // Toggle play/pause
417 function togglePlay() {
418 if (playerState.currentTrack === null) return;
419 const newIsPlaying = !playerState.isPlaying;
420 setPlayerState("isPlaying", newIsPlaying);
421
422 if (newIsPlaying) {
423 setPlayerState("waitingForStart", true);
424 videoElement
425 .play()
426 .catch((e) => {
427 setPlayerState("isPlaying", false);
428 setPlayerState("currentTime", 0);
429 handlePlayerError(e);
430 })
431 .finally(() => {
432 setPlayerState("waitingForStart", false);
433 });
434 } else {
435 videoElement.pause();
436 }
437 }
438
439 function timeRangesToArray(timeRanges: TimeRanges) {
440 const result = [];
441 for (let i = 0; i < timeRanges.length; i++) {
442 result.push({
443 start: timeRanges.start(i),
444 end: timeRanges.end(i),
445 });
446 }
447 return result;
448 }
449
450 function updateTime() {
451 setPlayerState("currentTime", videoElement.currentTime);
452 //also call updateProgress here, because some browsers send the "progress" events too slow
453 updateProgress();
454 }
455 function updateProgress() {
456 setPlayerState("buffered", reconcile(timeRangesToArray(videoElement.buffered)));
457 }
458
459 function needMSESeek(seekTo: number) {
460 function isInTimeRanges(timeRanges: TimeRanges, targetTime: number): boolean {
461 for (let i = 0; i < timeRanges.length; i++) {
462 if (targetTime >= timeRanges.start(i) && targetTime <= timeRanges.end(i)) {
463 return true;
464 }
465 }
466
467 // Special case: if seeking to exactly 0 and the first buffered range starts very close to 0
468 // (accounting for common MSE container overhead), treat it as within range
469 if (targetTime === 0 && timeRanges.length > 0 && timeRanges.start(0) < 0.1) {
470 return true;
471 }
472 return false;
473 }
474 return mediaSource !== null && !isInTimeRanges(videoElement.buffered, seekTo);
475 }
476
477 // Initialize app
478 onMount(async () => {
479 // Load from localStorage
480 try {
481 const savedOptions = localStorage.getItem(LocalStorageValues.options);
482 if (savedOptions) {
483 //keep default settings when no override exists
484 setOptions({ ...options, ...JSON.parse(savedOptions) });
485 }
486 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
487 if (savedVolume && !Number.isNaN(savedVolume)) {
488 setPlayerState("volume", savedVolume);
489 }
490 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
491 if (savedPlaylist) {
492 setPlaylist(JSON.parse(savedPlaylist));
493 }
494 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
495 setShuffled(savedShuffle === "true");
496 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
497 setRepeated(savedRepeat === "true");
498 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
499 setOffline(savedOffline === "true");
500 const savedPath = localStorage.getItem(LocalStorageValues.path);
501 if (options.rememberDir && savedPath !== null && !window.location.hash) {
502 setCurrentDir(savedPath);
503 }
504 } catch (e) {
505 toast("loading values from localStorage failed", "error", e);
506 localStorage.clear();
507 }
508 // Sync to localStorage
509 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
510 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
511 createEffect(() => localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist())));
512 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
513 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
514 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
515 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
516
517 // Set initial video element volume
518 videoElement.volume = playerState.volume;
519
520 // Set up video element event handlers
521 videoElement.addEventListener("ended", playNext);
522 videoElement.addEventListener("timeupdate", updateTime);
523 videoElement.addEventListener("progress", updateProgress);
524 videoElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
525 videoElement.addEventListener("play", () => setPlayerState("isPlaying", true));
526 videoElement.addEventListener("volumechange", () => setPlayerState("volume", videoElement.volume));
527 videoElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
528 videoElement.addEventListener("seeking", () => {
529 if (!ignoreSeek && playerState.currentTrack && needMSESeek(videoElement.currentTime)) {
530 playTrack(playerState.currentTrack, videoElement.currentTime);
531 }
532 ignoreSeek = false;
533 });
534 videoElement.addEventListener("loadedmetadata", () => {
535 setVideoShown(videoElement.videoWidth !== 0 || videoElement.videoHeight !== 0);
536 });
537 videoElement.addEventListener("click", (e) => {
538 e.preventDefault();
539 togglePlay();
540 });
541 videoElement.addEventListener("fullscreenchange", () => {
542 if (document.fullscreenElement !== videoElement) {
543 videoElement.controls = false;
544 }
545 });
546
547 window.addEventListener("hashchange", () => {
548 setCurrentDir(decodePath(window.location.hash.slice(1)));
549 });
550
551 document.addEventListener("keydown", (e) => {
552 const activeElementTag = document.activeElement?.tagName.toLowerCase();
553 if (activeElementTag === "input") {
554 return;
555 }
556 if (e.key === " " && document.fullscreenElement !== videoElement) {
557 togglePlay();
558 e.preventDefault();
559 } else if (e.key === "ArrowLeft") {
560 videoElement.currentTime -= 10;
561 e.preventDefault();
562 } else if (e.key === "ArrowRight") {
563 videoElement.currentTime += 10;
564 e.preventDefault();
565 } else if (e.key === "ArrowUp") {
566 videoElement.volume += 0.1;
567 e.preventDefault();
568 } else if (e.key === "ArrowDown") {
569 videoElement.volume -= 0.1;
570 e.preventDefault();
571 }
572 });
573 });
574
575 createEffect(() => {
576 videoElement.volume = playerState.volume;
577 videoElement.playbackRate = playerState.rate;
578 });
579
580 return (
581 <div class="app-container">
582 <div class="app-header">
583 <div style={{ "min-width": 0 }}>
584 <div class="title">Pico Pixel Player</div>
585 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
586 </div>
587
588 <div class="spacer" />
589 <div class="app-buttons">
590 <div class="signin-offline-group">
591 <SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} />
592 <button
593 style={{ display: isOffline() ? "none" : undefined }}
594 title="Sign in"
595 onclick={() => setShowSignIn(true)}
596 >
597 <LoginSVG />
598 </button>
599
600 <button
601 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
602 class="offline-button"
603 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
604 textContent={isOffline() ? "Go online" : "Go offline"}
605 onclick={() => setOffline(!isOffline())}
606 />
607 </div>
608 <div>
609 <button
610 title="Toggle File Browser"
611 class="filebrowser-toggle"
612 onClick={() => setShowFileBrowser((prev) => !prev)}
613 >
614 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
615 </button>
616 <button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
617 <DownloadManagerSVG />
618 </button>
619 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
620 <PlaylistSVG />
621 </button>
622 <button
623 title="Toggle Fullscreen"
624 onClick={() =>
625 document.fullscreenElement
626 ? document.exitFullscreen()
627 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
628 }
629 >
630 <FullscreenSVG />
631 </button>
632 <button title="Settings" onClick={() => setShowOptions(true)}>
633 <CogSVG />
634 </button>
635 </div>
636 </div>
637 </div>
638
639 <div class="main-content">
640 <FileBrowser
641 breadcrumbs={breadcrumbs}
642 currentDir={currentDir}
643 setCurrentDir={setCurrentDir}
644 playTrack={playTrack}
645 playlist={playlist}
646 setPlaylist={setPlaylist}
647 showFileBrowser={showFileBrowser}
648 setShowFileBrowser={setShowFileBrowser}
649 options={options}
650 isOffline={isOffline}
651 files={files}
652 fetchFiles={fetchFiles}
653 />
654
655 <Playlist
656 playlist={playlist}
657 setPlaylist={setPlaylist}
658 playerState={playerState}
659 setPlayerState={setPlayerState}
660 playTrack={playTrack}
661 playNext={playNext}
662 videoElement={(el) => {
663 videoElement = el;
664 }}
665 videoShown={videoShown}
666 options={options}
667 fetchFiles={fetchFiles}
668 isOffline={isOffline}
669 />
670 </div>
671
672 <PlayerControls
673 playerState={playerState}
674 setPlayerState={setPlayerState}
675 togglePlay={togglePlay}
676 playPrevious={playPrevious}
677 playNext={playNext}
678 isShuffled={isShuffled}
679 setShuffled={setShuffled}
680 isRepeated={isRepeated}
681 setRepeated={setRepeated}
682 videoElement={videoElement}
683 needMSESeek={(seekTo: number) => needMSESeek(seekTo)}
684 seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
685 options={options}
686 />
687
688 <OptionsModal
689 showOptions={showOptions}
690 setShowOptions={setShowOptions}
691 options={options}
692 setOptions={setOptions}
693 />
694
695 <PlaylistManager
696 showPlaylistManager={showPlaylistManager}
697 setShowPlaylistManager={setShowPlaylistManager}
698 playlist={playlist}
699 setPlaylist={setPlaylist}
700 playTrack={playTrack}
701 options={options}
702 />
703 <DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
704 </div>
705 );
706};
707
708export default App;
709