/* * 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::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>, } /// 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(); map.insert( "mpris:trackid".into(), Value::new("/org/mpris/MediaPlayer2/TrackList/CurrentTrack"), ); 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)); map.insert("mpris:artUrl".into(), Value::new("")); 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(); map.insert( "mpris:trackid".into(), Value::new("/org/mpris/MediaPlayer2/TrackList/NoTrack"), ); map } 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 { "None".to_string() } #[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) } #[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> { if let Some(track_path) = self.audio.current_track() { let track = Track::from_path(track_path); build_metadata(&track) } else if let Ok(q) = self.queue.lock() && let Some(track) = q.current() { build_metadata(track) } else { empty_metadata() } } } // --------------------------------------------------------------------------- // 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; } }; // Register both MPRIS interfaces on the same object path let player = MprisPlayer { audio, queue, scrobbler, scrobble_state, }; 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. while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) { thread::sleep(Duration::from_millis(500)); } crate::debug_eprintln(format_args!("mpris: shutting down")); }) .expect("failed to spawn mpris thread"); }