feat(playback): stream buffering UI, M4A moov-at-end streaming, hot-cache spill (#737)

* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix

Defer seekbar/progress until HTTP stream is armed for both legacy and
RangedHttpSource; show buffering overlay on cover art. Add MP4 tail
prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so
moov-at-end M4A can start without reading the full mdat.

* feat(hot-cache): spill large ranged streams to disk for promote

When a ranged HTTP download completes above the 64 MiB RAM promote cap,
write the existing buffer once to app-data stream-spill/ and register it
for hot-cache promote (rename) and replay via fetch_data. Analysis seeds
from the spill file up to the local-file cap (512 MiB).

* fix(ui): stream buffering — grayscale cover and static clock icon

Desaturate player and queue cover art while isPlaybackBuffering; keep a
non-animated clock overlay for visibility without the spinning animation.

* fix(playback): review follow-up — tests, i18n, spill cleanup, changelog

Clippy and test layout fixes; stream spill orphan cleanup on startup;
buffering flag guard in progress handler; bufferingStream in all player
locales; CHANGELOG and contributor credits for stream/M4A work.

* docs: attribute stream buffering and M4A streaming to PR #737

* test(audio): avoid create_engine in stream spill unit test

CI runners have no audio output device; test spill take/consume via
the Mutex slot only, matching install_stream_completed_spill tests.
This commit is contained in:
cucadmuh
2026-05-16 22:56:47 +03:00
committed by GitHub
parent 1ac354fb67
commit 6ea0acede5
45 changed files with 1280 additions and 105 deletions
+15 -5
View File
@@ -77,10 +77,14 @@ export type NormalizationStatePayload = {
export function handleAudioPlaying(_duration: number): void {
setDeferHotCachePrefetch(false);
resetProgressEmitThrottles();
usePlayerStore.setState({ isPlaying: true });
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
}
export function handleAudioProgress(current_time: number, duration: number): void {
export function handleAudioProgress(
current_time: number,
duration: number,
buffering = false,
): void {
bumpPerfCounter('audioProgressEvents');
const perfFlags = getPerfProbeFlags();
const progressUiDisabled = perfFlags.disablePlayerProgressUi;
@@ -105,6 +109,9 @@ export function handleAudioProgress(current_time: number, duration: number): voi
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
usePlayerStore.setState({ isPlaybackBuffering: buffering });
}
// Some backends can emit stale progress ticks shortly after pause/stop.
// Ignoring them avoids reactivating UI redraw loops while transport is idle.
const transportActive = store.isPlaying || store.currentRadio != null;
@@ -114,7 +121,7 @@ export function handleAudioProgress(current_time: number, duration: number): voi
setSeekFallbackVisualTarget(null);
visualTarget = null;
}
let displayTime = current_time;
let displayTime = buffering ? 0 : current_time;
if (visualTarget && visualTarget.trackId === track.id) {
const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0;
if (nearTarget) {
@@ -142,8 +149,9 @@ export function handleAudioProgress(current_time: number, duration: number): voi
) {
emitPlaybackProgress({
currentTime: displayTime,
progress,
progress: buffering ? 0 : progress,
buffered: 0,
buffering,
});
markLiveProgressEmit(nowLive);
}
@@ -315,6 +323,7 @@ export function handleAudioEnded(): void {
setIsAudioPaused(false);
usePlayerStore.setState({
isPlaying: false,
isPlaybackBuffering: false,
progress: 0,
currentTime: 0,
buffered: 0,
@@ -386,6 +395,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
isPlaying: true,
isPlaybackBuffering: switchPlaybackSource === 'stream',
progress: 0,
currentTime: 0,
buffered: 0,
@@ -427,7 +437,7 @@ export function handleAudioError(message: string): void {
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
const gen = getPlayGeneration();
usePlayerStore.setState({ isPlaying: false });
usePlayerStore.setState({ isPlaying: false, isPlaybackBuffering: false });
setTimeout(() => {
if (getPlayGeneration() !== gen) return;
usePlayerStore.getState().next(false);
@@ -38,7 +38,7 @@ export function setupAudioEngineListeners(): () => void {
const pending = [
listen<number>('audio:playing', ({ payload }) => handleAudioPlaying(payload)),
listen<{ current_time: number; duration: number }>('audio:progress', ({ payload }) => {
listen<{ current_time: number; duration: number; buffering?: boolean }>('audio:progress', ({ payload }) => {
if (import.meta.env.DEV) {
_devEventCount++;
const now = Date.now();
@@ -51,7 +51,7 @@ export function setupAudioEngineListeners(): () => void {
_devWindowStart = now;
}
}
handleAudioProgress(payload.current_time, payload.duration);
handleAudioProgress(payload.current_time, payload.duration, payload.buffering ?? false);
}),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
+4 -1
View File
@@ -254,7 +254,10 @@ export function runPlayTrack(
currentTime: initialTime,
scrobbled: false,
lastfmLoved: false,
isPlaying: true, // optimistic — reverted on error
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
// extrapolate while RangedHttpSource / legacy reader is still buffering.
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
});
+8 -4
View File
@@ -19,12 +19,14 @@ afterEach(() => {
describe('getPlaybackProgressSnapshot', () => {
it('starts at zero', () => {
const snap = getPlaybackProgressSnapshot();
expect(snap).toEqual({ currentTime: 0, progress: 0, buffered: 0 });
expect(snap).toEqual({ currentTime: 0, progress: 0, buffered: 0, buffering: false });
});
it('returns the latest snapshot after an emit', () => {
emitPlaybackProgress({ currentTime: 42, progress: 0.5, buffered: 0.7 });
expect(getPlaybackProgressSnapshot()).toEqual({ currentTime: 42, progress: 0.5, buffered: 0.7 });
expect(getPlaybackProgressSnapshot()).toEqual({
currentTime: 42, progress: 0.5, buffered: 0.7, buffering: false,
});
});
});
@@ -34,8 +36,10 @@ describe('subscribePlaybackProgress', () => {
subscribePlaybackProgress(cb);
emitPlaybackProgress({ currentTime: 1, progress: 0.1, buffered: 0.2 });
expect(cb).toHaveBeenCalledTimes(1);
expect(cb.mock.calls[0][0]).toEqual({ currentTime: 1, progress: 0.1, buffered: 0.2 });
expect(cb.mock.calls[0][1]).toEqual({ currentTime: 0, progress: 0, buffered: 0 });
expect(cb.mock.calls[0][0]).toEqual({
currentTime: 1, progress: 0.1, buffered: 0.2, buffering: false,
});
expect(cb.mock.calls[0][1]).toEqual({ currentTime: 0, progress: 0, buffered: 0, buffering: false });
});
it('returns an unsubscribe that detaches the listener', () => {
+14 -6
View File
@@ -12,12 +12,15 @@ export type PlaybackProgressSnapshot = {
currentTime: number;
progress: number;
buffered: number;
/** Legacy HTTP stream still filling — do not extrapolate the seekbar. */
buffering?: boolean;
};
let playbackProgressSnapshot: PlaybackProgressSnapshot = {
currentTime: 0,
progress: 0,
buffered: 0,
buffering: false,
};
const playbackProgressListeners = new Set<(
@@ -26,16 +29,21 @@ const playbackProgressListeners = new Set<(
) => void>();
export function emitPlaybackProgress(next: PlaybackProgressSnapshot): void {
const normalized: PlaybackProgressSnapshot = {
...next,
buffering: next.buffering ?? false,
};
const prev = playbackProgressSnapshot;
if (
Math.abs(prev.currentTime - next.currentTime) < 0.005 &&
Math.abs(prev.progress - next.progress) < 0.0002 &&
Math.abs(prev.buffered - next.buffered) < 0.0002
Math.abs(prev.currentTime - normalized.currentTime) < 0.005 &&
Math.abs(prev.progress - normalized.progress) < 0.0002 &&
Math.abs(prev.buffered - normalized.buffered) < 0.0002 &&
(prev.buffering ?? false) === normalized.buffering
) {
return;
}
playbackProgressSnapshot = next;
playbackProgressListeners.forEach(cb => cb(next, prev));
playbackProgressSnapshot = normalized;
playbackProgressListeners.forEach(cb => cb(normalized, prev));
}
export function getPlaybackProgressSnapshot(): PlaybackProgressSnapshot {
@@ -53,6 +61,6 @@ export function subscribePlaybackProgress(
/** Test-only: reset module state between specs so suites stay isolated. */
export function _resetPlaybackProgressForTest(): void {
playbackProgressSnapshot = { currentTime: 0, progress: 0, buffered: 0 };
playbackProgressSnapshot = { currentTime: 0, progress: 0, buffered: 0, buffering: false };
playbackProgressListeners.clear();
}
+50
View File
@@ -238,3 +238,53 @@ describe('audio:progress throttling (live-emit guard)', () => {
unsub();
});
});
describe('audio:progress buffering flag', () => {
it('sets isPlaybackBuffering from the optional buffering field', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({
currentTrack: track,
isPlaying: true,
isPlaybackBuffering: false,
});
emitTauriEvent('audio:progress', {
current_time: 0,
duration: 100,
buffering: true,
});
expect(usePlayerStore.getState().isPlaybackBuffering).toBe(true);
emitTauriEvent('audio:playing', 100);
expect(usePlayerStore.getState().isPlaybackBuffering).toBe(false);
});
it('does not rewrite isPlaybackBuffering when the flag is unchanged', () => {
const track = makeTrack({ duration: 100 });
usePlayerStore.setState({
currentTrack: track,
isPlaying: true,
isPlaybackBuffering: true,
});
const setStateSpy = vi.spyOn(usePlayerStore, 'setState');
emitTauriEvent('audio:progress', {
current_time: 0,
duration: 100,
buffering: true,
});
vi.advanceTimersByTime(2000);
emitTauriEvent('audio:progress', {
current_time: 0.9,
duration: 100,
buffering: true,
});
const bufferingWrites = setStateSpy.mock.calls.filter(
call => typeof call[0] === 'object' && call[0] !== null && 'isPlaybackBuffering' in call[0],
);
expect(bufferingWrites).toHaveLength(0);
setStateSpy.mockRestore();
});
});
+1
View File
@@ -39,6 +39,7 @@ export const usePlayerStore = create<PlayerState>()(
queueServerId: null,
queueIndex: 0,
isPlaying: false,
isPlaybackBuffering: false,
progress: 0,
buffered: 0,
currentTime: 0,
+2
View File
@@ -59,6 +59,8 @@ export interface PlayerState {
queueServerId: string | null;
queueIndex: number;
isPlaying: boolean;
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
isPlaybackBuffering: boolean;
progress: number; // 01
buffered: number; // 01 (unused in Rust backend, kept for UI compat)
currentTime: number;