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
@@ -18,9 +18,10 @@ use ringbuf::HeapCons;
use ringbuf::traits::{Consumer, Observer};
use symphonia::core::io::MediaSource;
use super::{RADIO_READ_TIMEOUT_SECS, RADIO_YIELD_MS};
use super::{RADIO_YIELD_MS};
pub(crate) struct AudioStreamReader {
pub(crate) read_timeout_secs: u64,
pub(crate) cons: Mutex<HeapCons<u8>>,
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
@@ -53,7 +54,7 @@ impl Read for AudioStreamReader {
if let Some(c) = newest {
*self.cons.lock().unwrap() = c;
self.deadline =
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
}
loop {
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
@@ -66,7 +67,7 @@ impl Read for AudioStreamReader {
self.pos += read as u64;
// Reset deadline: data arrived, so connection is alive.
self.deadline =
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
return Ok(read);
}
if self
@@ -80,7 +81,7 @@ impl Read for AudioStreamReader {
crate::app_eprintln!(
"[{}] AudioStreamReader: {}s without data → EOF",
self.source_tag,
RADIO_READ_TIMEOUT_SECS
self.read_timeout_secs
);
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,