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