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