/* * This file is a part of RSPlayer * Licensed under the GPL-3.0 License * Copyright (C) 2026 Salmonik @ salmonik@tuta.io * https://codeberg.org/salmonik/RSPlayer */ //! MPRIS D-Bus interface for external control via playerctl, KDE Connect, etc. //! //! Implements org.mpris.MediaPlayer2 and org.mpris.MediaPlayer2.Player on the //! session D-Bus bus. Uses zbus blocking API so no async runtime is required //! in the caller. use crate::audio::AudioEngine; use crate::config::Config; use crate::daemon::ScrobbleState; use crate::notification; use crate::queue::{Queue, ShuffleMode, Track}; use crate::scrobbler::Scrobbler; use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex, RwLock}; use std::thread; use std::time::Duration; use zbus::blocking::Connection; use zbus::interface; use zbus::zvariant::{ObjectPath, Value}; // --------------------------------------------------------------------------- // org.mpris.MediaPlayer2 — root interface // --------------------------------------------------------------------------- pub struct Mpris2; #[interface(name = "org.mpris.MediaPlayer2")] impl Mpris2 { #[zbus(property)] fn can_quit(&self) -> bool { true } #[zbus(property)] fn can_raise(&self) -> bool { false } #[zbus(property)] fn has_track_list(&self) -> bool { false } #[zbus(property)] fn identity(&self) -> String { "RSPlayer".to_string() } #[zbus(property)] fn desktop_entry(&self) -> String { "rsplayer".to_string() } #[zbus(property)] fn supported_uri_schemes(&self) -> Vec { vec!["file".into(), "http".into(), "https".into()] } #[zbus(property)] fn supported_mime_types(&self) -> Vec { vec![ "audio/mpeg".into(), "audio/flac".into(), "audio/ogg".into(), "audio/wav".into(), "audio/x-wav".into(), "audio/vorbis".into(), ] } fn quit(&self) -> zbus::fdo::Result<()> { crate::debug_eprintln(format_args!("mpris: quit requested")); std::process::exit(0); } fn raise(&self) { // Terminal-based player — nothing to raise. } } // --------------------------------------------------------------------------- // org.mpris.MediaPlayer2.Player — playback control // --------------------------------------------------------------------------- pub struct MprisPlayer { audio: Arc, queue: Arc>, scrobbler: Arc>, scrobble_state: Arc>, /// Shared with the state watcher in [`start_mpris_server`] so changes made over D-Bus are /// announced back out as `PropertiesChanged`. loop_status: Arc>, } /// 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"); map.insert("mpris:trackid".into(), Value::new(trackid)); let length_us = track .duration_secs .map(|d| (d * 1_000_000.0) as i64) .unwrap_or(0); map.insert("mpris:length".into(), Value::new(length_us)); // NOTE: `mpris:artUrl` is intentionally omitted. We have no artwork to offer, and an empty // string is not a valid URI, which makes some clients render a broken image placeholder. map.insert( "xesam:title".into(), Value::new(track.title.clone().unwrap_or_default()), ); let artist = track.artist.clone().unwrap_or_default(); map.insert("xesam:artist".into(), Value::new(vec![artist])); map.insert( "xesam:album".into(), Value::new(track.album.clone().unwrap_or_default()), ); map.insert( "xesam:trackNumber".into(), Value::new(track.track_number.unwrap_or(0)), ); map } fn empty_metadata() -> HashMap> { let mut map: HashMap> = HashMap::new(); let trackid = ObjectPath::try_from("/org/mpris/MediaPlayer2/TrackList/NoTrack") .expect("static object path"); map.insert("mpris:trackid".into(), Value::new(trackid)); map } /// Path of the track MPRIS should be reporting, if there is one. fn current_track_path(audio: &AudioEngine, queue: &Arc>) -> Option { if let Some(path) = audio.current_track() { return Some(path); } queue .lock() .ok() .and_then(|q| q.current().map(|t| t.path.clone())) } /// Build the `Metadata` map for whatever is loaded right now. fn current_metadata( audio: &AudioEngine, queue: &Arc>, ) -> HashMap> { if let Some(track_path) = audio.current_track() { build_metadata(&Track::from_path(track_path)) } else if let Ok(q) = queue.lock() && let Some(track) = q.current() { build_metadata(track) } else { empty_metadata() } } fn playback_status(audio: &AudioEngine) -> String { if audio.is_playing() { "Playing" } else if audio.current_track().is_some() { "Paused" } else { "Stopped" } .to_string() } #[interface(name = "org.mpris.MediaPlayer2.Player")] impl MprisPlayer { // -- Methods -- fn next(&self) { crate::debug_eprintln(format_args!("mpris: next")); crate::daemon::spawn_try_scrobble_current(&self.scrobbler, &self.scrobble_state); if let Ok(mut q) = self.queue.lock() { if let Some(next_path) = q.next() { self.audio.play(next_path.clone()); let track = Track::from_path(next_path); crate::daemon::spawn_send_now_playing(&self.scrobbler, &track); if let Ok(mut s) = self.scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); } else { self.audio.stop(); notification::notify_queue_ended(); q.clear(); } } } fn previous(&self) { crate::debug_eprintln(format_args!("mpris: previous")); crate::daemon::spawn_try_scrobble_current(&self.scrobbler, &self.scrobble_state); if let Ok(mut q) = self.queue.lock() && let Some(prev_path) = q.previous() { self.audio.play(prev_path.clone()); let track = Track::from_path(prev_path); crate::daemon::spawn_send_now_playing(&self.scrobbler, &track); if let Ok(mut s) = self.scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); } } fn pause(&self) { crate::debug_eprintln(format_args!("mpris: pause")); self.audio.pause(); notification::notify_change("Paused"); } fn play_pause(&self) { crate::debug_eprintln(format_args!("mpris: play_pause")); if self.audio.is_playing() { self.audio.pause(); notification::notify_change("Paused"); } else { self.audio.resume(); notification::notify_change("Resumed"); } } fn stop(&self) { crate::debug_eprintln(format_args!("mpris: stop")); crate::daemon::spawn_try_scrobble_current(&self.scrobbler, &self.scrobble_state); self.audio.stop(); if let Ok(mut q) = self.queue.lock() { q.clear(); } notification::notify_change("Stopped"); } fn play(&self) { crate::debug_eprintln(format_args!("mpris: play")); if self.audio.current_track().is_some() { self.audio.resume(); notification::notify_change("Resumed"); } else if let Ok(q) = self.queue.lock() && let Some(track) = q.current() { self.audio.play(track.path.clone()); crate::daemon::spawn_send_now_playing(&self.scrobbler, track); if let Ok(mut s) = self.scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(track, fmt.as_deref()); } } fn seek(&self, _offset: i64) { // Seeking not implemented yet. } fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, _position: i64) { // Seeking not implemented yet. } fn open_uri(&self, uri: String) { crate::debug_eprintln(format_args!("mpris: open_uri {}", uri)); let path_str = uri .strip_prefix("file://") .map(|s| s.to_string()) .unwrap_or_else(|| uri.clone()); let path = PathBuf::from(&path_str); if let Ok(mut q) = self.queue.lock() { let was_empty = q.is_empty(); let was_playing = self.audio.is_playing(); if path_str.starts_with("http://") || path_str.starts_with("https://") { let track = Track::from_url(&path_str); q.push_track(track.clone()); if was_empty && !was_playing && let Some(t) = q.current() { self.audio.play(t.path.clone()); crate::daemon::spawn_send_now_playing(&self.scrobbler, t); if let Ok(mut s) = self.scrobble_state.lock() { s.start(t.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(t, fmt.as_deref()); } } else if path.is_file() { q.add_track(path.clone()); if was_empty && !was_playing { let track = Track::from_path(path); self.audio.play(track.path.clone()); crate::daemon::spawn_send_now_playing(&self.scrobbler, &track); if let Ok(mut s) = self.scrobble_state.lock() { s.start(track.clone()); } let fmt = Config::load().ok().and_then(|c| c.display_format.clone()); notification::notify_track(&track, fmt.as_deref()); } } } } // -- Properties -- #[zbus(property)] fn playback_status(&self) -> String { playback_status(&self.audio) } #[zbus(property)] fn loop_status(&self) -> String { self.loop_status .lock() .map(|s| s.clone()) .unwrap_or_else(|_| "None".to_string()) } /// NOTE: repeat behavior is not implemented in the queue yet. This only records what a /// client asked for so controls such as the Plasma widget's repeat button round-trip /// instead of snapping straight back to "None". #[zbus(property)] #[zbus(name = "LoopStatus")] fn set_loop_status(&self, value: String) -> zbus::fdo::Result<()> { match value.as_str() { "None" | "Playlist" | "Track" => { if let Ok(mut s) = self.loop_status.lock() { *s = value; } Ok(()) } other => Err(zbus::fdo::Error::InvalidArgs(format!( "invalid LoopStatus: {other}" ))), } } #[zbus(property)] fn rate(&self) -> f64 { 1.0 } #[zbus(property)] fn shuffle(&self) -> bool { self.queue .lock() .map(|q| !matches!(q.shuffle_mode(), ShuffleMode::None)) .unwrap_or(false) } /// MPRIS only knows on/off, so turning shuffle on maps to [`ShuffleMode::All`] — unless a /// more specific mode (album shuffle) is already active, which this deliberately preserves /// instead of silently downgrading it. #[zbus(property)] #[zbus(name = "Shuffle")] fn set_shuffle(&self, value: bool) { if let Ok(mut q) = self.queue.lock() { let current = *q.shuffle_mode(); let mode = if !value { ShuffleMode::None } else if current == ShuffleMode::None { ShuffleMode::All } else { current }; if mode != current { q.set_shuffle(mode); } } } #[zbus(property)] fn volume(&self) -> f64 { self.audio.get_volume() as f64 / 100.0 } #[zbus(property)] #[zbus(name = "Volume")] fn set_volume(&self, value: f64) { let level = (value * 100.0).clamp(0.0, 100.0) as u8; self.audio.set_volume(level as f32 / 100.0); if let Ok(mut config) = Config::load() { config.volume = Some(level); let _ = config.save(); } } #[zbus(property)] fn position(&self) -> i64 { (self.audio.position() * 1_000_000.0) as i64 } #[zbus(property)] fn minimum_rate(&self) -> f64 { 1.0 } #[zbus(property)] fn maximum_rate(&self) -> f64 { 1.0 } #[zbus(property)] fn can_go_next(&self) -> bool { true } #[zbus(property)] fn can_go_previous(&self) -> bool { true } #[zbus(property)] fn can_play(&self) -> bool { true } #[zbus(property)] fn can_pause(&self) -> bool { true } #[zbus(property)] fn can_seek(&self) -> bool { false } #[zbus(property)] fn can_control(&self) -> bool { true } #[zbus(property)] fn metadata(&self) -> HashMap> { current_metadata(&self.audio, &self.queue) } } // --------------------------------------------------------------------------- // Public entry-point: spawn the MPRIS D-Bus server in a background thread // --------------------------------------------------------------------------- pub fn start_mpris_server( audio: Arc, queue: Arc>, scrobbler: Arc>, scrobble_state: Arc>, shutdown_flag: Arc, ) { thread::Builder::new() .name("mpris".into()) .spawn(move || { let conn = match Connection::session() { Ok(c) => c, Err(e) => { crate::debug_eprintln(format_args!( "mpris: failed to connect to session bus: {}", e )); return; } }; // Clones for the state watcher below; the interface takes the originals. let poll_audio = Arc::clone(&audio); let poll_queue = Arc::clone(&queue); let loop_status = Arc::new(Mutex::new("None".to_string())); let poll_loop_status = Arc::clone(&loop_status); // Register both MPRIS interfaces on the same object path let player = MprisPlayer { audio, queue, scrobbler, scrobble_state, loop_status, }; if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", Mpris2) { crate::debug_eprintln(format_args!( "mpris: failed to register MediaPlayer2: {}", e )); return; } if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", player) { crate::debug_eprintln(format_args!( "mpris: failed to register MediaPlayer2.Player: {}", e )); return; } if let Err(e) = conn.request_name("org.mpris.MediaPlayer2.rsplayer") { crate::debug_eprintln(format_args!("mpris: failed to request bus name: {}", e)); return; } crate::debug_eprintln(format_args!( "mpris: serving on org.mpris.MediaPlayer2.rsplayer" )); // Keep the connection alive until shutdown, and announce our state changes. // // Most MPRIS clients do not poll: they read the properties once when our bus name // appears and afterwards rely exclusively on `PropertiesChanged` signals. The KDE // Plasma "Media Player" widget is one of them, which is why it used to stay frozen // on the values from startup — or stay hidden entirely when we happened to be // stopped at that moment. (playerctl keeps working because it polls on demand.) // // So we watch our own state here and push every change out: playback status, // metadata, volume, shuffle and loop status. The steady tick of the playhead is // deliberately not pushed — the spec tells clients to interpolate while we report // `Playing` — but jumps in it are announced through the `Seeked` signal. let mut last_status: Option = None; let mut last_track: Option = None; let mut last_volume: Option = None; let mut last_shuffle: Option = None; let mut last_loop: Option = None; while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) { let status = playback_status(&poll_audio); let track = current_track_path(&poll_audio, &poll_queue); let volume = poll_audio.get_volume() as f64 / 100.0; let shuffle = poll_queue .lock() .map(|q| *q.shuffle_mode()) .unwrap_or(ShuffleMode::None); let loop_status_value = poll_loop_status .lock() .map(|s| s.clone()) .unwrap_or_else(|_| "None".to_string()); let status_changed = last_status.as_deref() != Some(status.as_str()); let track_changed = last_track != track; let volume_changed = last_volume != Some(volume); let shuffle_changed = last_shuffle.as_ref() != Some(&shuffle); let loop_changed = last_loop.as_deref() != Some(loop_status_value.as_str()); if status_changed || track_changed || volume_changed || shuffle_changed || loop_changed { let mut changed: HashMap<&str, Value<'static>> = HashMap::new(); if status_changed { changed.insert("PlaybackStatus", Value::new(status.clone())); } if track_changed { // Building metadata reads tags from disk, so only do it when the // current track actually changed. changed.insert( "Metadata", Value::new(current_metadata(&poll_audio, &poll_queue)), ); } if volume_changed { changed.insert("Volume", Value::new(volume)); } if shuffle_changed { changed.insert("Shuffle", Value::new(shuffle != ShuffleMode::None)); } if loop_changed { changed.insert("LoopStatus", Value::new(loop_status_value.clone())); } let body = ( "org.mpris.MediaPlayer2.Player", &changed, Vec::<&str>::new(), ); if let Err(e) = conn.emit_signal( None::<&str>, "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "PropertiesChanged", &body, ) { crate::debug_eprintln(format_args!( "mpris: failed to emit PropertiesChanged: {}", e )); } } // 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( None::<&str>, "/org/mpris/MediaPlayer2", "org.mpris.MediaPlayer2.Player", "Seeked", &(position_us,), ) { crate::debug_eprintln(format_args!("mpris: failed to emit Seeked: {}", e)); } } last_status = Some(status); last_track = track; last_volume = Some(volume); last_shuffle = Some(shuffle); last_loop = Some(loop_status_value); thread::sleep(Duration::from_millis(500)); } crate::debug_eprintln(format_args!("mpris: shutting down")); }) .expect("failed to spawn mpris thread"); }