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 } 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 let mimeType: string;
296 if (isVideo) {
297 const metadata = playerState.currentTrack?.metadata;
298 const videoStreamInfo =
299 metadata?.videoWidth && metadata.videoHeight && metadata.frameRate
300 ? {
301 sourceWidth: metadata.videoWidth,
302 sourceHeight: metadata.videoHeight,
303 frameRate: metadata.frameRate,
304 interlaced: metadata.interlaced === true,
305 bitrate: metadata.videoBitrate
306 ? Math.min(options.videoSettings.videoBitrate, metadata.videoBitrate)
307 : options.videoSettings.videoBitrate,
308 }
309 : undefined;
310 mimeType = createRFC6381String(
311 options.videoSettings.container,
312 options.videoSettings.videoCodec,
313 options.videoSettings.audioCodec,
314 videoStreamInfo,
315 );
316 } else {
317 mimeType = createRFC6381String(
318 options.audioSettings.container,
319 VideoCodec.none,
320 options.audioSettings.audioCodec,
321 );
322 }
323
324 if (!MediaSource.isTypeSupported(mimeType)) return null;
325 mediaSource = new MediaSource();
326 const thisMediaSource = mediaSource;
327 const onSourceOpen = async () => {
328 if (!thisMediaSource) return;
329
330 const sourceBuffer = thisMediaSource.addSourceBuffer(mimeType);
331 thisMediaSource.duration = playerState.currentTrack?.metadata.duration || 0;
332 sourceBuffer.mode = "sequence";
333 sourceBuffer.timestampOffset = offsetSeconds;
334
335 try {
336 mediaSourceAbortController = new AbortController();
337 const signal = mediaSourceAbortController.signal;
338 const response = await fetch(srcUrl, { credentials: "same-origin", signal });
339 if (handleUnauthorized(response)) return;
340 if (!response.ok || !response.body) throw new Error("Failed to fetch stream");
341 const reader = response.body.getReader();
342
343 function waitUntilUpdateDone() {
344 return new Promise((resolve) => {
345 if (!sourceBuffer.updating) return resolve(undefined);
346 sourceBuffer.addEventListener("updateend", resolve, { once: true });
347 });
348 }
349
350 //park the append loop until there is room ahead of the playhead again. the interval is what
351 //guarantees a wakeup: timeupdate does not fire at all while the element is paused (and is only
352 //loosely specified otherwise), so it must not be the sole liveness signal - it and seeking
353 //merely make the wakeup prompt. also resolves on abort/teardown so the loop can never park
354 //forever holding the reader, the SourceBuffer and the server-side ffmpeg process
355 function waitForBufferSpace() {
356 return new Promise<void>((resolve) => {
357 //every listener is registered against this controller, so one abort() detaches them all and
358 //no separate "already settled" bookkeeping is needed - resolving and clearing twice are no-ops.
359 //note the outer abort listener must be scoped to it too: that signal outlives a single wait
360 //(it is the whole track's), so a listener left on it would accumulate once per chunk
361 const controller = new AbortController();
362 const finish = () => {
363 clearInterval(timer);
364 controller.abort();
365 resolve();
366 };
367 const check = () => {
368 try {
369 if (signal.aborted || thisMediaSource.readyState !== "open" || videoElement.error) return finish();
370 if (bufferedAhead(sourceBuffer.buffered, videoElement.currentTime) < BUFFER_AHEAD_TARGET) finish();
371 } catch (_e) {
372 //reading buffered throws once the SourceBuffer is detached - nothing left to wait for
373 finish();
374 }
375 };
376 const timer = setInterval(check, BUFFER_POLL_INTERVAL);
377 videoElement.addEventListener("timeupdate", check, { signal: controller.signal });
378 videoElement.addEventListener("seeking", check, { signal: controller.signal });
379 signal.addEventListener("abort", finish, { signal: controller.signal });
380 //check once immediately, so a loop that already has room never waits an interval
381 check();
382 });
383 }
384
385 async function saveUpdate(action: () => void) {
386 while (true) {
387 // bail if the stream is gone or the media element entered a fatal error state:
388 // when videoElement.error is set the MediaSource stays "open" but every
389 // appendBuffer throws InvalidStateError, which would otherwise spin forever
390 if (thisMediaSource.readyState !== "open" || videoElement.error) return;
391 try {
392 await waitUntilUpdateDone();
393 action();
394 return;
395 } catch (e) {
396 if (!(e instanceof Error && e.name === "InvalidStateError")) throw e;
397 // an InvalidStateError while we are not updating is unrecoverable
398 // (e.g. element error / detached buffer) - don't retry into an infinite loop
399 if (!sourceBuffer.updating) return;
400 }
401 }
402 }
403
404 //drop everything more than `keepBehind` seconds before the playhead. returns false when there
405 //was nothing worth dropping, so the caller knows to stop retrying instead of spinning.
406 //minGain is the smallest removal considered worthwhile: the steady-state trim wants slack, or it
407 //would issue a remove() for a few milliseconds on every single chunk, while a quota retry passes 0
408 //because any freed byte is worth having
409 async function evictBehind(keepBehind: number, minGain: number): Promise<boolean> {
410 const ranges = sourceBuffer.buffered;
411 if (ranges.length === 0) return false;
412 const cutoff = videoElement.currentTime - keepBehind;
413 if (cutoff - ranges.start(0) <= minGain) return false;
414 await saveUpdate(() => sourceBuffer.remove(ranges.start(0), cutoff));
415 //remove() is asynchronous, so a caller about to retry an append has to wait it out
416 await waitUntilUpdateDone();
417 return true;
418 }
419
420 //what MSE actually enforces is a *byte* budget per SourceBuffer, and it varies by browser,
421 //codec and bitrate - the time window above is only a proxy for it. so treat QuotaExceededError
422 //as "make room and retry", which is what the spec intends, rather than as fatal: letting it
423 //propagate ends the stream early and truncates the track with no user-visible error
424 async function appendWithEviction(chunk: BufferSource) {
425 let keepBehind = BUFFER_BEHIND_TARGET;
426 while (true) {
427 try {
428 await saveUpdate(() => sourceBuffer.appendBuffer(chunk));
429 return;
430 } catch (e) {
431 if (!(e instanceof Error && e.name === "QuotaExceededError")) throw e;
432 //keep tightening the behind-window until a removal actually frees something. a single
433 //halving is not enough: early in a track the playhead has less than keepBehind seconds
434 //behind it, so the first cutoff lands before the buffer start and frees nothing even
435 //though a tighter one still would
436 let freed = false;
437 while (!freed && keepBehind > BUFFER_BEHIND_MIN) {
438 keepBehind = Math.max(BUFFER_BEHIND_MIN, Math.floor(keepBehind / 2));
439 freed = await evictBehind(keepBehind, 0);
440 }
441 //nothing behind the playhead left to give up - the configured bitrate simply needs more
442 //than the browser's per-SourceBuffer byte budget for BUFFER_AHEAD_TARGET seconds
443 if (!freed) throw e;
444 }
445 }
446 }
447
448 while (true) {
449 const { done, value } = await reader.read();
450 if (done || thisMediaSource.readyState !== "open") {
451 //release the connection (and the server-side ffmpeg transcode) when we stop early
452 if (!done) reader.cancel().catch(() => {});
453 break;
454 }
455
456 //a fetch body never yields SharedArrayBuffer-backed views, so narrowing to ArrayBuffer is safe
457 await appendWithEviction(value as Uint8Array<ArrayBuffer>);
458 //trim eagerly as well, so the quota is normally never reached in the first place
459 await evictBehind(BUFFER_BEHIND_TARGET, BUFFER_BEHIND_SLACK);
460 await waitForBufferSpace();
461 }
462 // Wait for buffer to finish updating
463 await waitUntilUpdateDone();
464 } catch (error) {
465 if (error instanceof Error && error.name === "QuotaExceededError") {
466 //endOfStream() below makes what is already buffered play out and then simply stop, which
467 //is indistinguishable from the track ending - so say why it was cut short
468 toast(
469 "Playback was cut short: the browser's media buffer is full, try a lower bitrate in the transcoding settings",
470 "error",
471 error,
472 );
473 } else if (!(error instanceof Error && error.name === "AbortError")) {
474 console.error(error);
475 }
476 } finally {
477 try {
478 thisMediaSource.endOfStream();
479 } catch {}
480 }
481 };
482 //once:true - a single MediaSource must only run one sourceopen handler
483 mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
484 return URL.createObjectURL(thisMediaSource);
485 }
486
487 //without this, seeking via mse will cause an infinite loop
488 let ignoreSeek = false;
489 // Play a track from the playlist
490 async function playTrack(track: PlaylistItem, seekTo?: number) {
491 const generation = ++playGeneration;
492 try {
493 const srcResult = await getSrc(track.path, track.metadata, options, isOffline(), seekTo || 0);
494 //undefined means offline and not cached, getSrc already explained that via toast
495 if (!srcResult) return;
496 const { src, isVideo } = srcResult;
497 if (generation !== playGeneration) {
498 //superseded while getSrc ran: for a synced file it already minted a blob url that
499 //nothing will ever assign to the element, so revoke it instead of leaking it
500 if (src.startsWith("blob:")) URL.revokeObjectURL(src);
501 return;
502 }
503 //Cleanup
504 if (videoElement.src.startsWith("blob:")) URL.revokeObjectURL(videoElement.src);
505 if (mediaSource) {
506 //without this abort, the old fetch keeps running and the old ffmpeg instance keeps transcoding
507 mediaSourceAbortController?.abort();
508 mediaSourceAbortController = null;
509 //null out before endOfStream: it throws when readyState isn't "open", which would
510 //otherwise leave a stale non-null mediaSource and make needMSESeek misfire on later tracks
511 const oldMediaSource = mediaSource;
512 mediaSource = null;
513 try {
514 oldMediaSource.endOfStream();
515 } catch {}
516 }
517
518 //each transcoding profile carries its own streaming mode, so which one applies depends on what
519 //is being played. a blob url is an already-synced file, which is never streamed
520 const streamingMode = isVideo ? options.videoSettings.streamingMode : options.audioSettings.streamingMode;
521 const mseURL =
522 options.enableTranscoding && streamingMode === StreamingMode.mse && !src.startsWith("blob:")
523 ? await getMSEUrl(src, seekTo || 0, isVideo === true)
524 : null;
525 if (generation !== playGeneration) {
526 //superseded while getMSEUrl ran: it created a detached MediaSource + object URL, but its
527 //sourceopen handler never fired (we never set videoElement.src), so no fetch/transcode was
528 //started and there's nothing to abort. Just revoke the URL and drop the now-stale module
529 //reference so a later needMSESeek can't misfire on it.
530 if (mseURL) {
531 URL.revokeObjectURL(mseURL);
532 mediaSource = null;
533 }
534 return;
535 }
536 setVideoShown(false);
537 videoElement.removeAttribute("src");
538 videoElement.srcObject = null;
539 videoElement.src = mseURL || src;
540 if (mseURL && seekTo) {
541 ignoreSeek = true;
542 videoElement.currentTime = seekTo;
543 }
544 batch(() => {
545 setPlayerState("isPlaying", true);
546 setPlayerState("currentTime", videoElement.currentTime);
547 setPlayerState("buffered", []);
548 //clone, otherwise it shares the reference
549 setPlayerState("currentTrack", { ...track });
550 setPlayerState("waitingForStart", true);
551 });
552
553 await videoElement.play();
554 } catch (e) {
555 setPlayerState("isPlaying", false);
556 handlePlayerError(e);
557 }
558 }
559
560 // Play next track
561 function playNext() {
562 const currentTrack = playerState.currentTrack;
563 if (!currentTrack) return;
564
565 let nextTrack: PlaylistItem | null = null;
566
567 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
568 if (isShuffled()) {
569 // Pick a different random track
570 if (playlist().length > 1) {
571 const otherTracks = playlist().filter((_, idx) => idx !== currentIndex);
572 nextTrack = otherTracks[Math.floor(Math.random() * otherTracks.length)];
573 } else {
574 nextTrack = isRepeated() ? currentTrack : null;
575 }
576 } else if (currentIndex >= 0 && currentIndex < playlist().length - 1) {
577 nextTrack = playlist()[currentIndex + 1];
578 } else if (isRepeated()) {
579 nextTrack = playlist()[0];
580 }
581 if (nextTrack) {
582 playTrack(nextTrack);
583 } else {
584 stopPlaying();
585 }
586 }
587
588 // Play previous track
589 function playPrevious() {
590 const currentTrack = playerState.currentTrack;
591 if (!currentTrack) return;
592 const currentIndex = playlist().findIndex((item) => item.id === currentTrack.id);
593 if (currentIndex > 0) playTrack(playlist()[currentIndex - 1]);
594 }
595
596 // Toggle play/pause
597 function togglePlay() {
598 if (playerState.currentTrack === null) return;
599 const newIsPlaying = !playerState.isPlaying;
600 setPlayerState("isPlaying", newIsPlaying);
601
602 if (newIsPlaying) {
603 setPlayerState("waitingForStart", true);
604 videoElement
605 .play()
606 .catch((e) => {
607 setPlayerState("isPlaying", false);
608 setPlayerState("currentTime", 0);
609 handlePlayerError(e);
610 })
611 .finally(() => {
612 setPlayerState("waitingForStart", false);
613 });
614 } else {
615 videoElement.pause();
616 }
617 }
618
619 function timeRangesToArray(timeRanges: TimeRanges) {
620 const result = [];
621 for (let i = 0; i < timeRanges.length; i++) {
622 result.push({
623 start: timeRanges.start(i),
624 end: timeRanges.end(i),
625 });
626 }
627 return result;
628 }
629
630 function updateTime() {
631 setPlayerState("currentTime", videoElement.currentTime);
632 //also call updateProgress here, because some browsers send the "progress" events too slow
633 updateProgress();
634 }
635 function updateProgress() {
636 setPlayerState("buffered", reconcile(timeRangesToArray(videoElement.buffered)));
637 }
638
639 //whether the target can be played straight out of the buffer, or needs a fresh transcode seeked with -ss.
640 //"buffered" is not the same question as "playable from": a target landing on the trailing edge of a range
641 //is inside it, yet seeking there stalls with nothing ahead to play and nothing to trigger a re-fetch
642 function needMSESeek(seekTo: number) {
643 if (mediaSource === null) return false;
644 const ranges = videoElement.buffered;
645 //while the source is still open the append loop is alive, so a target near the buffer head only has to
646 //wait for the next chunk. once it is "ended" nothing more will ever arrive - endOfStream() has run, which
647 //also happens when the stream died early - and seeking to the edge would stall there for good
648 const willRefill = mediaSource.readyState === "open";
649 //the track's own duration, not mediaSource.duration: endOfStream() redefines the media end as whatever
650 //was buffered, so after a stream dies early the two are equal and a truncated track is indistinguishable
651 //from a complete one - which is exactly the case where a seek has to force a fresh transcode
652 const trackDuration = playerState.currentTrack?.metadata.duration;
653 const mediaEnd = trackDuration && Number.isFinite(trackDuration) ? trackDuration : mediaSource.duration;
654 //RANGE_START_TOLERANCE covers container/frame-alignment slack at a range start - MSE containers do not
655 //begin at exactly 0, which is why seeking to the very start used to need a special case here
656 for (let i = 0; i < ranges.length; i++) {
657 //no headroom demanded where the range already reaches the end of the media either: there is nothing
658 //further to buffer there, so requiring it would re-transcode the whole track just to seek near the end
659 const atMediaEnd = Number.isFinite(mediaEnd) && ranges.end(i) >= mediaEnd - SEEK_MIN_AHEAD;
660 const minAhead = willRefill || atMediaEnd ? 0 : SEEK_MIN_AHEAD;
661 if (seekTo >= ranges.start(i) - RANGE_START_TOLERANCE && seekTo <= ranges.end(i) - minAhead) return false;
662 }
663 return true;
664 }
665
666 // Initialize app
667 onMount(async () => {
668 // Load from localStorage
669 try {
670 const savedOptions = localStorage.getItem(LocalStorageValues.options);
671 if (savedOptions) {
672 //keep default settings when no override exists. the two transcoding profiles have to be merged
673 //one level deeper than the rest: a spread would replace them wholesale, so a setting added
674 //after the options were last saved would come back undefined instead of taking its default
675 const saved = JSON.parse(savedOptions) as Partial<AppOptions>;
676 //playableSettings, because nothing downstream re-checks: the transcode url is built straight
677 //from these, so a combination this browser cannot play would reach the element as a format
678 //error with no explanation, and one with an unrecognised container would throw on sight
679 setOptions({
680 ...options,
681 ...saved,
682 videoSettings: playableSettings({ ...options.videoSettings, ...saved.videoSettings }),
683 audioSettings: playableSettings({ ...options.audioSettings, ...saved.audioSettings }),
684 });
685 }
686 const savedVolume = Number.parseFloat(localStorage.getItem(LocalStorageValues.volume) ?? "");
687 if (savedVolume && !Number.isNaN(savedVolume)) {
688 setPlayerState("volume", savedVolume);
689 }
690 const savedPlaylist = localStorage.getItem(LocalStorageValues.playlist);
691 if (savedPlaylist) {
692 setPlaylist(JSON.parse(savedPlaylist));
693 }
694 const savedShuffle = localStorage.getItem(LocalStorageValues.shuffle);
695 setShuffled(savedShuffle === "true");
696 const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
697 setRepeated(savedRepeat === "true");
698 const savedOffline = localStorage.getItem(LocalStorageValues.offline);
699 setOffline(savedOffline === "true");
700 const savedPath = localStorage.getItem(LocalStorageValues.path);
701 if (options.rememberDir && savedPath !== null && !window.location.hash) {
702 setCurrentDir(savedPath);
703 }
704 } catch (e) {
705 toast("loading values from localStorage failed", "error", e);
706 localStorage.clear();
707 }
708 // Sync to localStorage
709 createEffect(() => localStorage.setItem(LocalStorageValues.options, JSON.stringify(options)));
710 createEffect(() => localStorage.setItem(LocalStorageValues.volume, playerState.volume.toString()));
711 createEffect(() => {
712 //a large playlist can exceed the localStorage quota; swallow the error so it doesn't
713 //propagate out of the reactive flush and break every subsequent playlist update
714 try {
715 localStorage.setItem(LocalStorageValues.playlist, JSON.stringify(playlist()));
716 } catch (e) {
717 console.error("Persisting playlist to localStorage failed", e);
718 }
719 });
720 createEffect(() => localStorage.setItem(LocalStorageValues.shuffle, isShuffled().toString()));
721 createEffect(() => localStorage.setItem(LocalStorageValues.repeat, isRepeated().toString()));
722 createEffect(() => localStorage.setItem(LocalStorageValues.path, currentDir()));
723 createEffect(() => localStorage.setItem(LocalStorageValues.offline, isOffline().toString()));
724
725 // Set initial video element volume
726 videoElement.volume = playerState.volume;
727
728 // Set up video element event handlers
729 videoElement.addEventListener("ended", playNext);
730 videoElement.addEventListener("timeupdate", updateTime);
731 videoElement.addEventListener("progress", updateProgress);
732 videoElement.addEventListener("pause", () => setPlayerState("isPlaying", false));
733 videoElement.addEventListener("play", () => setPlayerState("isPlaying", true));
734 videoElement.addEventListener("volumechange", () => setPlayerState("volume", videoElement.volume));
735 videoElement.addEventListener("canplay", () => setPlayerState("waitingForStart", false));
736 videoElement.addEventListener("seeking", () => {
737 if (!ignoreSeek && playerState.currentTrack && needMSESeek(videoElement.currentTime)) {
738 playTrack(playerState.currentTrack, videoElement.currentTime);
739 }
740 ignoreSeek = false;
741 });
742 videoElement.addEventListener("loadedmetadata", () => {
743 setVideoShown(videoElement.videoWidth !== 0 || videoElement.videoHeight !== 0);
744 });
745 videoElement.addEventListener("click", (e) => {
746 e.preventDefault();
747 togglePlay();
748 });
749 videoElement.addEventListener("fullscreenchange", () => {
750 if (document.fullscreenElement !== videoElement) {
751 videoElement.controls = false;
752 }
753 });
754
755 window.addEventListener("hashchange", () => {
756 setCurrentDir(decodePath(window.location.hash.slice(1)));
757 });
758
759 document.addEventListener("keydown", (e) => {
760 const activeElementTag = document.activeElement?.tagName.toLowerCase();
761 if (activeElementTag === "input") {
762 return;
763 }
764 if (e.key === " " && document.fullscreenElement !== videoElement) {
765 togglePlay();
766 e.preventDefault();
767 } else if (e.key === "ArrowLeft") {
768 videoElement.currentTime -= 10;
769 e.preventDefault();
770 } else if (e.key === "ArrowRight") {
771 videoElement.currentTime += 10;
772 e.preventDefault();
773 } else if (e.key === "ArrowUp") {
774 videoElement.volume += 0.1;
775 e.preventDefault();
776 } else if (e.key === "ArrowDown") {
777 videoElement.volume -= 0.1;
778 e.preventDefault();
779 }
780 });
781 });
782
783 createEffect(() => {
784 videoElement.volume = playerState.volume;
785 videoElement.playbackRate = playerState.rate;
786 });
787
788 return (
789 <div class="app-container">
790 <div class="app-header">
791 <div style={{ "min-width": 0 }}>
792 <div class="title">Pico Pixel Player</div>
793 <sub style={{ display: "block", "line-height": "1em" }}>{BUILD_HASH}</sub>
794 </div>
795
796 <div class="spacer" />
797 <div class="app-buttons">
798 <div class="signin-offline-group">
799 <SignIn showSignIn={showSignIn} setShowSignIn={setShowSignIn} onLoggedIn={() => setLoggedIn(true)} />
800 <Show when={authRequired() && !isOffline()}>
801 <Show
802 when={loggedIn()}
803 fallback={
804 <button title="Sign in" onclick={() => setShowSignIn(true)}>
805 <LoginSVG />
806 </button>
807 }
808 >
809 <button title="Sign out" onclick={doLogout}>
810 <LogoutSVG />
811 </button>
812 </Show>
813 </Show>
814
815 <button
816 title="Switch between online and offline mode. In offline mode only locally cached files are listed"
817 class="offline-button"
818 style={{ background: isOffline() ? "lightgreen" : "indianred" }}
819 textContent={isOffline() ? "Go online" : "Go offline"}
820 onclick={() => setOffline(!isOffline())}
821 />
822 </div>
823 <div>
824 <button
825 title="Toggle File Browser"
826 class="filebrowser-toggle"
827 onClick={() => setShowFileBrowser((prev) => !prev)}
828 >
829 {showFileBrowser() ? <OpenFolderSVG /> : <ClosedFolderSVG forButton={true} />}
830 </button>
831 <button title="Manage Downloads" onClick={() => setShowDownloadManager(true)}>
832 <DownloadManagerSVG />
833 </button>
834 <button title="Manage playlists" onClick={() => setShowPlaylistManager(true)}>
835 <PlaylistSVG />
836 </button>
837 <button
838 title="Toggle Fullscreen"
839 onClick={() =>
840 document.fullscreenElement
841 ? document.exitFullscreen()
842 : document.documentElement.requestFullscreen({ navigationUI: "hide" })
843 }
844 >
845 <FullscreenSVG />
846 </button>
847 <button title="Settings" onClick={() => setShowOptions(true)}>
848 <CogSVG />
849 </button>
850 </div>
851 </div>
852 </div>
853
854 <div class="main-content">
855 <FileBrowser
856 breadcrumbs={breadcrumbs}
857 currentDir={currentDir}
858 setCurrentDir={setCurrentDir}
859 playTrack={playTrack}
860 playlist={playlist}
861 setPlaylist={setPlaylist}
862 showFileBrowser={showFileBrowser}
863 setShowFileBrowser={setShowFileBrowser}
864 options={options}
865 isOffline={isOffline}
866 files={files}
867 fetchFiles={fetchFiles}
868 />
869
870 <Playlist
871 playlist={playlist}
872 setPlaylist={setPlaylist}
873 playerState={playerState}
874 setPlayerState={setPlayerState}
875 playTrack={playTrack}
876 playNext={playNext}
877 videoElement={(el) => {
878 videoElement = el;
879 }}
880 videoShown={videoShown}
881 options={options}
882 fetchFiles={fetchFiles}
883 isOffline={isOffline}
884 />
885 </div>
886
887 <PlayerControls
888 playerState={playerState}
889 setPlayerState={setPlayerState}
890 togglePlay={togglePlay}
891 playPrevious={playPrevious}
892 playNext={playNext}
893 isShuffled={isShuffled}
894 setShuffled={setShuffled}
895 isRepeated={isRepeated}
896 setRepeated={setRepeated}
897 videoElement={videoElement}
898 needMSESeek={(seekTo: number) => needMSESeek(seekTo)}
899 seekTo={(seekTo: number) => playerState.currentTrack && playTrack(playerState.currentTrack, seekTo)}
900 options={options}
901 />
902
903 <OptionsModal
904 showOptions={showOptions}
905 setShowOptions={setShowOptions}
906 options={options}
907 setOptions={setOptions}
908 />
909
910 <PlaylistManager
911 showPlaylistManager={showPlaylistManager}
912 setShowPlaylistManager={setShowPlaylistManager}
913 playlist={playlist}
914 setPlaylist={setPlaylist}
915 playTrack={playTrack}
916 options={options}
917 />
918 <DownloadManager showDownloadManager={showDownloadManager} setShowDownloadManager={setShowDownloadManager} />
919 </div>
920 );
921};
922
923export default App;
924