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
@@ -414,6 +414,11 @@ impl<B: ReadBytes> AtomIterator<B> {
&mut self.reader
}
/// Exclusive end offset of the current atom in the stream.
pub fn current_atom_end(&self) -> u64 {
self.next_atom_pos
}
pub fn next(&mut self) -> Result<Option<AtomHeader>> {
// Ignore any remaining data in the current atom that was not read.
let cur_pos = self.reader.pos();
@@ -26,6 +26,29 @@ use crate::stream::*;
use log::{debug, info, trace, warn};
/// When `mdat` claims the rest of the file (common moov-at-end M4A), locate `moov` in the
/// tail instead of seeking to EOF (which yields end-of-stream during probe).
fn find_moov_atom_offset_in_tail(
mss: &mut MediaSourceStream,
total_len: u64,
) -> Result<Option<u64>> {
const MAX_SCAN: u64 = 8 * 1024 * 1024;
if total_len < 16 {
return Ok(None);
}
let scan_len = MAX_SCAN.min(total_len) as usize;
let scan_start = total_len - scan_len as u64;
mss.seek(SeekFrom::Start(scan_start))?;
let mut buf = vec![0u8; scan_len];
mss.read_buf_exact(&mut buf).map_err(Error::from)?;
for i in 0..buf.len().saturating_sub(8) {
if &buf[i + 4..i + 8] == b"moov" {
return Ok(Some(scan_start + i as u64));
}
}
Ok(None)
}
pub struct TrackState {
codec_params: CodecParameters,
/// The track number.
@@ -397,6 +420,29 @@ impl FormatReader for IsoMp4Reader {
// The remainder of the stream will be read incrementally.
break;
}
let end = iter.current_atom_end();
let file_len = total_len.unwrap_or(end);
if moov.is_none() {
let mut mss = iter.into_inner();
let resume_at = if end < file_len.saturating_sub(8) {
// Bounded mdat — `moov` is the next top-level sibling.
end
} else if let Some(tl) = total_len {
match find_moov_atom_offset_in_tail(&mut mss, tl)? {
Some(off) => off,
None => return unsupported_error("isomp4: missing moov atom"),
}
} else {
end
};
mss.seek(SeekFrom::Start(resume_at))?;
iter = AtomIterator::new_root(mss, total_len);
} else if end < file_len.saturating_sub(8) {
// Fast-start: skip a bounded mdat without linear read.
let mut mss = iter.into_inner();
mss.seek(SeekFrom::Start(end))?;
iter = AtomIterator::new_root(mss, total_len);
}
}
AtomType::Meta => {
// Read the metadata atom and append it to the log.