feat(web): stream resilience — preload=auto, stall recovery, next-track prefetch
Phases 1+2 of the background-play resilience work. Net effect on desktop: gap-free track transitions on slow networks, automatic recovery from transient stalls / network errors mid-track. - preload="metadata" → "auto" on the main audio element so the browser pre-buffers more aggressively. - attachStallRetry() — listens for stalled / waiting / network-error events. Schedules a reload-from-current-position after delayMs (3s default) if the buffer hasn't recovered. MEDIA_ERR_NETWORK triggers immediate retry. Bounded by maxRetries (3 default) to prevent tight loops on persistent failures. - audioLoader seam — `lib/player/audioLoader.ts` exposes a resolve(track) → URL + optional prefetch(track) hint. Default returns track.stream_url; the planned Tauri shell can swap via setAudioLoader() to return blob URLs backed by a Rust cache, mirroring the Flutter drift+LockCachingAudioSource pattern. - Hidden prefetch <audio> element. When current track passes 50%, start loading queue[index+1]. On track-end the swap is gap-free via browser HTTP cache + audio buffer warm-up. Phase 3 (service worker chunk cache) deliberately deferred — that work belongs in the Tauri shell as native Rust caching, not as a web-only investment that would compete with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,12 @@
|
||||
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
||||
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||
import { audioLoader } from '$lib/player/audioLoader';
|
||||
import { attachStallRetry } from '$lib/player/stallRetry';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
let audioEl: HTMLAudioElement | undefined = $state();
|
||||
let prefetchEl: HTMLAudioElement | undefined = $state();
|
||||
|
||||
// Reactive guard: runs every time page.url or user.value changes.
|
||||
$effect(() => {
|
||||
@@ -42,9 +45,16 @@
|
||||
return () => registerAudioEl(null);
|
||||
});
|
||||
|
||||
// Auto-recovery for transient network failures during playback.
|
||||
$effect(() => {
|
||||
if (!audioEl) return;
|
||||
const src = player.current?.stream_url;
|
||||
return attachStallRetry(audioEl);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!audioEl) return;
|
||||
const cur = player.current;
|
||||
const src = cur ? audioLoader.resolve(cur) : null;
|
||||
if (src) {
|
||||
const absolute = new URL(src, window.location.origin).href;
|
||||
if (audioEl.src !== absolute) audioEl.src = src;
|
||||
@@ -54,6 +64,27 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 2 prefetch: when the current track passes the halfway mark,
|
||||
// start loading the next track's bytes into a hidden <audio> element.
|
||||
// The browser HTTP cache + audio buffer warm-up means the swap on
|
||||
// track-end is gap-free on slow networks. Reset src when no next
|
||||
// track exists or the index advances.
|
||||
$effect(() => {
|
||||
if (!prefetchEl) return;
|
||||
const next = player.queue[player.index + 1];
|
||||
const ratio = player.duration > 0 ? player.position / player.duration : 0;
|
||||
if (next && ratio >= 0.5) {
|
||||
const nextSrc = audioLoader.resolve(next);
|
||||
if (prefetchEl.src !== new URL(nextSrc, window.location.origin).href) {
|
||||
prefetchEl.src = nextSrc;
|
||||
audioLoader.prefetch?.(next);
|
||||
}
|
||||
} else if (!next && prefetchEl.src) {
|
||||
prefetchEl.removeAttribute('src');
|
||||
prefetchEl.load();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (audioEl) audioEl.volume = player.volume;
|
||||
});
|
||||
@@ -87,7 +118,7 @@
|
||||
|
||||
<audio
|
||||
bind:this={audioEl}
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
|
||||
onloadedmetadata={() => {
|
||||
if (!audioEl) return;
|
||||
@@ -104,6 +135,17 @@
|
||||
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
|
||||
></audio>
|
||||
|
||||
<!-- Hidden prefetch element. Loads the next track's bytes ahead of
|
||||
time so the swap on track-end is gap-free. Muted + display:none
|
||||
so it can't be played or interacted with directly. -->
|
||||
<audio
|
||||
bind:this={prefetchEl}
|
||||
preload="auto"
|
||||
muted
|
||||
aria-hidden="true"
|
||||
style="display: none"
|
||||
></audio>
|
||||
|
||||
<QueueDrawer />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
Reference in New Issue
Block a user