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
+76 -3
View File
@@ -363,11 +363,31 @@ impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
pub(crate) struct CountingSource<S: Source<Item = f32>> {
inner: S,
counter: Arc<AtomicU64>,
/// When set, count samples only while the flag is true (legacy track stream).
count_gate: Option<Arc<AtomicBool>>,
}
impl<S: Source<Item = f32>> CountingSource<S> {
pub(crate) fn new(inner: S, counter: Arc<AtomicU64>) -> Self {
Self { inner, counter }
Self {
inner,
counter,
count_gate: None,
}
}
pub(crate) fn new_gated(inner: S, counter: Arc<AtomicU64>, gate: Arc<AtomicBool>) -> Self {
Self {
inner,
counter,
count_gate: Some(gate),
}
}
fn should_count(&self) -> bool {
self.count_gate
.as_ref()
.is_none_or(|g| g.load(Ordering::Relaxed))
}
}
@@ -375,7 +395,7 @@ impl<S: Source<Item = f32>> Iterator for CountingSource<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
let sample = self.inner.next();
if sample.is_some() {
if sample.is_some() && self.should_count() {
self.counter.fetch_add(1, Ordering::Relaxed);
}
sample
@@ -393,7 +413,7 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
// new position while the decoder is still at the old one — causing
// a permanent desync between displayed time and actual audio.
let result = self.inner.try_seek(pos);
if result.is_ok() {
if result.is_ok() && self.should_count() {
let samples = (pos.as_secs_f64() * self.inner.sample_rate().get() as f64
* self.inner.channels().get() as f64) as u64;
self.counter.store(samples, Ordering::Relaxed);
@@ -481,3 +501,56 @@ impl<S: Source<Item = f32>> Source for PriorityBoostSource<S> {
self.inner.try_seek(pos)
}
}
#[cfg(test)]
mod counting_source_tests {
use super::*;
use rodio::Source;
use std::time::Duration;
struct TwoSamples(u8);
impl Iterator for TwoSamples {
type Item = f32;
fn next(&mut self) -> Option<f32> {
match self.0 {
0 => {
self.0 = 1;
Some(0.1)
}
1 => {
self.0 = 2;
Some(0.2)
}
_ => None,
}
}
}
impl Source for TwoSamples {
fn current_span_len(&self) -> Option<usize> {
Some(1)
}
fn channels(&self) -> rodio::ChannelCount {
std::num::NonZero::new(1).unwrap()
}
fn sample_rate(&self) -> rodio::SampleRate {
std::num::NonZero::new(44_100).unwrap()
}
fn total_duration(&self) -> Option<Duration> {
Some(Duration::from_secs_f32(2.0 / 44_100.0))
}
}
#[test]
fn gated_counter_skips_samples_until_gate_is_set() {
let counter = Arc::new(AtomicU64::new(0));
let gate = Arc::new(AtomicBool::new(false));
let mut src = CountingSource::new_gated(TwoSamples(0), counter.clone(), gate.clone());
assert_eq!(src.next(), Some(0.1));
assert_eq!(src.next(), Some(0.2));
assert_eq!(counter.load(Ordering::Relaxed), 0);
gate.store(true, Ordering::SeqCst);
let mut src2 = CountingSource::new_gated(TwoSamples(0), counter.clone(), gate);
assert_eq!(src2.next(), Some(0.1));
assert_eq!(counter.load(Ordering::Relaxed), 1);
}
}