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