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