aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:02:11 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:02:11 +0100
commit42cf7e9ba7ea36e04b970e132ebd7cf59c54dd33 (patch)
tree6981658b08fc5bd6c1192f24ce4150dc64c91f15
parentlyrics: cache lrclib results, redraw cleanly and show a now playing header (diff)
downloadrsplayer-42cf7e9ba7ea36e04b970e132ebd7cf59c54dd33.tar.gz
rsplayer-42cf7e9ba7ea36e04b970e132ebd7cf59c54dd33.zip
mpris: report trackid as an object path, drop artUrl, track loop status
An empty artUrl is not a valid URI and makes some clients render a broken image, and the repeat button snapped back to "None" because LoopStatus was not stored.
-rw-r--r--src/mpris.rs225
1 files changed, 203 insertions, 22 deletions
diff --git a/src/mpris.rs b/src/mpris.rs
index 39630b0..f156f7f 100644
--- a/src/mpris.rs
+++ b/src/mpris.rs
@@ -25,7 +25,7 @@ use std::thread;
25use std::time::Duration; 25use std::time::Duration;
26use zbus::blocking::Connection; 26use zbus::blocking::Connection;
27use zbus::interface; 27use zbus::interface;
28use zbus::zvariant::Value; 28use zbus::zvariant::{ObjectPath, Value};
29 29
30// --------------------------------------------------------------------------- 30// ---------------------------------------------------------------------------
31// org.mpris.MediaPlayer2 — root interface 31// org.mpris.MediaPlayer2 — root interface
@@ -96,6 +96,9 @@ pub struct MprisPlayer {
96 queue: Arc<Mutex<Queue>>, 96 queue: Arc<Mutex<Queue>>,
97 scrobbler: Arc<RwLock<Scrobbler>>, 97 scrobbler: Arc<RwLock<Scrobbler>>,
98 scrobble_state: Arc<Mutex<ScrobbleState>>, 98 scrobble_state: Arc<Mutex<ScrobbleState>>,
99 /// Shared with the state watcher in [`start_mpris_server`] so changes made over D-Bus are
100 /// announced back out as `PropertiesChanged`.
101 loop_status: Arc<Mutex<String>>,
99} 102}
100 103
101/// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values 104/// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values
@@ -103,17 +106,18 @@ pub struct MprisPlayer {
103fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> { 106fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> {
104 let mut map: HashMap<String, Value<'static>> = HashMap::new(); 107 let mut map: HashMap<String, Value<'static>> = HashMap::new();
105 108
106 map.insert( 109 // `mpris:trackid` is typed as an object path (`o`) in the spec, not as a string.
107 "mpris:trackid".into(), 110 let trackid = ObjectPath::try_from("/org/mpris/MediaPlayer2/TrackList/CurrentTrack")
108 Value::new("/org/mpris/MediaPlayer2/TrackList/CurrentTrack"), 111 .expect("static object path");
109 ); 112 map.insert("mpris:trackid".into(), Value::new(trackid));
110 113
111 let length_us = track 114 let length_us = track
112 .duration_secs 115 .duration_secs
113 .map(|d| (d * 1_000_000.0) as i64) 116 .map(|d| (d * 1_000_000.0) as i64)
114 .unwrap_or(0); 117 .unwrap_or(0);
115 map.insert("mpris:length".into(), Value::new(length_us)); 118 map.insert("mpris:length".into(), Value::new(length_us));
116 map.insert("mpris:artUrl".into(), Value::new("")); 119 // NOTE: `mpris:artUrl` is intentionally omitted. We have no artwork to offer, and an empty
120 // string is not a valid URI, which makes some clients render a broken image placeholder.
117 121
118 map.insert( 122 map.insert(
119 "xesam:title".into(), 123 "xesam:title".into(),
@@ -138,13 +142,39 @@ fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> {
138 142
139fn empty_metadata() -> HashMap<String, Value<'static>> { 143fn empty_metadata() -> HashMap<String, Value<'static>> {
140 let mut map: HashMap<String, Value<'static>> = HashMap::new(); 144 let mut map: HashMap<String, Value<'static>> = HashMap::new();
141 map.insert( 145 let trackid = ObjectPath::try_from("/org/mpris/MediaPlayer2/TrackList/NoTrack")
142 "mpris:trackid".into(), 146 .expect("static object path");
143 Value::new("/org/mpris/MediaPlayer2/TrackList/NoTrack"), 147 map.insert("mpris:trackid".into(), Value::new(trackid));
144 );
145 map 148 map
146} 149}
147 150
151/// Path of the track MPRIS should be reporting, if there is one.
152fn current_track_path(audio: &AudioEngine, queue: &Arc<Mutex<Queue>>) -> Option<PathBuf> {
153 if let Some(path) = audio.current_track() {
154 return Some(path);
155 }
156 queue
157 .lock()
158 .ok()
159 .and_then(|q| q.current().map(|t| t.path.clone()))
160}
161
162/// Build the `Metadata` map for whatever is loaded right now.
163fn current_metadata(
164 audio: &AudioEngine,
165 queue: &Arc<Mutex<Queue>>,
166) -> HashMap<String, Value<'static>> {
167 if let Some(track_path) = audio.current_track() {
168 build_metadata(&Track::from_path(track_path))
169 } else if let Ok(q) = queue.lock()
170 && let Some(track) = q.current()
171 {
172 build_metadata(track)
173 } else {
174 empty_metadata()
175 }
176}
177
148fn playback_status(audio: &AudioEngine) -> String { 178fn playback_status(audio: &AudioEngine) -> String {
149 if audio.is_playing() { 179 if audio.is_playing() {
150 "Playing" 180 "Playing"
@@ -303,7 +333,29 @@ impl MprisPlayer {
303 333
304 #[zbus(property)] 334 #[zbus(property)]
305 fn loop_status(&self) -> String { 335 fn loop_status(&self) -> String {
306 "None".to_string() 336 self.loop_status
337 .lock()
338 .map(|s| s.clone())
339 .unwrap_or_else(|_| "None".to_string())
340 }
341
342 /// NOTE: repeat behavior is not implemented in the queue yet. This only records what a
343 /// client asked for so controls such as the Plasma widget's repeat button round-trip
344 /// instead of snapping straight back to "None".
345 #[zbus(property)]
346 #[zbus(name = "LoopStatus")]
347 fn set_loop_status(&self, value: String) -> zbus::fdo::Result<()> {
348 match value.as_str() {
349 "None" | "Playlist" | "Track" => {
350 if let Ok(mut s) = self.loop_status.lock() {
351 *s = value;
352 }
353 Ok(())
354 }
355 other => Err(zbus::fdo::Error::InvalidArgs(format!(
356 "invalid LoopStatus: {other}"
357 ))),
358 }
307 } 359 }
308 360
309 #[zbus(property)] 361 #[zbus(property)]
@@ -319,6 +371,28 @@ impl MprisPlayer {
319 .unwrap_or(false) 371 .unwrap_or(false)
320 } 372 }
321 373
374 /// MPRIS only knows on/off, so turning shuffle on maps to [`ShuffleMode::All`] — unless a
375 /// more specific mode (album shuffle) is already active, which this deliberately preserves
376 /// instead of silently downgrading it.
377 #[zbus(property)]
378 #[zbus(name = "Shuffle")]
379 fn set_shuffle(&self, value: bool) {
380 if let Ok(mut q) = self.queue.lock() {
381 let current = q.shuffle_mode().clone();
382 let mode = if !value {
383 ShuffleMode::None
384 } else if current == ShuffleMode::None {
385 ShuffleMode::All
386 } else {
387 current.clone()
388 };
389
390 if mode != current {
391 q.set_shuffle(mode);
392 }
393 }
394 }
395
322 #[zbus(property)] 396 #[zbus(property)]
323 fn volume(&self) -> f64 { 397 fn volume(&self) -> f64 {
324 self.audio.get_volume() as f64 / 100.0 398 self.audio.get_volume() as f64 / 100.0
@@ -382,16 +456,7 @@ impl MprisPlayer {
382 456
383 #[zbus(property)] 457 #[zbus(property)]
384 fn metadata(&self) -> HashMap<String, Value<'static>> { 458 fn metadata(&self) -> HashMap<String, Value<'static>> {
385 if let Some(track_path) = self.audio.current_track() { 459 current_metadata(&self.audio, &self.queue)
386 let track = Track::from_path(track_path);
387 build_metadata(&track)
388 } else if let Ok(q) = self.queue.lock()
389 && let Some(track) = q.current()
390 {
391 build_metadata(track)
392 } else {
393 empty_metadata()
394 }
395 } 460 }
396} 461}
397 462
@@ -420,12 +485,20 @@ pub fn start_mpris_server(
420 } 485 }
421 }; 486 };
422 487
488 // Clones for the state watcher below; the interface takes the originals.
489 let poll_audio = Arc::clone(&audio);
490 let poll_queue = Arc::clone(&queue);
491
492 let loop_status = Arc::new(Mutex::new("None".to_string()));
493 let poll_loop_status = Arc::clone(&loop_status);
494
423 // Register both MPRIS interfaces on the same object path 495 // Register both MPRIS interfaces on the same object path
424 let player = MprisPlayer { 496 let player = MprisPlayer {
425 audio, 497 audio,
426 queue, 498 queue,
427 scrobbler, 499 scrobbler,
428 scrobble_state, 500 scrobble_state,
501 loop_status,
429 }; 502 };
430 503
431 if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", Mpris2) { 504 if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", Mpris2) {
@@ -453,8 +526,116 @@ pub fn start_mpris_server(
453 "mpris: serving on org.mpris.MediaPlayer2.rsplayer" 526 "mpris: serving on org.mpris.MediaPlayer2.rsplayer"
454 )); 527 ));
455 528
456 // Keep the connection alive until shutdown. 529 // Keep the connection alive until shutdown, and announce our state changes.
530 //
531 // Most MPRIS clients do not poll: they read the properties once when our bus name
532 // appears and afterwards rely exclusively on `PropertiesChanged` signals. The KDE
533 // Plasma "Media Player" widget is one of them, which is why it used to stay frozen
534 // on the values from startup — or stay hidden entirely when we happened to be
535 // stopped at that moment. (playerctl keeps working because it polls on demand.)
536 //
537 // So we watch our own state here and push every change out: playback status,
538 // metadata, volume, shuffle and loop status. The steady tick of the playhead is
539 // deliberately not pushed — the spec tells clients to interpolate while we report
540 // `Playing` — but jumps in it are announced through the `Seeked` signal.
541 let mut last_status: Option<String> = None;
542 let mut last_track: Option<PathBuf> = None;
543 let mut last_volume: Option<f64> = None;
544 let mut last_shuffle: Option<ShuffleMode> = None;
545 let mut last_loop: Option<String> = None;
546
457 while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) { 547 while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) {
548 let status = playback_status(&poll_audio);
549 let track = current_track_path(&poll_audio, &poll_queue);
550 let volume = poll_audio.get_volume() as f64 / 100.0;
551 let shuffle = poll_queue
552 .lock()
553 .map(|q| q.shuffle_mode().clone())
554 .unwrap_or(ShuffleMode::None);
555 let loop_status_value = poll_loop_status
556 .lock()
557 .map(|s| s.clone())
558 .unwrap_or_else(|_| "None".to_string());
559
560 let status_changed = last_status.as_deref() != Some(status.as_str());
561 let track_changed = last_track != track;
562 let volume_changed = last_volume != Some(volume);
563 let shuffle_changed = last_shuffle.as_ref() != Some(&shuffle);
564 let loop_changed = last_loop.as_deref() != Some(loop_status_value.as_str());
565
566 if status_changed
567 || track_changed
568 || volume_changed
569 || shuffle_changed
570 || loop_changed
571 {
572 let mut changed: HashMap<&str, Value<'static>> = HashMap::new();
573 if status_changed {
574 changed.insert("PlaybackStatus", Value::new(status.clone()));
575 }
576 if track_changed {
577 // Building metadata reads tags from disk, so only do it when the
578 // current track actually changed.
579 changed.insert(
580 "Metadata",
581 Value::new(current_metadata(&poll_audio, &poll_queue)),
582 );
583 }
584 if volume_changed {
585 changed.insert("Volume", Value::new(volume));
586 }
587 if shuffle_changed {
588 changed.insert("Shuffle", Value::new(shuffle != ShuffleMode::None));
589 }
590 if loop_changed {
591 changed.insert("LoopStatus", Value::new(loop_status_value.clone()));
592 }
593
594 let body = (
595 "org.mpris.MediaPlayer2.Player",
596 &changed,
597 Vec::<&str>::new(),
598 );
599 if let Err(e) = conn.emit_signal(
600 None::<&str>,
601 "/org/mpris/MediaPlayer2",
602 "org.freedesktop.DBus.Properties",
603 "PropertiesChanged",
604 &body,
605 ) {
606 crate::debug_eprintln(format_args!(
607 "mpris: failed to emit PropertiesChanged: {}",
608 e
609 ));
610 }
611 }
612
613 // A different track (or none at all) means the playhead jumped, which is what
614 // `Seeked` exists for. Clients use it to reset their progress bar without
615 // having to notice that two consecutive tracks happen to share a length.
616 if track_changed {
617 let position_us = if track.is_some() {
618 (poll_audio.position() * 1_000_000.0) as i64
619 } else {
620 0
621 };
622 if let Err(e) = conn.emit_signal(
623 None::<&str>,
624 "/org/mpris/MediaPlayer2",
625 "org.mpris.MediaPlayer2.Player",
626 "Seeked",
627 &(position_us,),
628 ) {
629 crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e));
630 }
631 }
632
633 last_status = Some(status);
634 last_track = track;
635 last_volume = Some(volume);
636 last_shuffle = Some(shuffle);
637 last_loop = Some(loop_status_value);
638
458 thread::sleep(Duration::from_millis(500)); 639 thread::sleep(Duration::from_millis(500));
459 } 640 }
460 641