aboutsummaryrefslogtreecommitdiffstats
path: root/src/mpris.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:19:49 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:19:49 +0100
commit50cd2a17734dee4c4739112261fc40974cce409a (patch)
tree027366c170eb8dc035c5bd24be2669c41df64f28 /src/mpris.rs
parentstate: persist the queue and pick up where playback stopped (diff)
downloadrsplayer-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 'src/mpris.rs')
-rw-r--r--src/mpris.rs71
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;
22use std::sync::atomic::AtomicBool; 22use std::sync::atomic::AtomicBool;
23use std::sync::{Arc, Mutex, RwLock}; 23use std::sync::{Arc, Mutex, RwLock};
24use std::thread; 24use std::thread;
25use std::time::Duration; 25use std::time::{Duration, Instant};
26use zbus::blocking::Connection; 26use zbus::blocking::Connection;
27use zbus::interface; 27use zbus::interface;
28use zbus::zvariant::{ObjectPath, Value}; 28use 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`.
106const 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.
106fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> { 110fn 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;