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