mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
6ea0acede5
* 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.
112 lines
4.4 KiB
Rust
112 lines
4.4 KiB
Rust
//! `AudioStreamReader` — bridges an SPSC ring buffer (`HeapCons<u8>`) into the
|
|
//! synchronous `std::io::Read` interface that Symphonia requires.
|
|
//!
|
|
//! Designed to run inside `tokio::task::spawn_blocking`.
|
|
//!
|
|
//! - Empty buffer: sleeps `RADIO_YIELD_MS` ms, retries. Never busy-spins.
|
|
//! - Timeout: after `RADIO_READ_TIMEOUT_SECS` with no data → `TimedOut`.
|
|
//! - Generation: if `gen_arc` != `self.gen` → `Ok(0)` (EOF; new track started).
|
|
//! - Reconnect: `audio_resume` sends a fresh `HeapCons` via `new_cons_rx`.
|
|
//! On the next read() we drain the channel (keep latest) and swap.
|
|
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use ringbuf::HeapCons;
|
|
use ringbuf::traits::{Consumer, Observer};
|
|
use symphonia::core::io::MediaSource;
|
|
|
|
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).
|
|
/// No real contention: only the audio thread ever calls read().
|
|
pub(crate) new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapCons<u8>>>,
|
|
pub(crate) deadline: std::time::Instant,
|
|
pub(crate) gen_arc: Arc<AtomicU64>,
|
|
pub(crate) gen: u64,
|
|
/// Diagnostic tag for logs ("radio" or "track-stream").
|
|
pub(crate) source_tag: &'static str,
|
|
/// Optional completion marker: when true and the ring buffer is empty,
|
|
/// return EOF immediately (used by one-shot track streaming).
|
|
pub(crate) eof_when_empty: Option<Arc<AtomicBool>>,
|
|
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
|
pub(crate) pos: u64,
|
|
}
|
|
|
|
impl Read for AudioStreamReader {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
// EOF guard: new track started.
|
|
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
|
return Ok(0);
|
|
}
|
|
// Drain reconnect channel; keep only the most recently delivered consumer
|
|
// so a double-tap of resume doesn't leave stale data in place.
|
|
let mut newest: Option<HeapCons<u8>> = None;
|
|
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
|
|
newest = Some(c);
|
|
}
|
|
if let Some(c) = newest {
|
|
*self.cons.lock().unwrap() = c;
|
|
self.deadline =
|
|
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
|
}
|
|
loop {
|
|
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
|
|
return Ok(0);
|
|
}
|
|
let available = self.cons.lock().unwrap().occupied_len();
|
|
if available > 0 {
|
|
let n = buf.len().min(available);
|
|
let read = self.cons.lock().unwrap().pop_slice(&mut buf[..n]);
|
|
self.pos += read as u64;
|
|
// Reset deadline: data arrived, so connection is alive.
|
|
self.deadline =
|
|
std::time::Instant::now() + Duration::from_secs(self.read_timeout_secs);
|
|
return Ok(read);
|
|
}
|
|
if self
|
|
.eof_when_empty
|
|
.as_ref()
|
|
.is_some_and(|done| done.load(Ordering::SeqCst))
|
|
{
|
|
return Ok(0);
|
|
}
|
|
if std::time::Instant::now() >= self.deadline {
|
|
crate::app_eprintln!(
|
|
"[{}] AudioStreamReader: {}s without data → EOF",
|
|
self.source_tag,
|
|
self.read_timeout_secs
|
|
);
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::TimedOut,
|
|
format!("{}: no data received", self.source_tag),
|
|
));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Seek for AudioStreamReader {
|
|
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
|
match pos {
|
|
SeekFrom::Current(0) => Ok(self.pos),
|
|
_ => Err(std::io::Error::new(
|
|
std::io::ErrorKind::Unsupported,
|
|
format!("{} stream is not seekable", self.source_tag),
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MediaSource for AudioStreamReader {
|
|
fn is_seekable(&self) -> bool { false }
|
|
fn byte_len(&self) -> Option<u64> { None }
|
|
}
|