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