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:
@@ -0,0 +1,52 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
import { audioLoader, setAudioLoader, __resetAudioLoader } from './audioLoader';
|
||||||
|
|
||||||
|
const sampleTrack: TrackRef = {
|
||||||
|
id: 't1',
|
||||||
|
title: 'Sample',
|
||||||
|
album_id: 'a1',
|
||||||
|
album_title: 'Album',
|
||||||
|
artist_id: 'ar1',
|
||||||
|
artist_name: 'Artist',
|
||||||
|
duration_sec: 180,
|
||||||
|
stream_url: '/api/tracks/t1/stream'
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetAudioLoader();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('audioLoader', () => {
|
||||||
|
test('default resolve returns the track stream_url', () => {
|
||||||
|
expect(audioLoader.resolve(sampleTrack)).toBe('/api/tracks/t1/stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default prefetch is a no-op (does not throw)', () => {
|
||||||
|
expect(() => audioLoader.prefetch?.(sampleTrack)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setAudioLoader swaps the resolver', () => {
|
||||||
|
setAudioLoader({
|
||||||
|
resolve: (t) => `blob:cached-${t.id}`
|
||||||
|
});
|
||||||
|
expect(audioLoader.resolve(sampleTrack)).toBe('blob:cached-t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setAudioLoader swaps the prefetch hook', () => {
|
||||||
|
const spy = vi.fn();
|
||||||
|
setAudioLoader({
|
||||||
|
resolve: (t) => t.stream_url,
|
||||||
|
prefetch: spy
|
||||||
|
});
|
||||||
|
audioLoader.prefetch?.(sampleTrack);
|
||||||
|
expect(spy).toHaveBeenCalledWith(sampleTrack);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('__resetAudioLoader restores the default', () => {
|
||||||
|
setAudioLoader({ resolve: () => 'blob:swapped' });
|
||||||
|
expect(audioLoader.resolve(sampleTrack)).toBe('blob:swapped');
|
||||||
|
__resetAudioLoader();
|
||||||
|
expect(audioLoader.resolve(sampleTrack)).toBe('/api/tracks/t1/stream');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Audio loader seam. Today: returns the server-relative stream URL
|
||||||
|
// directly. In the future Tauri shell, the loader can be swapped to
|
||||||
|
// return blob URLs backed by a Rust-side cache (mirroring the Flutter
|
||||||
|
// drift+LockCachingAudioSource pattern), without touching the player UI.
|
||||||
|
//
|
||||||
|
// Two operations:
|
||||||
|
// - resolve(track) — returns the URL the <audio> element should src.
|
||||||
|
// - prefetch(track) — hint that a track will be needed soon. Default
|
||||||
|
// no-op (the browser handles it via the prefetch <audio> element);
|
||||||
|
// Tauri can warm its native cache here.
|
||||||
|
|
||||||
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
export type AudioLoader = {
|
||||||
|
resolve: (track: TrackRef) => string;
|
||||||
|
prefetch?: (track: TrackRef) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultLoader: AudioLoader = {
|
||||||
|
resolve: (track) => track.stream_url
|
||||||
|
};
|
||||||
|
|
||||||
|
let _current: AudioLoader = defaultLoader;
|
||||||
|
|
||||||
|
export const audioLoader: AudioLoader = {
|
||||||
|
resolve: (track) => _current.resolve(track),
|
||||||
|
prefetch: (track) => _current.prefetch?.(track)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the active loader. Call once at startup from the Tauri shell;
|
||||||
|
* web shell uses the default and never calls this.
|
||||||
|
*/
|
||||||
|
export function setAudioLoader(loader: AudioLoader): void {
|
||||||
|
_current = loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: restore the default loader between tests. */
|
||||||
|
export function __resetAudioLoader(): void {
|
||||||
|
_current = defaultLoader;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { attachStallRetry } from './stallRetry';
|
||||||
|
|
||||||
|
// Minimal HTMLAudioElement stand-in for vitest. Real DOM elements
|
||||||
|
// are over-featured; we only need the methods + addEventListener.
|
||||||
|
function makeAudioStub() {
|
||||||
|
const handlers = new Map<string, Set<EventListener>>();
|
||||||
|
const stub = {
|
||||||
|
currentTime: 0,
|
||||||
|
paused: false,
|
||||||
|
readyState: 4,
|
||||||
|
error: null as MediaError | null,
|
||||||
|
load: vi.fn(),
|
||||||
|
play: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addEventListener(type: string, fn: EventListener) {
|
||||||
|
if (!handlers.has(type)) handlers.set(type, new Set());
|
||||||
|
handlers.get(type)!.add(fn);
|
||||||
|
},
|
||||||
|
removeEventListener(type: string, fn: EventListener) {
|
||||||
|
handlers.get(type)?.delete(fn);
|
||||||
|
},
|
||||||
|
fire(type: string) {
|
||||||
|
handlers.get(type)?.forEach((fn) => fn(new Event(type)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('attachStallRetry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stalled event triggers reload after delayMs when buffer not recovered', () => {
|
||||||
|
const el = makeAudioStub();
|
||||||
|
el.currentTime = 42;
|
||||||
|
el.readyState = 1; // not yet HAVE_FUTURE_DATA
|
||||||
|
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
|
||||||
|
|
||||||
|
el.fire('stalled');
|
||||||
|
expect(el.load).not.toHaveBeenCalled(); // not yet — waiting
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(el.load).toHaveBeenCalledTimes(1);
|
||||||
|
expect(el.currentTime).toBe(42); // restored after load()
|
||||||
|
expect(el.play).toHaveBeenCalled(); // wasPlaying = true (paused: false)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips reload when readyState recovered before delay elapses', () => {
|
||||||
|
const el = makeAudioStub();
|
||||||
|
el.readyState = 1;
|
||||||
|
attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });
|
||||||
|
|
||||||
|
el.fire('stalled');
|
||||||
|
el.readyState = 4; // recovered
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(el.load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canplay clears retry counter so a later stall can retry again', () => {
|
||||||
|
const el = makeAudioStub();
|
||||||
|
el.readyState = 1;
|
||||||
|
attachStallRetry(el as unknown as HTMLAudioElement, {
|
||||||
|
delayMs: 100,
|
||||||
|
maxRetries: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
el.fire('stalled');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(el.load).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
el.fire('canplay'); // resets retry count
|
||||||
|
el.fire('stalled');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(el.load).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxRetries caps recovery attempts', () => {
|
||||||
|
const el = makeAudioStub();
|
||||||
|
el.readyState = 1;
|
||||||
|
attachStallRetry(el as unknown as HTMLAudioElement, {
|
||||||
|
delayMs: 100,
|
||||||
|
maxRetries: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
el.fire('stalled');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
}
|
||||||
|
expect(el.load).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MEDIA_ERR_NETWORK error triggers immediate reload', () => {
|
||||||
|
const el = makeAudioStub();
|
||||||
|
el.error = { code: 2 } as MediaError;
|
||||||
|
el.currentTime = 17;
|
||||||
|
attachStallRetry(el as unknown as HTMLAudioElement);
|
||||||
|
|
||||||
|
el.fire('error');
|
||||||
|
expect(el.load).toHaveBeenCalled();
|
||||||
|
expect(el.currentTime).toBe(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-network errors (DECODE, SRC_NOT_SUPPORTED) are not retried', () => {
|
||||||
|
const el = makeAudioStub();
|
||||||
|
el.error = { code: 3 } as MediaError; // MEDIA_ERR_DECODE
|
||||||
|
attachStallRetry(el as unknown as HTMLAudioElement);
|
||||||
|
|
||||||
|
el.fire('error');
|
||||||
|
expect(el.load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('teardown removes listeners and clears pending timer', () => {
|
||||||
|
const el = makeAudioStub();
|
||||||
|
el.readyState = 1;
|
||||||
|
const teardown = attachStallRetry(el as unknown as HTMLAudioElement, {
|
||||||
|
delayMs: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
el.fire('stalled');
|
||||||
|
teardown();
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(el.load).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// After teardown, firing events does nothing.
|
||||||
|
el.fire('stalled');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(el.load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Auto-recovery for transient network failures during playback.
|
||||||
|
// Listens for stalled / waiting / network-error events and forces a
|
||||||
|
// reload-from-current-position when the audio element doesn't recover
|
||||||
|
// on its own within `delayMs`. Bounded retry count prevents tight
|
||||||
|
// loops on persistent failures (the existing onerror handler still
|
||||||
|
// reports to the store after retries exhaust).
|
||||||
|
//
|
||||||
|
// Returns a teardown function — call from $effect cleanup.
|
||||||
|
|
||||||
|
export type StallRetryOptions = {
|
||||||
|
maxRetries?: number;
|
||||||
|
delayMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function attachStallRetry(
|
||||||
|
el: HTMLAudioElement,
|
||||||
|
opts: StallRetryOptions = {}
|
||||||
|
): () => void {
|
||||||
|
const maxRetries = opts.maxRetries ?? 3;
|
||||||
|
const delayMs = opts.delayMs ?? 3000;
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let retries = 0;
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptRecovery() {
|
||||||
|
if (retries >= maxRetries) return;
|
||||||
|
retries++;
|
||||||
|
const lastPos = el.currentTime;
|
||||||
|
const wasPlaying = !el.paused;
|
||||||
|
el.load();
|
||||||
|
el.currentTime = lastPos;
|
||||||
|
if (wasPlaying) el.play().catch(() => { /* swallowed; store sees the event */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStallish() {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
// readyState >= 3 (HAVE_FUTURE_DATA) means the buffer recovered.
|
||||||
|
if (el.readyState >= 3) return;
|
||||||
|
attemptRecovery();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError() {
|
||||||
|
// MediaError.MEDIA_ERR_NETWORK === 2 → transient; worth retrying.
|
||||||
|
// Other codes (DECODE, SRC_NOT_SUPPORTED) are deterministic; skip.
|
||||||
|
if (!el.error) return;
|
||||||
|
if (el.error.code !== 2) return;
|
||||||
|
attemptRecovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRecovered() {
|
||||||
|
retries = 0;
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('stalled', onStallish);
|
||||||
|
el.addEventListener('waiting', onStallish);
|
||||||
|
el.addEventListener('error', onError);
|
||||||
|
el.addEventListener('canplay', onRecovered);
|
||||||
|
el.addEventListener('playing', onRecovered);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('stalled', onStallish);
|
||||||
|
el.removeEventListener('waiting', onStallish);
|
||||||
|
el.removeEventListener('error', onError);
|
||||||
|
el.removeEventListener('canplay', onRecovered);
|
||||||
|
el.removeEventListener('playing', onRecovered);
|
||||||
|
clear();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,9 +21,12 @@
|
|||||||
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
||||||
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
||||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.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 { children } = $props<{ children: import('svelte').Snippet }>();
|
||||||
let audioEl: HTMLAudioElement | undefined = $state();
|
let audioEl: HTMLAudioElement | undefined = $state();
|
||||||
|
let prefetchEl: HTMLAudioElement | undefined = $state();
|
||||||
|
|
||||||
// Reactive guard: runs every time page.url or user.value changes.
|
// Reactive guard: runs every time page.url or user.value changes.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -42,9 +45,16 @@
|
|||||||
return () => registerAudioEl(null);
|
return () => registerAudioEl(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-recovery for transient network failures during playback.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!audioEl) return;
|
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) {
|
if (src) {
|
||||||
const absolute = new URL(src, window.location.origin).href;
|
const absolute = new URL(src, window.location.origin).href;
|
||||||
if (audioEl.src !== absolute) audioEl.src = src;
|
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(() => {
|
$effect(() => {
|
||||||
if (audioEl) audioEl.volume = player.volume;
|
if (audioEl) audioEl.volume = player.volume;
|
||||||
});
|
});
|
||||||
@@ -87,7 +118,7 @@
|
|||||||
|
|
||||||
<audio
|
<audio
|
||||||
bind:this={audioEl}
|
bind:this={audioEl}
|
||||||
preload="metadata"
|
preload="auto"
|
||||||
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
|
ontimeupdate={() => audioEl && reportTimeUpdate(audioEl.currentTime)}
|
||||||
onloadedmetadata={() => {
|
onloadedmetadata={() => {
|
||||||
if (!audioEl) return;
|
if (!audioEl) return;
|
||||||
@@ -104,6 +135,17 @@
|
|||||||
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
|
onerror={() => reportStateFromAudio('error', audioEl?.error?.message)}
|
||||||
></audio>
|
></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 />
|
<QueueDrawer />
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
Reference in New Issue
Block a user