App.tsx
⎇
Raw
1import {
2 AudioCodec,
3 type Directory,
4 MediaContainer,
5 type MediaFile,
6 VideoCodec,
7 VideoEncodingSetting,
8} from "music-server-shared/types";
9import { createRFC6381String, decodePath, encodePath } from "music-server-shared/utils";
10import type { Component } from "solid-js";
11import { batch, createEffect, createResource, createSignal, on, onMount, Show } from "solid-js";
12import { createStore, reconcile } from "solid-js/store";
13import { BUILD_HASH } from "./build-info";
14import DownloadManager from "./components/DownloadManager";
15import FileBrowser from "./components/FileBrowser";
16import OptionsModal from "./components/OptionsModal";
17import PlayerControls from "./components/PlayerControls";
18import Playlist from "./components/Playlist";
19import PlaylistManager from "./components/PlaylistManager";
20import { SignIn } from "./components/SignIn";
21import {
22 ClosedFolderSVG,
23 CogSVG,
24 DownloadManagerSVG,
25 FullscreenSVG,
26 LoginSVG,
27 LogoutSVG,
28 OpenFolderSVG,
29 PlaylistSVG,
30} from "./components/SVG";
31import { getCover, getSrc, listFiles } from "./offline";
32import { type AppOptions, FileDisplay, type PlayerState, type PlaylistItem } from "./types";
33import { formatFilename, handleUnauthorized, LocalStorageValues, setUnauthorizedHandler, toast } from "./utils";
34
35//TODO: bypass music-metadata if it fails, maybe only use for fallback for files with weird encodings
36//TODO: more file display options
37//TODO: keep synced-aborted in downloadmanager
38//TODO: Add documentation to functions
39//TODO: allow syncing playlists to server
40//TODO: video support
41//TODO: virtual rendering for playlist
42//TODO: custom serialization?
43
44export type FlatFileListing = [string, Directory | MediaFile][];
45
46const BUFFER_AHEAD_TARGET = 30; //stop appending once this much media sits past the playhead
47const BUFFER_BEHIND_TARGET = 20; //keep this much behind it, so short backwards seeks stay in the buffer
48const BUFFER_BEHIND_MIN = 2; //how far quota pressure may shrink that before we give up
49const BUFFER_BEHIND_SLACK = 5; //let the behind-window overshoot this much before trimming it again
50const BUFFER_POLL_INTERVAL = 250; //ms between buffer checks while the append loop is parked
51const RANGE_START_TOLERANCE = 0.25; //seconds of slack when deciding which range a time sits in
52const SEEK_MIN_AHEAD = 0.5; //playable seconds a seek target needs ahead of it, or it lands on the buffer edge
53
54//how much contiguous media is buffered ahead of `time`. buffered is a *list* of disjoint ranges, so
55//end(0) is only the playhead's range while the buffer happens to be contiguous - once a gap appears
56//it reports the end of a range behind the playhead and the caller's arithmetic goes negative
57function bufferedAhead(ranges: TimeRanges, time: number): number {
58 for (let i = 0; i < ranges.length; i++) {
59 //tolerance at the start: the playhead can sit a frame before a range boundary
60 if (time >= ranges.start(i) - RANGE_START_TOLERANCE && time <= ranges.end(i)) return ranges.end(i) - time;
61 }
62 return 0; //the playhead is starving, so append immediately
63}
64
65const App: Component = () => {
66 const [showFileBrowser, setShowFileBrowser] = createSignal(false);
67 const [currentDir, setCurrentDir] = createSignal(decodePath(window.location.hash.slice(1)));
68 const [playlist, setPlaylist] = createSignal<PlaylistItem[]>([]);
69 const [playerState, setPlayerState] = createStore<PlayerState>({
70 isPlaying: false,
71 currentTrack: null,
72 currentTime: 0,
73 volume: 0.5,
74 buffered: [],
75 rate: 1,
76 waitingForStart: false,
77 });
78 const [showOptions, setShowOptions] = createSignal(false);
79 const [showPlaylistManager, setShowPlaylistManager] = createSignal(false);
80 const [showDownloadManager, setShowDownloadManager] = createSignal(false);
81 const [showSignIn, setShowSignIn] = createSignal(false);
82 const [authRequired, setAuthRequired] = createSignal(false);
83 const [loggedIn, setLoggedIn] = createSignal(false);
84 const [options, setOptions] = createStore<AppOptions>({
85 //default settings
86 enableTranscoding: true,
87 languages: "",
88 disableChunkedTranscoding: false,
89 maxTranscodes: 4,
90 maxDownloads: 2,
91 transcodeCovers: false,
92
93 hideVolume: false,
94 hideCover: false,
95 hidePlaylistCover: false,
96 fontResolution: 12,
97 filebrowserDisplay: FileDisplay.TRACKNO_TITLE,
98 playlistDisplay: FileDisplay.TRACKNO_TITLE,
99 controlsDisplay: FileDisplay.TITLE,
100 progressBarHeight: 10,
101
102 useMSE: "MediaSource" in window,
103 rememberDir: false,
104 duplicateWarning: true,
105
106 videoSettings: {
107 container: MediaContainer.mp4,
108 videoCodec: VideoCodec.av1,
109 videoBitrate: 8000000, //8mbit
110 videoEncodingSetting: VideoEncodingSetting.fast,
111 audioCodec: AudioCodec.opus,
112 audioBitrate: 128000, //128kbit
113 },
114
115 audioSettings: {
116 container: MediaContainer.webm,
117 audioBitrate: 128000, //128kbit
118 audioCodec: AudioCodec.opus,
119 },
120 });
121 const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
122 const [isShuffled, setShuffled] = createSignal(false);
123 const [isRepeated, setRepeated] = createSignal(false);
124 const [isOffline, setOffline] = createSignal(true);
125 const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
126 () => ({ dir: currentDir(), offline: isOffline() }),
127 async (args) => {
128 return Array.from(await listFiles(args.dir, args.offline, false));
129 },
130 { initialValue: [] },
131 );
132 const [videoShown, setVideoShown] = createSignal(false);
133
134 async function refreshAuthStatus() {
135 try {
136 const res = await fetch("/auth/status", { credentials: "same-origin" });
137 if (!res.ok) return;
138 const status = (await res.json()) as { authRequired: boolean; loggedIn: boolean };
139 setAuthRequired(status.authRequired);
140 setLoggedIn(status.loggedIn);
141 } catch {
142 //server unreachable (e.g. offline) - leave the last known state untouched
143 }
144 }
145
146 async function doLogout() {
147 const res = await fetch("/logout", { method: "POST", credentials: "same-origin" }).catch(() => {});
148 if (res?.ok) {
149 setLoggedIn(false);
150 toast("Signed out", "success");
151 } else {
152 toast("Sign out failed", "error");
153 }
154 }
155
156 //a 401 from any request means the session is no longer valid
157 setUnauthorizedHandler(() => setLoggedIn(false));
158
159 //re-check auth whenever we (re)enter online mode
160 createEffect(() => {
161 if (!isOffline()) refreshAuthStatus();
162 });
163
164 createEffect(() => {
165 const parts = currentDir().split("/").filter(Boolean);
166 setBreadcrumbs(["root", ...parts]);
167 // Update URL fragment when directory changes
168 window.location.hash = encodePath(currentDir());
169 });
170
171 createEffect(
172 on(currentDir, () => {
173 setShowFileBrowser(true);
174 }),
175 );
176
177 createEffect(() => {
178 const new_font = new FontFace(
179 "ark-latin",
180 `url(assets/ark-pixel-${options.fontResolution}px-proportional-latin.woff2) format(woff2)`,
181 );
182 document.fonts.clear();
183 document.fonts.add(new_font);
184 });
185
186 createEffect(() => {
187 videoElement.style.display = videoShown() ? "block" : "none";
188 });
189
190 //incremented on every playTrack call so a slow/older invocation can detect it has been superseded
191 //and bail before clobbering the newer track's state
192 let playGeneration = 0;
193
194 function stopPlaying() {
195 //invalidate any in-flight playTrack so a stale invocation bails instead of restarting playback
196 playGeneration++;
197 batch(() => {
198 setPlayerState("isPlaying", false);
199 setPlayerState("currentTime", 0);
200 setPlayerState("currentTrack", null);
201 setPlayerState("waitingForStart", false);
202 });
203 videoElement.pause();
204
205 //tear down any in-flight MSE stream, otherwise the fetch + server-side ffmpeg transcode keeps running
206 mediaSourceAbortController?.abort();
207 mediaSourceAbortController = null;
208 if (mediaSource) {
209 try {
210 mediaSource.endOfStream();
211 } catch {}
212 mediaSource = null;
213 }
214
215 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
216 //remove the src before clearing srcObject, otherwise the media load algorithm
217 //re-fetches the just-revoked blob url and parks the element in an error state
218 videoElement.removeAttribute("src");
219 videoElement.srcObject = null;
220 }
221
222 createEffect(
223 on(playlist, () => {
224 if (playlist().length === 0) {
225 stopPlaying();
226 }
227 }),
228 );
229
230 if ("mediaSession" in navigator) {
231 createEffect(async () => {
232 //revoked before the early return as well, otherwise the last track's artwork leaks on every stop
233 const oldSrc = navigator.mediaSession.metadata?.artwork[0]?.src ?? "";
234 if (oldSrc.startsWith("blob:")) URL.revokeObjectURL(oldSrc);
235 if (!playerState.currentTrack) {
236 navigator.mediaSession.metadata = new MediaMetadata();
237 navigator.mediaSession.setActionHandler("pause", null);
238 navigator.mediaSession.setActionHandler("play", null);
239 navigator.mediaSession.setActionHandler("stop", null);
240 navigator.mediaSession.setActionHandler("previoustrack", null);
241 navigator.mediaSession.setActionHandler("nexttrack", null);
242 return;
243 }
244 const artwork = await getCover(playerState.currentTrack.path, options, false, isOffline());
245 navigator.mediaSession.metadata = new MediaMetadata({
246 title: formatFilename(playerState.currentTrack, options.controlsDisplay),
247 album: playerState.currentTrack.metadata.album,
248 artist: playerState.currentTrack.metadata.artist,
249 artwork: [
250 {
251 src: artwork ?? "",
252 },
253 ],
254 });
255
256 navigator.mediaSession.setActionHandler("nexttrack", playNext);
257 navigator.mediaSession.setActionHandler("previoustrack", playPrevious);
258 navigator.mediaSession.setActionHandler("stop", stopPlaying);
259 });
260 createEffect(() => {
261 if (!playerState.currentTrack) {
262 navigator.mediaSession.setActionHandler("pause", null);
263 navigator.mediaSession.setActionHandler("play", null);
264 return;
265 }
266 if (playerState.isPlaying) {
267 navigator.mediaSession.setActionHandler("pause", togglePlay);
268 navigator.mediaSession.setActionHandler("play", null);
269 } else {
270 navigator.mediaSession.setActionHandler("pause", null);
271 navigator.mediaSession.setActionHandler("play", togglePlay);
272 }
273 });
274 }
275
276 let videoElement!: HTMLVideoElement;
277
278 function handlePlayerError(e: unknown) {
279 if (e instanceof Error && e.name === "AbortError") return;
280 if (e instanceof Error && e.name === "NotSupportedError") {
281 console.error(e);
282 toast(
283 "Playing track failed: Format not supported, check if the server is up when playing a remote file, or change your transcoding settings",
284 "error",
285 );
286 } else {
287 toast("Playing track failed", "error", e);
288 }
289 }
290
291 let mediaSource: MediaSource | null = null;
292 let mediaSourceAbortController: AbortController | null = null;
293 async function getMSEUrl(srcUrl: string, offsetSeconds: number, isVideo: boolean) {
294 let mimeType: string;
295 if (isVideo) {
296 mimeType = createRFC6381String(
297 options.videoSettings.container,
298 options.videoSettings.videoCodec,
299 options.videoSettings.audioCodec,
300 );
301 } else {
302 mimeType = createRFC6381String(
303 options.audioSettings.container,
304 VideoCodec.none,
305 options.audioSettings.audioCodec,
306 );
307 }
308
309 if (!MediaSource.isTypeSupported(mimeType)) return null;
310 mediaSource = new MediaSource();
311 const thisMediaSource = mediaSource;
312 const onSourceOpen = async () => {
313 if (!thisMediaSource) return;
314
315 const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
316 thisMediaSource.duration = playerState.currentTrack?.metadata.duration || 0;
317 sourceBuffer.mode = "sequence";
318 sourceBuffer.timestampOffset = offsetSeconds;
319
320 try {
321 mediaSourceAbortController = new AbortController();
322 const signal = mediaSourceAbortController.signal;
323 const response = await fetch(srcUrl, { credentials: "same-origin", signal });
324 if (handleUnauthorized(response)) return;
325 if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
326 const reader = response.body.getReader();
327
328 function waitUntilUpdateDone() {
329 return new Promise((resolve) => {
330 if (!sourceBuffer.updating) return resolve(undefined);
331 sourceBuffer.addEventListener("updateend", resolve, { once: true });
332 });
333 }
334
335 //park the append loop until there is room ahead of the playhead again. the interval is what
336 //guarantees a wakeup: timeupdate does not fire at all while the element is paused (and is only
337 //loosely specified otherwise), so it must not be the sole liveness signal - it and seeking
338 //merely make the wakeup prompt. also resolves on abort/teardown so the loop can never park
339 //forever holding the reader, the SourceBuffer and the server-side ffmpeg process
340 function waitForBufferSpace() {
341 return new Promise<void>((resolve) => {
342 //every listener is registered against this controller, so one abort() detaches them all and
343 //no separate "already settled" bookkeeping is needed - resolving and clearing twice are no-ops.
344 //note the outer abort listener must be scoped to it too: that signal outlives a single wait
345 //(it is the whole track's), so a listener left on it would accumulate once per chunk
346 const controller = new AbortController();
347 const finish = () => {
348 clearInterval(timer);
349 controller.abort();
350 resolve();
351 };
352 const check = () => {
353 try {
354 if (signal.aborted || thisMediaSource.readyState !== "open" || videoElement.error) return finish();
355 if (bufferedAhead(sourceBuffer.buffered, videoElement.currentTime) < BUFFER_AHEAD_TARGET) finish();
356 } catch (_e) {
357 //reading buffered throws once the SourceBuffer is detached - nothing left to wait for
358 finish();
359 }
360 };
361 const timer = setInterval(check, BUFFER_POLL_INTERVAL);
362 videoElement.addEventListener("timeupdate", check, { signal: controller.signal });
363 videoElement.addEventListener("seeking", check, { signal: controller.signal });
364 signal.addEventListener("abort", finish, { signal: controller.signal });
365 //check once immediately, so a loop that already has room never waits an interval
366 check();
367 });
368 }
369
370 async function saveUpdate(action: () => void) {
371 while (true) {
372 // bail if the stream is gone or the media element entered a fatal error state:
373 // when videoElement.error is set the MediaSource stays "open" but every
374 // appendBuffer throws InvalidStateError, which would otherwise spin forever
375 if (thisMediaSource.readyState !== "open" || videoElement.error) return;
376 try {
377 await waitUntilUpdateDone();
378 action();
379 return;
380 } catch (e) {
381 if (!(e instanceof Error && e.name === "InvalidStateError")) throw e;
382 // an InvalidStateError while we are not updating is unrecoverable
383 // (e.g. element error / detached buffer) - don't retry into an infinite loop
384 if (!sourceBuffer.updating) return;
385 }
386 }
387 }
388
389 //drop everything more than `keepBehind` seconds before the playhead. returns false when there
390 //was nothing worth dropping, so the caller knows to stop retrying instead of spinning.
391 //minGain is the smallest removal considered worthwhile: the steady-state trim wants slack, or it
392 //would issue a remove() for a few milliseconds on every single chunk, while a quota retry passes 0
393 //because any freed byte is worth having
394 async function evictBehind(keepBehind: number, minGain: number): Promise<boolean> {
395 const ranges = sourceBuffer.buffered;
396 if (ranges.length === 0) return false;
397 const cutoff = videoElement.currentTime - keepBehind;
398 if (cutoff - ranges.start(0) <= minGain) return false;
399 await saveUpdate(() => sourceBuffer.remove(ranges.start(0), cutoff));
400 //remove() is asynchronous, so a caller about to retry an append has to wait it out
401 await waitUntilUpdateDone();
402 return true;
403 }
404
405 //what MSE actually enforces is a *byte* budget per SourceBuffer, and it varies by browser,
406 //codec and bitrate - the time window above is only a proxy for it. so treat QuotaExceededError
407 //as "make room and retry", which is what the spec intends, rather than as fatal: letting it
408 //propagate ends the stream early and truncates the track with no user-visible error
409 async function appendWithEviction(chunk: BufferSource) {
410 let keepBehind = BUFFER_BEHIND_TARGET;
411 while (true) {
412 try {
413 await saveUpdate(() => sourceBuffer.appendBuffer(chunk));
414 return;
415 } catch (e) {
416 if (!(e instanceof Error && e.name === "QuotaExceededError")) throw e;
417 //keep tightening the behind-window until a removal actually frees something. a single
418 //halving is not enough: early in a track the playhead has less than keepBehind seconds
419 //behind it, so the first cutoff lands before the buffer start and frees nothing even
420 //though a tighter one still would
421 let freed = false;
422 while (!freed && keepBehind > BUFFER_BEHIND_MIN) {
423 keepBehind = Math.max(BUFFER_BEHIND_MIN, Math.floor(keepBehind / 2));
424 freed = await evictBehind(keepBehind, 0);
425 }
426 //nothing behind the playhead left to give up - the configured bitrate simply needs more
427 //than the browser's per-SourceBuffer byte budget for BUFFER_AHEAD_TARGET seconds
428 if (!freed) throw e;
429 }
430 }
431 }
432
433 while (true) {
434 const { done, value } = await reader.read();
435 if (done || thisMediaSource.readyState !== "open") {
436 //release the connection (and the server-side ffmpeg transcode) when we stop early
437 if (!done) reader.cancel().catch(() => {});
438 break;
439 }
440
441 //a fetch body never yields SharedArrayBuffer-backed views, so narrowing to ArrayBuffer is safe
442 await appendWithEviction(value as Uint8Array<ArrayBuffer>);
443 //trim eagerly as well, so the quota is normally never reached in the first place
444 await evictBehind(BUFFER_BEHIND_TARGET, BUFFER_BEHIND_SLACK);
445 await waitForBufferSpace();
446 }
447 // Wait for buffer to finish updating
448 await waitUntilUpdateDone();
449 } catch (error) {
450 if (error instanceof Error && error.name === "QuotaExceededError") {
451 //endOfStream() below makes what is already buffered play out and then simply stop, which
452 //is indistinguishable from the track ending - so say why it was cut short
453 toast(
454 "Playback was cut short: the browser's media buffer is full, try a lower bitrate in the transcoding settings",
455 "error",
456 error,
457 );
458 } else if (!(error instanceof Error && error.name === "AbortError")) {
459 console.error(error);
460 }
461 } finally {
462 try {
463 thisMediaSource.endOfStream();
464 } catch {}
465 }
466 };
467 //once:true - a single MediaSource must only run one sourceopen handler
468 mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
469 return URL.createObjectURL(thisMediaSource);
470 }
471
472 //without this, seeking via mse will cause an infinite loop
473 let ignoreSeek = false;
474 // Play a track from the playlist
475 async function playTrack(track: PlaylistItem, seekTo?: number) {
476 const generation = ++playGeneration;
477 try {
478 const srcResult = await getSrc(track.path, track.metadata, options, isOffline(), seekTo || 0);
479 //undefined means offline and not cached, getSrc already explained that via toast
480 if (!srcResult) return;
481 const { src, isVideo } = srcResult;
482 if (generation !== playGeneration) {
483 //superseded while getSrc ran: for a synced file it already minted a blob url that
484 //nothing will ever assign to the element, so revoke it instead of leaking it
485 if (src.startsWith("blob:")) URL.revokeObjectURL(src);
486 return;
487 }
488 //Cleanup
489 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
490 if (mediaSource) {
491 //without this abort, the old fetch keeps running and the old ffmpeg instance keeps transcoding
492 mediaSourceAbortController?.abort();
493 mediaSourceAbortController = null;
494 //null out before endOfStream: it throws when readyState isn't "open", which would
495 //otherwise leave a stale non-null mediaSource and make needMSESeek misfire on later tracks
496 const oldMediaSource = mediaSource;
497 mediaSource = null;
498 try {
499 oldMediaSource.endOfStream();
500 } catch {}
501 }
502
503 const mseURL =
504 options.enableTranscoding && options.useMSE && !src.startsWith("blob:")
505 ? await getMSEUrl(src, seekTo || 0, isVideo === true)
506 : null;
507 if (generation !== playGeneration) {
508 //superseded while getMSEUrl ran: it created a detached MediaSource + object URL, but its
509 //sourceopen handler never fired (we never set videoElement.src), so no fetch/transcode was
510 //started and there's nothing to abort. Just revoke the URL and drop the now-stale module
511 //reference so a later needMSESeek can't misfire on it.
512 if (mseURL) {
513 URL.revokeObjectURL(mseURL);
514 mediaSource = null;
515 }
516 return;
517 }
518 setVideoShown(false);
519 videoElement.removeAttribute("src");
520 videoElement.srcObject = null;
521 videoElement.src = mseURL || src;
522 if (mseURL && seekTo) {
523 ignoreSeek = true;
524 videoElement.currentTime = seekTo;
525 }
526 batch(() => {
527 setPlayerState("isPlaying", true);
528 setPlayerState("currentTime", videoElement.currentTime);
529 setPlayerState("buffered", []);
530 //clone, otherwise it shares the reference
531 setPlayerState("currentTrack", { ...track });
532 setPlayerState("waitingForStart", true);
533 });
534
535 await videoElement.play();
536 } catch (e) {
537 setPlayerState("isPlaying", false);
538 handlePlayerError(e);
539 }
540 }
541
542 // Play next track
543 function playNext() {
544 const currentTrack = playerState.currentTrack;
545 if (!currentTrack) return;
546
547 let nextTrack: PlaylistItem | null = null;
548
549 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
550 if (isShuffled()) {
551 // Pick a different random track
552 if (playlist().length > 1) {
553 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
554 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
555 } else {
556 nextTrack = isRepeated() ? currentTrack : null;
557 }
558 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
559 nextTrack = playlist()[currentIndex + 1];
560 } else if (isRepeated()) {
561 nextTrack = playlist()[0];
562 }
563 if (nextTrack) {
564 playTrack(nextTrack);
565 } else {
566 stopPlaying();
567 }
568 }
569
570 // Play previous track
571 function playPrevious() {
572 const currentTrack = playerState.currentTrack;
573 if (!currentTrack) return;
574 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
575 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
576 }
577
578 // Toggle play/pause
579 function togglePlay() {
580 if (playerState.currentTrack === null) return;
581 const newIsPlaying = !playerState.isPlaying;
582 setPlayerState("isPlaying", newIsPlaying);
583
584 if (newIsPlaying) {
585 setPlayerState("waitingForStart", true);
586 videoElement
587 .play()
588 .catch((e) => {
589 setPlayerState("isPlaying", false);
590 setPlayerState("currentTime", 0);
591 handlePlayerError(e);
592 })
593 .finally(() => {
594 setPlayerState("waitingForStart", false);
595 });
596 } else {
597 videoElement.pause();
598 }
599 }
600
601 function timeRangesToArray(timeRanges: TimeRanges) {
602 const result = [];
603 for (let i = 0; i < timeRanges.length; i++) {
604 result.push({
605 start: timeRanges.start(i),
606 end: timeRanges.end(i),
607 });
608 }
609 return result;
610 }
611
612 function updateTime() {
613 setPlayerState("currentTime", videoElement.currentTime);
614 //also call updateProgress here, because some browsers send the "progress" events too slow
615 updateProgress();
616 }
617 function updateProgress() {
618 setPlayerState("buffered", reconcile(timeRangesToArray(videoElement.buffered)));
619 }
620
621 //whether the target can be played straight out of the buffer, or needs a fresh transcode seeked with -ss.
622 //"buffered" is not the same question as "playable from": a target landing on the trailing edge of a range
623 //is inside it, yet seeking there stalls with nothing ahead to play and nothing to trigger a re-fetch
624 function needMSESeek(seekTo: number) {
625 if (mediaSource === null) return false;
626 const ranges = videoElement.buffered;
627 //while the source is still open the append loop is alive, so a target near the buffer head only has to
628 //wait for the next chunk. once it is "ended" nothing more will ever arrive - endOfStream() has run, which
629 //also happens when the stream died early - and seeking to the edge would stall there for good
630 const willRefill = mediaSource.readyState === "open";
631 //the track's own duration, not mediaSource.duration: endOfStream() redefines the media end as whatever
632 //was buffered, so after a stream dies early the two are equal and a truncated track is indistinguishable
633 //from a complete one - which is exactly the case where a seek has to force a fresh transcode
634 const trackDuration = playerState.currentTrack?.metadata.duration;
635 const mediaEnd = trackDuration && Number.isFinite(trackDuration) ? trackDuration : mediaSource.duration;
636 //RANGE_START_TOLERANCE covers container/frame-alignment slack at a range start - MSE containers do not
637 //begin at exactly 0, which is why seeking to the very start used to need a special case here
638 for (let i = 0; i < ranges.length; i++) {
639 //no headroom demanded where the range already reaches the end of the media either: there is nothing
640 //further to buffer there, so requiring it would re-transcode the whole track just to seek near the end
641 const atMediaEnd = Number.isFinite(mediaEnd) && ranges.end(i) >= mediaEnd - SEEK_MIN_AHEAD;
642 const minAhead = willRefill || atMediaEnd ? 0 : SEEK_MIN_AHEAD;
643 if (seekTo >= ranges.start(i) - RANGE_START_TOLERANCE && seekTo <= ranges.end(i) - minAhead) return false;
644 }
645 return true;
646 }
647
648 // Initialize app
649 onMount(async () => {
650 // Load from localStorage
651 try {
652 const savedOptions = localStorage.getItem(LocalStorageValues.options);
653 if (savedOptions) {
654 //keep default settings when no override exists
655 setOptions({ ...options, ...JSON.parse(savedOptions) });
656 }
657 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
658 if (savedVolume && !Number.isNaN(savedVolume)) {
659 setPlayerState("volume", savedVolume);
660 }
661 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
662 if (savedPlaylist) {
663 setPlaylist(JSON.parse(savedPlaylist));
664 }
665 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
666 setShuffled(savedShuffle === "true");
667 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
668 setRepeated(savedRepeat === "true");
669 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
670 setOffline(savedOffline === "true");
671 const savedPath = localStorage.getItem(LocalStorageValues.path);
672 if (options.rememberDir && savedPath !== null && !window.location.hash) {
673 setCurrentDir(savedPath);
674 }
675 } catch (e) {
676 toast("loading values from localStorage failed", "error", e);
677 localStorage.clear();
678 }
679 // Sync to localStorage
680 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
681 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
682 createEffect(() => {
683 //a large playlist can exceed the localStorage quota; swallow the error so it doesn't
684 //propagate out of the reactive flush and break every subsequent playlist update
685 try {
686 localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist()));
687 } catch (e) {
688 console.error("Persisting playlist to localStorage failed", e);
689 }
690 });
691 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
692 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
693 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
694 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
695
696 // Set initial video element volume
697 videoElement.volume = playerState.volume;
698
699 // Set up video element event handlers
700 videoElement.addEventListener("ended", playNext);
701 videoElement.addEventListener("timeupdate", updateTime);
702 videoElement.addEventListener("progress", updateProgress);
703 videoElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
704 videoElement.addEventListener("play", () => setPlayerState("isPlaying", true));
705 videoElement.addEventListener("volumechange", () => setPlayerState("volume", videoElement.volume));
706 videoElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
707 videoElement.addEventListener("seeking", () => {
708 if (!ignoreSeek && playerState.currentTrack && needMSESeek(videoElement.currentTime)) {
709 playTrack(playerState.currentTrack, videoElement.currentTime);
710 }
711 ignoreSeek = false;
712 });
713 videoElement.addEventListener("loadedmetadata", () => {
714 setVideoShown(videoElement.videoWidth !== 0 || videoElement.videoHeight !== 0);
715 });
716 videoElement.addEventListener("click", (e) => {
717 e.preventDefault();
718 togglePlay();
719 });
720 videoElement.addEventListener("fullscreenchange", () => {
721 if (document.fullscreenElement !== videoElement) {
722 videoElement.controls = false;
723 }
724 });
725
726 window.addEventListener("hashchange", () => {
727 setCurrentDir(decodePath(window.location.hash.slice(1)));
728 });
729
730 document.addEventListener("keydown", (e) => {
731 const activeElementTag = document.activeElement?.tagName.toLowerCase();
732 if (activeElementTag === "input") {
733 return;
734 }
735 if (e.key === " " && document.fullscreenElement !== videoElement) {
736 togglePlay();
737 e.preventDefault();
738 } else if (e.key === "ArrowLeft") {
739 videoElement.currentTime -= 10;
740 e.preventDefault();
741 } else if (e.key === "ArrowRight") {
742 videoElement.currentTime += 10;
743 e.preventDefault();
744 } else if (e.key === "ArrowUp") {
745 videoElement.volume += 0.1;
746 e.preventDefault();
747 } else if (e.key === "ArrowDown") {
748 videoElement.volume -= 0.1;
749 e.preventDefault();
750 }
751 });
752 });
753
754 createEffect(() => {
755 videoElement.volume = playerState.volume;
756 videoElement.playbackRate = playerState.rate;
757 });
758
759 return (
760 <div class="app-container">
761 <div class="app-header">
762 <div style={{ "min-width": 0 }}>
763 <div class="title">Pico Pixel Player</div>
764 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
765 </div>
766
767 <div class="spacer" />
768 <div class="app-buttons">
769 <div class="signin-offline-group">
770 <SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} onLoggedIn={() => setLoggedIn(true)} />
771 <Show when={authRequired() && !isOffline()}>
772 <Show
773 when={loggedIn()}
774 fallback={
775 <button title="Sign in" onclick={() => setShowSignIn(true)}>
776 <LoginSVG />
777 </button>
778 }
779 >
780 <button title="Sign out" onclick={doLogout}>
781 <LogoutSVG />
782 </button>
783 </Show>
784 </Show>
785
786 <button
787 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
788 class="offline-button"
789 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
790 textContent={isOffline() ? "Go online" : "Go offline"}
791 onclick={() => setOffline(!isOffline())}
792 />
793 </div>
794 <div>
795 <button
796 title="Toggle File Browser"
797 class="filebrowser-toggle"
798 onClick={() => setShowFileBrowser((prev) => !prev)}
799 >
800 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
801 </button>
802 <button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
803 <DownloadManagerSVG />
804 </button>
805 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
806 <PlaylistSVG />
807 </button>
808 <button
809 title="Toggle Fullscreen"
810 onClick={() =>
811 document.fullscreenElement
812 ? document.exitFullscreen()
813 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
814 }
815 >
816 <FullscreenSVG />
817 </button>
818 <button title="Settings" onClick={() => setShowOptions(true)}>
819 <CogSVG />
820 </button>
821 </div>
822 </div>
823 </div>
824
825 <div class="main-content">
826 <FileBrowser
827 breadcrumbs={breadcrumbs}
828 currentDir={currentDir}
829 setCurrentDir={setCurrentDir}
830 playTrack={playTrack}
831 playlist={playlist}
832 setPlaylist={setPlaylist}
833 showFileBrowser={showFileBrowser}
834 setShowFileBrowser={setShowFileBrowser}
835 options={options}
836 isOffline={isOffline}
837 files={files}
838 fetchFiles={fetchFiles}
839 />
840
841 <Playlist
842 playlist={playlist}
843 setPlaylist={setPlaylist}
844 playerState={playerState}
845 setPlayerState={setPlayerState}
846 playTrack={playTrack}
847 playNext={playNext}
848 videoElement={(el) => {
849 videoElement = el;
850 }}
851 videoShown={videoShown}
852 options={options}
853 fetchFiles={fetchFiles}
854 isOffline={isOffline}
855 />
856 </div>
857
858 <PlayerControls
859 playerState={playerState}
860 setPlayerState={setPlayerState}
861 togglePlay={togglePlay}
862 playPrevious={playPrevious}
863 playNext={playNext}
864 isShuffled={isShuffled}
865 setShuffled={setShuffled}
866 isRepeated={isRepeated}
867 setRepeated={setRepeated}
868 videoElement={videoElement}
869 needMSESeek={(seekTo: number) => needMSESeek(seekTo)}
870 seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
871 options={options}
872 />
873
874 <OptionsModal
875 showOptions={showOptions}
876 setShowOptions={setShowOptions}
877 options={options}
878 setOptions={setOptions}
879 />
880
881 <PlaylistManager
882 showPlaylistManager={showPlaylistManager}
883 setShowPlaylistManager={setShowPlaylistManager}
884 playlist={playlist}
885 setPlaylist={setPlaylist}
886 playTrack={playTrack}
887 options={options}
888 />
889 <DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
890 </div>
891 );
892};
893
894export default App;
895