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);