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