diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:19:49 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:19:49 +0100 |
| commit | 50cd2a17734dee4c4739112261fc40974cce409a (patch) | |
| tree | 027366c170eb8dc035c5bd24be2669c41df64f28 /src/mpris.rs | |
| parent | state: persist the queue and pick up where playback stopped (diff) | |
| download | rsplayer-50cd2a17734dee4c4739112261fc40974cce409a.tar.gz rsplayer-50cd2a17734dee4c4739112261fc40974cce409a.zip | |
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.
Diffstat (limited to '')
| -rw-r--r-- | src/mpris.rs | 71 |
1 files changed, 50 insertions, 21 deletions
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; | |||
| 22 | use std::sync::atomic::AtomicBool; | 22 | use std::sync::atomic::AtomicBool; |
| 23 | use std::sync::{Arc, Mutex, RwLock}; | 23 | use std::sync::{Arc, Mutex, RwLock}; |
| 24 | use std::thread; | 24 | use std::thread; |
| 25 | use std::time::Duration; | 25 | use std::time::{Duration, Instant}; |
| 26 | use zbus::blocking::Connection; | 26 | use zbus::blocking::Connection; |
| 27 | use zbus::interface; | 27 | use zbus::interface; |
| 28 | use zbus::zvariant::{ObjectPath, Value}; | 28 | use zbus::zvariant::{ObjectPath, Value}; |
| @@ -101,14 +101,17 @@ pub struct MprisPlayer { | |||
| 101 | loop_status: Arc<Mutex<String>>, | 101 | loop_status: Arc<Mutex<String>>, |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | /// `mpris:trackid` advertised for whatever is playing. The spec only requires it to be a stable | ||
| 105 | /// object path that changes per track; clients echo it back in `SetPosition`. | ||
| 106 | const CURRENT_TRACK_ID: &str = "/org/mpris/MediaPlayer2/TrackList/CurrentTrack"; | ||
| 107 | |||
| 104 | /// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values | 108 | /// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values |
| 105 | /// so no borrowing from locals. | 109 | /// so no borrowing from locals. |
| 106 | fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> { | 110 | fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> { |
| 107 | let mut map: HashMap<String, Value<'static>> = HashMap::new(); | 111 | let mut map: HashMap<String, Value<'static>> = HashMap::new(); |
| 108 | 112 | ||
| 109 | // `mpris:trackid` is typed as an object path (`o`) in the spec, not as a string. | 113 | // `mpris:trackid` is typed as an object path (`o`) in the spec, not as a string. |
| 110 | let trackid = ObjectPath::try_from("/org/mpris/MediaPlayer2/TrackList/CurrentTrack") | 114 | let trackid = ObjectPath::try_from(CURRENT_TRACK_ID).expect("static object path"); |
| 111 | .expect("static object path"); | ||
| 112 | map.insert("mpris:trackid".into(), Value::new(trackid)); | 115 | map.insert("mpris:trackid".into(), Value::new(trackid)); |
| 113 | 116 | ||
| 114 | let length_us = track | 117 | let length_us = track |
| @@ -273,12 +276,23 @@ impl MprisPlayer { | |||
| 273 | } | 276 | } |
| 274 | } | 277 | } |
| 275 | 278 | ||
| 276 | fn seek(&self, _offset: i64) { | 279 | fn seek(&self, offset: i64) { |
| 277 | // Seeking not implemented yet. | 280 | let target = (self.audio.position() + offset as f64 / 1_000_000.0).max(0.0); |
| 281 | crate::debug_eprintln(format_args!("mpris: seek by {}us", offset)); | ||
| 282 | self.audio.seek(target); | ||
| 278 | } | 283 | } |
| 279 | 284 | ||
| 280 | fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, _position: i64) { | 285 | fn set_position(&self, track_id: zbus::zvariant::ObjectPath<'_>, position: i64) { |
| 281 | // Seeking not implemented yet. | 286 | // The spec says to ignore the request when the id is not the track we are playing. |
| 287 | if track_id.as_str() != CURRENT_TRACK_ID { | ||
| 288 | crate::debug_eprintln(format_args!( | ||
| 289 | "mpris: ignoring SetPosition for foreign track {}", | ||
| 290 | track_id.as_str() | ||
| 291 | )); | ||
| 292 | return; | ||
| 293 | } | ||
| 294 | crate::debug_eprintln(format_args!("mpris: set position to {}us", position)); | ||
| 295 | self.audio.seek((position as f64 / 1_000_000.0).max(0.0)); | ||
| 282 | } | 296 | } |
| 283 | 297 | ||
| 284 | fn open_uri(&self, uri: String) { | 298 | fn open_uri(&self, uri: String) { |
| @@ -446,7 +460,7 @@ impl MprisPlayer { | |||
| 446 | 460 | ||
| 447 | #[zbus(property)] | 461 | #[zbus(property)] |
| 448 | fn can_seek(&self) -> bool { | 462 | fn can_seek(&self) -> bool { |
| 449 | false | 463 | true |
| 450 | } | 464 | } |
| 451 | 465 | ||
| 452 | #[zbus(property)] | 466 | #[zbus(property)] |
| @@ -543,6 +557,7 @@ pub fn start_mpris_server( | |||
| 543 | let mut last_volume: Option<f64> = None; | 557 | let mut last_volume: Option<f64> = None; |
| 544 | let mut last_shuffle: Option<ShuffleMode> = None; | 558 | let mut last_shuffle: Option<ShuffleMode> = None; |
| 545 | let mut last_loop: Option<String> = None; | 559 | let mut last_loop: Option<String> = None; |
| 560 | let mut last_position: Option<(Instant, i64)> = None; | ||
| 546 | 561 | ||
| 547 | while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) { | 562 | while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) { |
| 548 | let status = playback_status(&poll_audio); | 563 | let status = playback_status(&poll_audio); |
| @@ -610,25 +625,39 @@ pub fn start_mpris_server( | |||
| 610 | } | 625 | } |
| 611 | } | 626 | } |
| 612 | 627 | ||
| 613 | // A different track (or none at all) means the playhead jumped, which is what | 628 | // A different track, or a position that is nowhere near where the playhead |
| 614 | // `Seeked` exists for. Clients use it to reset their progress bar without | 629 | // should be by now, means it jumped — which is what `Seeked` exists for. Clients |
| 615 | // having to notice that two consecutive tracks happen to share a length. | 630 | // use it to reset their progress bar without having to notice that two |
| 616 | if track_changed { | 631 | // consecutive tracks happen to share a length. The steady tick is intentionally |
| 617 | let position_us = if track.is_some() { | 632 | // not signalled: the spec tells clients to interpolate while we report `Playing`. |
| 618 | (poll_audio.position() * 1_000_000.0) as i64 | 633 | let position_us = if track.is_some() { |
| 619 | } else { | 634 | (poll_audio.position() * 1_000_000.0) as i64 |
| 620 | 0 | 635 | } else { |
| 621 | }; | 636 | 0 |
| 622 | if let Err(e) = conn.emit_signal( | 637 | }; |
| 638 | let jumped = match last_position { | ||
| 639 | Some((at, seen)) if !track_changed => { | ||
| 640 | let expected = if status == "Playing" { | ||
| 641 | seen + at.elapsed().as_micros() as i64 | ||
| 642 | } else { | ||
| 643 | seen | ||
| 644 | }; | ||
| 645 | (position_us - expected).abs() > 1_500_000 | ||
| 646 | } | ||
| 647 | _ => false, | ||
| 648 | }; | ||
| 649 | if (track_changed || jumped) | ||
| 650 | && let Err(e) = conn.emit_signal( | ||
| 623 | None::<&str>, | 651 | None::<&str>, |
| 624 | "/org/mpris/MediaPlayer2", | 652 | "/org/mpris/MediaPlayer2", |
| 625 | "org.mpris.MediaPlayer2.Player", | 653 | "org.mpris.MediaPlayer2.Player", |
| 626 | "Seeked", | 654 | "Seeked", |
| 627 | &(position_us,), | 655 | &(position_us,), |
| 628 | ) { | 656 | ) |
| 629 | crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e)); | 657 | { |
| 630 | } | 658 | crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e)); |
| 631 | } | 659 | } |
| 660 | last_position = Some((Instant::now(), position_us)); | ||
| 632 | 661 | ||
| 633 | last_status = Some(status); | 662 | last_status = Some(status); |
| 634 | last_track = track; | 663 | last_track = track; |
