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,8 +15,8 @@ use super::engine::{audio_http_client, AudioEngine};
use super::helpers::*;
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
use super::play_input::{
build_source_from_play_input, select_play_input, swap_in_new_sink, url_format_hint,
PlayInputContext, SinkSwapInputs,
build_source_from_play_input, select_play_input, spawn_legacy_stream_start_when_armed,
swap_in_new_sink, url_format_hint, PlayInputContext, SinkSwapInputs,
};
use super::preview::preview_clear_for_new_main_playback;
use super::progress_task::spawn_progress_task;
@@ -97,6 +97,8 @@ pub async fn audio_play(
// Bump generation first so the old progress task stops before we peel
// chained_info (avoids a race where it sees current_done + empty chain).
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Ranged/legacy HTTP paths reset this to false in `select_play_input`.
state.stream_playback_armed.store(true, Ordering::SeqCst);
// Manual skip onto the gapless-pre-chained track: reuse raw bytes (no HTTP;
// preload cache was already consumed when the chain was built). Otherwise
@@ -325,7 +327,8 @@ pub async fn audio_play(
// without an underrun on the very first period.
// Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
let needs_prefill = hi_res_enabled && output_rate > 48_000;
if needs_prefill {
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
if needs_prefill || defer_playback_start {
sink.pause();
}
@@ -372,7 +375,9 @@ pub async fn audio_play(
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); // skipped during pre-fill — abort silently
}
sink.play();
if !defer_playback_start {
sink.play();
}
}
swap_in_new_sink(&state, SinkSwapInputs {
@@ -386,7 +391,24 @@ pub async fn audio_play(
actual_fade_secs,
});
app.emit("audio:playing", duration_secs).ok();
if defer_playback_start {
{
let mut cur = state.current.lock().unwrap();
cur.play_started = None;
cur.paused_at = Some(0.0);
}
spawn_legacy_stream_start_when_armed(
gen,
state.generation.clone(),
state.stream_playback_armed.clone(),
state.samples_played.clone(),
state.current.clone(),
app.clone(),
duration_secs,
);
} else {
app.emit("audio:playing", duration_secs).ok();
}
// ── Progress + ended detection ────────────────────────────────────────────
spawn_progress_task(
@@ -403,6 +425,7 @@ pub async fn audio_play(
state.current_channels.clone(),
state.gapless_switch_at.clone(),
state.current_playback_url.clone(),
state.stream_playback_armed.clone(),
);
Ok(())