mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user