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