perf(player): use Symphonia native seek for CMAF streaming seeks

Replaces skip_duration's decode-every-sample-from-zero loop with
FormatReader::seek(SeekMode::Accurate, SeekTo::Time) on a fresh
IncrementalStreamingSource. For FLAC this jumps via the seek table to
the nearest seek point, then decodes forward to the exact sample —
instead of grinding through millions of samples under the buffered
source's mutex (the cause of post-seek stuttering).

Added IncrementalStreamingSource::seek_to(Duration) which also resets
the decoder, clears the sample queue, and rearms the finished flag so
the source is cleanly re-primed for the iterator.

Non-streaming seeks (cache, offline-cache, local library) still use
skip_duration — they hit in-memory bytes with no lock contention, so
the linear decode is effectively free and there's no reason to change
the path.

Part of #335.

cc
This commit is contained in:
vicrodh committed 2026-04-20 21:15:14 -06:00
1 parent bdc901a88a
commit 11d01a2075
2 files changed
+49 -6

No files matched your search

+19 -5
View File
@@ -2226,15 +2226,29 @@ impl Player {
engine.set_volume(volume);
// Build the decoded source for the seek. Streaming:
// create a fresh IncrementalStreamingSource on the
// shared BufferedMediaSource — new reader at byte 0,
// same downloaded bytes (no re-download). Non-streaming:
// decode the in-RAM full file as before.
// create a fresh IncrementalStreamingSource and use
// Symphonia's native seek — jumps via FLAC seek
// table / MP3 TOC to the target byte, then decodes
// forward to the exact sample. Far cheaper than
// skip_duration's decode-every-sample-from-zero
// loop, which stuttered under the buffered-source
// mutex. Non-streaming: decode the in-RAM full file
// and skip_duration as before (no lock contention,
// fast).
let skip_duration = Duration::from_secs(position_secs);
let skipped_source: Box<dyn Source<Item = f32> + Send> =
if let Some(ref stream_src) = *current_streaming_source {
match IncrementalStreamingSource::new(stream_src.clone()) {
Ok(s) => Box::new(s.skip_duration(skip_duration)),
Ok(mut s) => {
if let Err(e) = s.seek_to(skip_duration) {
log::error!(
"Failed to native-seek streaming source: {}",
e
);
return;
}
Box::new(s)
}
Err(e) => {
log::error!(
"Failed to create streaming source for seek: {}",
@@ -32,7 +32,7 @@ use rodio::Source;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
use symphonia::core::io::{MediaSource, MediaSourceStream};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
@@ -587,6 +587,35 @@ impl IncrementalStreamingSource {
&self.buffered_source
}
/// Seek the decoder to the given time using Symphonia's native seek.
///
/// For FLAC this uses the seek table to jump directly to the nearest
/// seek point, then decodes forward to the exact sample — far cheaper
/// than skip_duration's decode-every-sample-from-zero path. For MP3
/// with Xing/VBRI headers it uses the TOC; without headers, Symphonia
/// falls back to a binary search, still much cheaper than linear decode.
///
/// The underlying BufferedMediaSource::seek is the I/O target. If the
/// requested byte offset isn't buffered yet it will block on the
/// condition variable — callers must only invoke this for times within
/// the downloaded watermark.
pub fn seek_to(&mut self, time: Duration) -> Result<(), String> {
self.format
.seek(
SeekMode::Accurate,
SeekTo::Time {
time: time.into(),
track_id: Some(self.track_id),
},
)
.map_err(|e| format!("Symphonia seek failed: {}", e))?;
self.decoder.reset();
self.sample_queue.clear();
self.packets_decoded = 0;
self.finished = false;
Ok(())
}
/// Decode more packets to fill the sample queue.
///
/// This is called when the sample queue is running low.