From 547e7ac5218327216704e1fb72004a3de6992940 Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 6 Jul 2026 15:49:27 +0200 Subject: add MPRIS support and non-blocking notifs --- src/daemon.rs | 13 +- src/lib.rs | 1 + src/main.rs | 1 + src/mpris.rs | 467 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/notification.rs | 68 +++++--- 5 files changed, 523 insertions(+), 27 deletions(-) create mode 100644 src/mpris.rs (limited to 'src') diff --git a/src/daemon.rs b/src/daemon.rs index 61d021b..1c918a8 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -102,7 +102,7 @@ impl ScrobbleState { } } -fn try_scrobble_current(scrobbler: &RwLock, state: &mut ScrobbleState) { +pub fn try_scrobble_current(scrobbler: &RwLock, state: &mut ScrobbleState) { if let Some(ref track) = state.track && track.is_stream { @@ -137,7 +137,7 @@ fn try_scrobble_current(scrobbler: &RwLock, state: &mut ScrobbleState state.clear(); } -fn send_now_playing(scrobbler: &RwLock, track: &Track) { +pub fn send_now_playing(scrobbler: &RwLock, track: &Track) { if track.is_stream { crate::debug_eprintln(format_args!( "scrobble: skipping now_playing for stream track" @@ -246,6 +246,15 @@ pub fn start_daemon() -> Result<(), Box> { // Save full config config.save()?; + // Start MPRIS D-Bus server for external control (playerctl, etc.) + crate::mpris::start_mpris_server( + Arc::clone(&audio_engine), + Arc::clone(&queue), + Arc::clone(&scrobbler), + Arc::clone(&scrobble_state), + Arc::clone(&shutdown_flag), + ); + // Accept connections until shutdown listener.set_nonblocking(true)?; // Helper: poll until a specific track actually starts playing or timeout diff --git a/src/lib.rs b/src/lib.rs index cc1f70a..f08e8ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod cli; pub mod config; pub mod daemon; pub mod database; +pub mod mpris; pub mod notification; pub mod playback; pub mod queue; diff --git a/src/main.rs b/src/main.rs index d472b2d..fd60bef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod cli; mod config; mod daemon; mod database; +mod mpris; mod notification; mod playback; mod queue; diff --git a/src/mpris.rs b/src/mpris.rs new file mode 100644 index 0000000..0c637c0 --- /dev/null +++ b/src/mpris.rs @@ -0,0 +1,467 @@ +/* + * 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(0i32)); + + 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")); + if let Ok(mut s) = self.scrobble_state.lock() { + crate::daemon::try_scrobble_current(&self.scrobbler, &mut s); + } + 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::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")); + if let Ok(mut s) = self.scrobble_state.lock() { + crate::daemon::try_scrobble_current(&self.scrobbler, &mut s); + } + 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::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")); + if let Ok(mut s) = self.scrobble_state.lock() { + crate::daemon::try_scrobble_current(&self.scrobbler, &mut s); + } + 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::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::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::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"); +} diff --git a/src/notification.rs b/src/notification.rs index b2d2b56..9c19b0b 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -22,41 +22,59 @@ pub fn notify_track(track: &Track, format: Option<&str>) { }, }; - let _ = notify_rust::Notification::new() - .summary("Now Playing") - .body(&body) - .appname("RSPlayer") - .show(); + // show() is a synchronous D-Bus call that blocks until the notification + // daemon responds. Spawn it on a separate thread so it never blocks the + // player event loop or CLI command processing. + std::thread::spawn(move || { + let _ = notify_rust::Notification::new() + .summary("Now Playing") + .body(&body) + .appname("RSPlayer") + .show(); + }); } pub fn notify_queue_ended() { - let _ = notify_rust::Notification::new() - .summary("Playback Ended") - .body("The queue is empty") - .appname("RSPlayer") - .show(); + std::thread::spawn(|| { + let _ = notify_rust::Notification::new() + .summary("Playback Ended") + .body("The queue is empty") + .appname("RSPlayer") + .show(); + }); } pub fn notify_change(state: &str) { - let _ = notify_rust::Notification::new() - .summary(state) - .body(&format!("Playback {}", state.to_lowercase())) - .appname("RSPlayer") - .show(); + let summary = state.to_owned(); + let body = format!("Playback {}", state.to_lowercase()); + std::thread::spawn(move || { + let _ = notify_rust::Notification::new() + .summary(&summary) + .body(&body) + .appname("RSPlayer") + .show(); + }); } pub fn notify_error(context: &str) { - let _ = notify_rust::Notification::new() - .summary("RSPlayer Error") - .body(&format!("{} - Check the logs for details", context)) - .appname("RSPlayer") - .show(); + let body = format!("{} - Check the logs for details", context); + std::thread::spawn(move || { + let _ = notify_rust::Notification::new() + .summary("RSPlayer Error") + .body(&body) + .appname("RSPlayer") + .show(); + }); } pub fn notify(summary: &str, body: &str) { - let _ = notify_rust::Notification::new() - .summary(summary) - .body(body) - .appname("RSPlayer") - .show(); + let summary = summary.to_owned(); + let body = body.to_owned(); + std::thread::spawn(move || { + let _ = notify_rust::Notification::new() + .summary(&summary) + .body(&body) + .appname("RSPlayer") + .show(); + }); } -- cgit v1.2.3 From fdfe24213d70b5548fb09051a0e32b7224210231 Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 6 Jul 2026 15:58:01 +0200 Subject: add track index to MPRIS --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/mpris.rs | 5 ++++- src/queue.rs | 3 +++ tests/daemon.rs | 1 + tests/queue.rs | 2 ++ 6 files changed, 12 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/Cargo.lock b/Cargo.lock index 0c3c6af..acf5ce5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "RSPlayer" -version = "0.9.0" +version = "0.9.1" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index e795707..ce8ca48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "RSPlayer" -version = "0.9.0" +version = "0.9.1" edition = "2024" description = "Rust music player daemon with Unix socket IPC" license = "GPL-3.0 or later" diff --git a/src/mpris.rs b/src/mpris.rs index 0c637c0..a7ac360 100644 --- a/src/mpris.rs +++ b/src/mpris.rs @@ -128,7 +128,10 @@ fn build_metadata(track: &Track) -> HashMap> { Value::new(track.album.clone().unwrap_or_default()), ); - map.insert("xesam:trackNumber".into(), Value::new(0i32)); + map.insert( + "xesam:trackNumber".into(), + Value::new(track.track_number.unwrap_or(0)), + ); map } diff --git a/src/queue.rs b/src/queue.rs index 7675ddd..1f2f0fc 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -98,6 +98,7 @@ pub struct Track { pub title: Option, pub artist: Option, pub album: Option, + pub track_number: Option, pub duration_secs: Option, pub is_stream: bool, } @@ -115,6 +116,7 @@ impl Track { title: metadata.and_then(|m| m.title.clone()), artist: metadata.and_then(|m| m.artist.clone()), album: metadata.and_then(|m| m.album.clone()), + track_number: metadata.and_then(|m| m.track_number), duration_secs: metadata.and_then(|m| m.duration_secs), is_stream: false, } @@ -139,6 +141,7 @@ impl Track { title: Some(title), artist: None, album: None, + track_number: None, duration_secs: None, is_stream: true, } diff --git a/tests/daemon.rs b/tests/daemon.rs index e5cb270..01241fd 100644 --- a/tests/daemon.rs +++ b/tests/daemon.rs @@ -26,6 +26,7 @@ fn test_scrobble_state_clear() { title: Some("Test".into()), artist: Some("Artist".into()), album: None, + track_number: None, duration_secs: Some(200.0), is_stream: false, }; diff --git a/tests/queue.rs b/tests/queue.rs index 59311d2..2eaa6f3 100644 --- a/tests/queue.rs +++ b/tests/queue.rs @@ -14,6 +14,7 @@ fn track(path: &str, title: Option<&str>, artist: Option<&str>, album: Option<&s title: title.map(|s| s.to_string()), artist: artist.map(|s| s.to_string()), album: album.map(|s| s.to_string()), + track_number: None, duration_secs: None, is_stream: false, } @@ -25,6 +26,7 @@ fn track_no_meta(path: &str) -> Track { title: None, artist: None, album: None, + track_number: None, duration_secs: None, is_stream: false, } -- cgit v1.2.3