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/mpris.rs | 71 ++++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 21 deletions(-) (limited to 'src/mpris.rs') diff --git a/src/mpris.rs b/src/mpris.rs index 3fc706d..de19113 100644 --- a/src/mpris.rs +++ b/src/mpris.rs @@ -22,7 +22,7 @@ use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex, RwLock}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use zbus::blocking::Connection; use zbus::interface; use zbus::zvariant::{ObjectPath, Value}; @@ -101,14 +101,17 @@ pub struct MprisPlayer { loop_status: Arc>, } +/// `mpris:trackid` advertised for whatever is playing. The spec only requires it to be a stable +/// object path that changes per track; clients echo it back in `SetPosition`. +const CURRENT_TRACK_ID: &str = "/org/mpris/MediaPlayer2/TrackList/CurrentTrack"; + /// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values /// so no borrowing from locals. fn build_metadata(track: &Track) -> HashMap> { let mut map: HashMap> = HashMap::new(); // `mpris:trackid` is typed as an object path (`o`) in the spec, not as a string. - let trackid = ObjectPath::try_from("/org/mpris/MediaPlayer2/TrackList/CurrentTrack") - .expect("static object path"); + let trackid = ObjectPath::try_from(CURRENT_TRACK_ID).expect("static object path"); map.insert("mpris:trackid".into(), Value::new(trackid)); let length_us = track @@ -273,12 +276,23 @@ impl MprisPlayer { } } - fn seek(&self, _offset: i64) { - // Seeking not implemented yet. + fn seek(&self, offset: i64) { + let target = (self.audio.position() + offset as f64 / 1_000_000.0).max(0.0); + crate::debug_eprintln(format_args!("mpris: seek by {}us", offset)); + self.audio.seek(target); } - fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, _position: i64) { - // Seeking not implemented yet. + fn set_position(&self, track_id: zbus::zvariant::ObjectPath<'_>, position: i64) { + // The spec says to ignore the request when the id is not the track we are playing. + if track_id.as_str() != CURRENT_TRACK_ID { + crate::debug_eprintln(format_args!( + "mpris: ignoring SetPosition for foreign track {}", + track_id.as_str() + )); + return; + } + crate::debug_eprintln(format_args!("mpris: set position to {}us", position)); + self.audio.seek((position as f64 / 1_000_000.0).max(0.0)); } fn open_uri(&self, uri: String) { @@ -446,7 +460,7 @@ impl MprisPlayer { #[zbus(property)] fn can_seek(&self) -> bool { - false + true } #[zbus(property)] @@ -543,6 +557,7 @@ pub fn start_mpris_server( let mut last_volume: Option = None; let mut last_shuffle: Option = None; let mut last_loop: Option = None; + let mut last_position: Option<(Instant, i64)> = None; while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) { let status = playback_status(&poll_audio); @@ -610,25 +625,39 @@ pub fn start_mpris_server( } } - // A different track (or none at all) means the playhead jumped, which is what - // `Seeked` exists for. Clients use it to reset their progress bar without - // having to notice that two consecutive tracks happen to share a length. - if track_changed { - let position_us = if track.is_some() { - (poll_audio.position() * 1_000_000.0) as i64 - } else { - 0 - }; - if let Err(e) = conn.emit_signal( + // A different track, or a position that is nowhere near where the playhead + // should be by now, means it jumped — which is what `Seeked` exists for. Clients + // use it to reset their progress bar without having to notice that two + // consecutive tracks happen to share a length. The steady tick is intentionally + // not signalled: the spec tells clients to interpolate while we report `Playing`. + let position_us = if track.is_some() { + (poll_audio.position() * 1_000_000.0) as i64 + } else { + 0 + }; + let jumped = match last_position { + Some((at, seen)) if !track_changed => { + let expected = if status == "Playing" { + seen + at.elapsed().as_micros() as i64 + } else { + seen + }; + (position_us - expected).abs() > 1_500_000 + } + _ => false, + }; + if (track_changed || jumped) + && let Err(e) = conn.emit_signal( None::<&str>, "/org/mpris/MediaPlayer2", "org.mpris.MediaPlayer2.Player", "Seeked", &(position_us,), - ) { - crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e)); - } + ) + { + crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e)); } + last_position = Some((Instant::now(), position_us)); last_status = Some(status); last_track = track; -- cgit v1.2.3