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