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