diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:02:11 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:02:11 +0100 |
| commit | 42cf7e9ba7ea36e04b970e132ebd7cf59c54dd33 (patch) | |
| tree | 6981658b08fc5bd6c1192f24ce4150dc64c91f15 | |
| parent | lyrics: cache lrclib results, redraw cleanly and show a now playing header (diff) | |
| download | rsplayer-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.rs | 225 |
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; | |||
| 25 | use std::time::Duration; | 25 | use std::time::Duration; |
| 26 | use zbus::blocking::Connection; | 26 | use zbus::blocking::Connection; |
| 27 | use zbus::interface; | 27 | use zbus::interface; |
| 28 | use zbus::zvariant::Value; | 28 | use 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 { | |||
| 103 | fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> { | 106 | fn 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 | ||
| 139 | fn empty_metadata() -> HashMap<String, Value<'static>> { | 143 | fn 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. | ||
| 152 | fn 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. | ||
| 163 | fn 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 | |||
| 148 | fn playback_status(audio: &AudioEngine) -> String { | 178 | fn 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 | ||
