From 50cd2a17734dee4c4739112261fc40974cce409a Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 2 Nov 2026 13:19:49 +0100 Subject: seek: jump within the current track from the CLI and over MPRIS `rsp seek 1:30`, `rsp seek +15`, `rsp seek -30` and `rsp seek` (print the position) drive a new AudioCommand that seeks the format reader, resets the decoder and drops the buffered samples, so the cpal stream keeps running and playback continues from the new spot. MPRIS gets a working Seek/SetPosition, reports CanSeek, and the state watcher now emits Seeked when the playhead jumps (not just on track changes), which is what clients need to move their progress bar after an external seek. `rsp status` also reports the position (and duration when known) and says Paused instead of Stopped when a track is loaded but not playing. --- src/audio.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) (limited to 'src/audio.rs') diff --git a/src/audio.rs b/src/audio.rs index 8bac26f..5ce00ae 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -123,6 +123,7 @@ pub enum AudioCommand { Pause, Resume, Stop, + Seek(f64), SetVolume(f32), } @@ -193,6 +194,11 @@ impl AudioEngine { let _ = self.command_tx.send(AudioCommand::Stop); } + /// Jump to `position` seconds into the current track. + pub fn seek(&self, position: f64) { + let _ = self.command_tx.send(AudioCommand::Seek(position)); + } + pub fn set_volume(&self, volume: f32) { let _ = self.command_tx.send(AudioCommand::SetVolume(volume)); } @@ -799,6 +805,22 @@ fn audio_thread( *track = None; } } + AudioCommand::Seek(seconds) => { + let target = seconds.max(0.0); + if let Ok(mut d) = ctx.audio_data.lock() + && let Some(data) = d.as_mut() + { + match seek_audio(data, target) { + Ok(()) => { + if let Ok(mut pos) = position.lock() { + *pos = target; + } + crate::debug_eprintln(format_args!("Seeked to {:.1}s", target)); + } + Err(e) => eprintln!("Failed to seek: {}", e), + } + } + } AudioCommand::SetVolume(vol) => { if let Ok(mut volume_ref) = volume.lock() { *volume_ref = vol; @@ -865,6 +887,40 @@ fn audio_thread( } } +/// Moves the decoder to `seconds` into the current track. +/// +/// Symphonia's accurate seek lands on the packet *before* the requested +/// position, so packets short of the required timestamp are dropped before +/// playback resumes. The cpal stream itself keeps running: only the decoded +/// buffers are flushed, so there is no gap beyond the dropped packet. +fn seek_audio(data: &mut AudioData, seconds: f64) -> Result<(), String> { + use symphonia::core::formats::{SeekMode, SeekTo}; + use symphonia::core::units::Time; + + let time = Time::try_from_secs_f64(seconds).ok_or("invalid seek position")?; + let seeked = data + .format + .seek( + SeekMode::Accurate, + SeekTo::Time { + time, + track_id: None, + }, + ) + .map_err(|e| e.to_string())?; + + data.decoder.reset(); + data.sample_buffer.clear(); + data.buffer_pos = 0; + while let Ok(Some(packet)) = data.format.next_packet() { + if packet.pts >= seeked.required_ts { + break; + } + } + + Ok(()) +} + fn open_audio_file(path: &PathBuf) -> Result> { use symphonia::core::audio::layouts::CHANNEL_LAYOUT_STEREO; use symphonia::core::codecs::audio::AudioDecoderOptions; -- cgit v1.2.3