aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-06 15:49:27 +0200
committersalmonik <raisersoft@proton.me>2026-07-06 15:49:27 +0200
commit547e7ac5218327216704e1fb72004a3de6992940 (patch)
tree2913726087b10d9ad0083ef1196fb52439705035
parentfix -a to work properly (diff)
downloadrsplayer-547e7ac5218327216704e1fb72004a3de6992940.tar.gz
rsplayer-547e7ac5218327216704e1fb72004a3de6992940.zip
add MPRIS support and non-blocking notifs
-rw-r--r--Cargo.lock3
-rw-r--r--Cargo.toml3
-rw-r--r--src/daemon.rs13
-rw-r--r--src/lib.rs1
-rw-r--r--src/main.rs1
-rw-r--r--src/mpris.rs467
-rw-r--r--src/notification.rs68
7 files changed, 527 insertions, 29 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 43b17e7..0c3c6af 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
4 4
5[[package]] 5[[package]]
6name = "RSPlayer" 6name = "RSPlayer"
7version = "0.8.6" 7version = "0.9.0"
8dependencies = [ 8dependencies = [
9 "clap", 9 "clap",
10 "clap_complete", 10 "clap_complete",
@@ -23,6 +23,7 @@ dependencies = [
23 "serde_json", 23 "serde_json",
24 "symphonia", 24 "symphonia",
25 "toml", 25 "toml",
26 "zbus",
26] 27]
27 28
28[[package]] 29[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index b64a2ee..e795707 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "RSPlayer" 2name = "RSPlayer"
3version = "0.8.6" 3version = "0.9.0"
4edition = "2024" 4edition = "2024"
5description = "Rust music player daemon with Unix socket IPC" 5description = "Rust music player daemon with Unix socket IPC"
6license = "GPL-3.0 or later" 6license = "GPL-3.0 or later"
@@ -34,6 +34,7 @@ rand = "0.10.1"
34notify-rust = "4.17.0" 34notify-rust = "4.17.0"
35md-5 = "0.11.0" 35md-5 = "0.11.0"
36hex = "0.4.3" 36hex = "0.4.3"
37zbus = { version = "5", default-features = false, features = ["blocking-api"] }
37 38
38[profile.release] 39[profile.release]
39opt-level = "z" 40opt-level = "z"
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 {
102 } 102 }
103} 103}
104 104
105fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) { 105pub fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState) {
106 if let Some(ref track) = state.track 106 if let Some(ref track) = state.track
107 && track.is_stream 107 && track.is_stream
108 { 108 {
@@ -137,7 +137,7 @@ fn try_scrobble_current(scrobbler: &RwLock<Scrobbler>, state: &mut ScrobbleState
137 state.clear(); 137 state.clear();
138} 138}
139 139
140fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) { 140pub fn send_now_playing(scrobbler: &RwLock<Scrobbler>, track: &Track) {
141 if track.is_stream { 141 if track.is_stream {
142 crate::debug_eprintln(format_args!( 142 crate::debug_eprintln(format_args!(
143 "scrobble: skipping now_playing for stream track" 143 "scrobble: skipping now_playing for stream track"
@@ -246,6 +246,15 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
246 // Save full config 246 // Save full config
247 config.save()?; 247 config.save()?;
248 248
249 // Start MPRIS D-Bus server for external control (playerctl, etc.)
250 crate::mpris::start_mpris_server(
251 Arc::clone(&audio_engine),
252 Arc::clone(&queue),
253 Arc::clone(&scrobbler),
254 Arc::clone(&scrobble_state),
255 Arc::clone(&shutdown_flag),
256 );
257
249 // Accept connections until shutdown 258 // Accept connections until shutdown
250 listener.set_nonblocking(true)?; 259 listener.set_nonblocking(true)?;
251 // Helper: poll until a specific track actually starts playing or timeout 260 // 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;
17pub mod config; 17pub mod config;
18pub mod daemon; 18pub mod daemon;
19pub mod database; 19pub mod database;
20pub mod mpris;
20pub mod notification; 21pub mod notification;
21pub mod playback; 22pub mod playback;
22pub mod queue; 23pub 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;
10mod config; 10mod config;
11mod daemon; 11mod daemon;
12mod database; 12mod database;
13mod mpris;
13mod notification; 14mod notification;
14mod playback; 15mod playback;
15mod queue; 16mod 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 @@
1/*
2 * This file is a part of RSPlayer
3 * Licensed under the GPL-3.0 License
4 * Copyright (C) 2026 Salmonik @ salmonik@tuta.io
5 * https://codeberg.org/salmonik/RSPlayer
6*/
7
8//! MPRIS D-Bus interface for external control via playerctl, KDE Connect, etc.
9//!
10//! Implements org.mpris.MediaPlayer2 and org.mpris.MediaPlayer2.Player on the
11//! session D-Bus bus. Uses zbus blocking API so no async runtime is required
12//! in the caller.
13
14use crate::audio::AudioEngine;
15use crate::config::Config;
16use crate::daemon::ScrobbleState;
17use crate::notification;
18use crate::queue::{Queue, ShuffleMode, Track};
19use crate::scrobbler::Scrobbler;
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::atomic::AtomicBool;
23use std::sync::{Arc, Mutex, RwLock};
24use std::thread;
25use std::time::Duration;
26use zbus::blocking::Connection;
27use zbus::interface;
28use zbus::zvariant::Value;
29
30// ---------------------------------------------------------------------------
31// org.mpris.MediaPlayer2 — root interface
32// ---------------------------------------------------------------------------
33
34pub struct Mpris2;
35
36#[interface(name = "org.mpris.MediaPlayer2")]
37impl Mpris2 {
38 #[zbus(property)]
39 fn can_quit(&self) -> bool {
40 true
41 }
42
43 #[zbus(property)]
44 fn can_raise(&self) -> bool {
45 false
46 }
47
48 #[zbus(property)]
49 fn has_track_list(&self) -> bool {
50 false
51 }
52
53 #[zbus(property)]
54 fn identity(&self) -> String {
55 "RSPlayer".to_string()
56 }
57
58 #[zbus(property)]
59 fn desktop_entry(&self) -> String {
60 "rsplayer".to_string()
61 }
62
63 #[zbus(property)]
64 fn supported_uri_schemes(&self) -> Vec<String> {
65 vec!["file".into(), "http".into(), "https".into()]
66 }
67
68 #[zbus(property)]
69 fn supported_mime_types(&self) -> Vec<String> {
70 vec![
71 "audio/mpeg".into(),
72 "audio/flac".into(),
73 "audio/ogg".into(),
74 "audio/wav".into(),
75 "audio/x-wav".into(),
76 "audio/vorbis".into(),
77 ]
78 }
79
80 fn quit(&self) -> zbus::fdo::Result<()> {
81 crate::debug_eprintln(format_args!("mpris: quit requested"));
82 std::process::exit(0);
83 }
84
85 fn raise(&self) {
86 // Terminal-based player — nothing to raise.
87 }
88}
89
90// ---------------------------------------------------------------------------
91// org.mpris.MediaPlayer2.Player — playback control
92// ---------------------------------------------------------------------------
93
94pub struct MprisPlayer {
95 audio: Arc<AudioEngine>,
96 queue: Arc<Mutex<Queue>>,
97 scrobbler: Arc<RwLock<Scrobbler>>,
98 scrobble_state: Arc<Mutex<ScrobbleState>>,
99}
100
101/// Build the `Metadata` map (a{sv}) required by MPRIS, using only owned values
102/// so no borrowing from locals.
103fn build_metadata(track: &Track) -> HashMap<String, Value<'static>> {
104 let mut map: HashMap<String, Value<'static>> = HashMap::new();
105
106 map.insert(
107 "mpris:trackid".into(),
108 Value::new("/org/mpris/MediaPlayer2/TrackList/CurrentTrack"),
109 );
110
111 let length_us = track
112 .duration_secs
113 .map(|d| (d * 1_000_000.0) as i64)
114 .unwrap_or(0);
115 map.insert("mpris:length".into(), Value::new(length_us));
116 map.insert("mpris:artUrl".into(), Value::new(""));
117
118 map.insert(
119 "xesam:title".into(),
120 Value::new(track.title.clone().unwrap_or_default()),
121 );
122
123 let artist = track.artist.clone().unwrap_or_default();
124 map.insert("xesam:artist".into(), Value::new(vec![artist]));
125
126 map.insert(
127 "xesam:album".into(),
128 Value::new(track.album.clone().unwrap_or_default()),
129 );
130
131 map.insert("xesam:trackNumber".into(), Value::new(0i32));
132
133 map
134}
135
136fn empty_metadata() -> HashMap<String, Value<'static>> {
137 let mut map: HashMap<String, Value<'static>> = HashMap::new();
138 map.insert(
139 "mpris:trackid".into(),
140 Value::new("/org/mpris/MediaPlayer2/TrackList/NoTrack"),
141 );
142 map
143}
144
145fn playback_status(audio: &AudioEngine) -> String {
146 if audio.is_playing() {
147 "Playing"
148 } else if audio.current_track().is_some() {
149 "Paused"
150 } else {
151 "Stopped"
152 }
153 .to_string()
154}
155
156#[interface(name = "org.mpris.MediaPlayer2.Player")]
157impl MprisPlayer {
158 // -- Methods --
159
160 fn next(&self) {
161 crate::debug_eprintln(format_args!("mpris: next"));
162 if let Ok(mut s) = self.scrobble_state.lock() {
163 crate::daemon::try_scrobble_current(&self.scrobbler, &mut s);
164 }
165 if let Ok(mut q) = self.queue.lock() {
166 if let Some(next_path) = q.next() {
167 self.audio.play(next_path.clone());
168 let track = Track::from_path(next_path);
169 crate::daemon::send_now_playing(&self.scrobbler, &track);
170 if let Ok(mut s) = self.scrobble_state.lock() {
171 s.start(track.clone());
172 }
173 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
174 notification::notify_track(&track, fmt.as_deref());
175 } else {
176 self.audio.stop();
177 notification::notify_queue_ended();
178 q.clear();
179 }
180 }
181 }
182
183 fn previous(&self) {
184 crate::debug_eprintln(format_args!("mpris: previous"));
185 if let Ok(mut s) = self.scrobble_state.lock() {
186 crate::daemon::try_scrobble_current(&self.scrobbler, &mut s);
187 }
188 if let Ok(mut q) = self.queue.lock()
189 && let Some(prev_path) = q.previous()
190 {
191 self.audio.play(prev_path.clone());
192 let track = Track::from_path(prev_path);
193 crate::daemon::send_now_playing(&self.scrobbler, &track);
194 if let Ok(mut s) = self.scrobble_state.lock() {
195 s.start(track.clone());
196 }
197 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
198 notification::notify_track(&track, fmt.as_deref());
199 }
200 }
201
202 fn pause(&self) {
203 crate::debug_eprintln(format_args!("mpris: pause"));
204 self.audio.pause();
205 notification::notify_change("Paused");
206 }
207
208 fn play_pause(&self) {
209 crate::debug_eprintln(format_args!("mpris: play_pause"));
210 if self.audio.is_playing() {
211 self.audio.pause();
212 notification::notify_change("Paused");
213 } else {
214 self.audio.resume();
215 notification::notify_change("Resumed");
216 }
217 }
218
219 fn stop(&self) {
220 crate::debug_eprintln(format_args!("mpris: stop"));
221 if let Ok(mut s) = self.scrobble_state.lock() {
222 crate::daemon::try_scrobble_current(&self.scrobbler, &mut s);
223 }
224 self.audio.stop();
225 if let Ok(mut q) = self.queue.lock() {
226 q.clear();
227 }
228 notification::notify_change("Stopped");
229 }
230
231 fn play(&self) {
232 crate::debug_eprintln(format_args!("mpris: play"));
233 if self.audio.current_track().is_some() {
234 self.audio.resume();
235 notification::notify_change("Resumed");
236 } else if let Ok(q) = self.queue.lock()
237 && let Some(track) = q.current()
238 {
239 self.audio.play(track.path.clone());
240 crate::daemon::send_now_playing(&self.scrobbler, track);
241 if let Ok(mut s) = self.scrobble_state.lock() {
242 s.start(track.clone());
243 }
244 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
245 notification::notify_track(track, fmt.as_deref());
246 }
247 }
248
249 fn seek(&self, _offset: i64) {
250 // Seeking not implemented yet.
251 }
252
253 fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, _position: i64) {
254 // Seeking not implemented yet.
255 }
256
257 fn open_uri(&self, uri: String) {
258 crate::debug_eprintln(format_args!("mpris: open_uri {}", uri));
259 let path_str = uri
260 .strip_prefix("file://")
261 .map(|s| s.to_string())
262 .unwrap_or_else(|| uri.clone());
263
264 let path = PathBuf::from(&path_str);
265 if let Ok(mut q) = self.queue.lock() {
266 let was_empty = q.is_empty();
267 let was_playing = self.audio.is_playing();
268
269 if path_str.starts_with("http://") || path_str.starts_with("https://") {
270 let track = Track::from_url(&path_str);
271 q.push_track(track.clone());
272 if was_empty
273 && !was_playing
274 && let Some(t) = q.current()
275 {
276 self.audio.play(t.path.clone());
277 crate::daemon::send_now_playing(&self.scrobbler, t);
278 if let Ok(mut s) = self.scrobble_state.lock() {
279 s.start(t.clone());
280 }
281 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
282 notification::notify_track(t, fmt.as_deref());
283 }
284 } else if path.is_file() {
285 q.add_track(path.clone());
286 if was_empty && !was_playing {
287 let track = Track::from_path(path);
288 self.audio.play(track.path.clone());
289 crate::daemon::send_now_playing(&self.scrobbler, &track);
290 if let Ok(mut s) = self.scrobble_state.lock() {
291 s.start(track.clone());
292 }
293 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
294 notification::notify_track(&track, fmt.as_deref());
295 }
296 }
297 }
298 }
299
300 // -- Properties --
301
302 #[zbus(property)]
303 fn playback_status(&self) -> String {
304 playback_status(&self.audio)
305 }
306
307 #[zbus(property)]
308 fn loop_status(&self) -> String {
309 "None".to_string()
310 }
311
312 #[zbus(property)]
313 fn rate(&self) -> f64 {
314 1.0
315 }
316
317 #[zbus(property)]
318 fn shuffle(&self) -> bool {
319 self.queue
320 .lock()
321 .map(|q| !matches!(q.shuffle_mode(), ShuffleMode::None))
322 .unwrap_or(false)
323 }
324
325 #[zbus(property)]
326 fn volume(&self) -> f64 {
327 self.audio.get_volume() as f64 / 100.0
328 }
329
330 #[zbus(property)]
331 #[zbus(name = "Volume")]
332 fn set_volume(&self, value: f64) {
333 let level = (value * 100.0).clamp(0.0, 100.0) as u8;
334 self.audio.set_volume(level as f32 / 100.0);
335 if let Ok(mut config) = Config::load() {
336 config.volume = Some(level);
337 let _ = config.save();
338 }
339 }
340
341 #[zbus(property)]
342 fn position(&self) -> i64 {
343 (self.audio.position() * 1_000_000.0) as i64
344 }
345
346 #[zbus(property)]
347 fn minimum_rate(&self) -> f64 {
348 1.0
349 }
350
351 #[zbus(property)]
352 fn maximum_rate(&self) -> f64 {
353 1.0
354 }
355
356 #[zbus(property)]
357 fn can_go_next(&self) -> bool {
358 true
359 }
360
361 #[zbus(property)]
362 fn can_go_previous(&self) -> bool {
363 true
364 }
365
366 #[zbus(property)]
367 fn can_play(&self) -> bool {
368 true
369 }
370
371 #[zbus(property)]
372 fn can_pause(&self) -> bool {
373 true
374 }
375
376 #[zbus(property)]
377 fn can_seek(&self) -> bool {
378 false
379 }
380
381 #[zbus(property)]
382 fn can_control(&self) -> bool {
383 true
384 }
385
386 #[zbus(property)]
387 fn metadata(&self) -> HashMap<String, Value<'static>> {
388 if let Some(track_path) = self.audio.current_track() {
389 let track = Track::from_path(track_path);
390 build_metadata(&track)
391 } else if let Ok(q) = self.queue.lock()
392 && let Some(track) = q.current()
393 {
394 build_metadata(track)
395 } else {
396 empty_metadata()
397 }
398 }
399}
400
401// ---------------------------------------------------------------------------
402// Public entry-point: spawn the MPRIS D-Bus server in a background thread
403// ---------------------------------------------------------------------------
404
405pub fn start_mpris_server(
406 audio: Arc<AudioEngine>,
407 queue: Arc<Mutex<Queue>>,
408 scrobbler: Arc<RwLock<Scrobbler>>,
409 scrobble_state: Arc<Mutex<ScrobbleState>>,
410 shutdown_flag: Arc<AtomicBool>,
411) {
412 thread::Builder::new()
413 .name("mpris".into())
414 .spawn(move || {
415 let conn = match Connection::session() {
416 Ok(c) => c,
417 Err(e) => {
418 crate::debug_eprintln(format_args!(
419 "mpris: failed to connect to session bus: {}",
420 e
421 ));
422 return;
423 }
424 };
425
426 // Register both MPRIS interfaces on the same object path
427 let player = MprisPlayer {
428 audio,
429 queue,
430 scrobbler,
431 scrobble_state,
432 };
433
434 if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", Mpris2) {
435 crate::debug_eprintln(format_args!(
436 "mpris: failed to register MediaPlayer2: {}",
437 e
438 ));
439 return;
440 }
441
442 if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", player) {
443 crate::debug_eprintln(format_args!(
444 "mpris: failed to register MediaPlayer2.Player: {}",
445 e
446 ));
447 return;
448 }
449
450 if let Err(e) = conn.request_name("org.mpris.MediaPlayer2.rsplayer") {
451 crate::debug_eprintln(format_args!("mpris: failed to request bus name: {}", e));
452 return;
453 }
454
455 crate::debug_eprintln(format_args!(
456 "mpris: serving on org.mpris.MediaPlayer2.rsplayer"
457 ));
458
459 // Keep the connection alive until shutdown.
460 while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) {
461 thread::sleep(Duration::from_millis(500));
462 }
463
464 crate::debug_eprintln(format_args!("mpris: shutting down"));
465 })
466 .expect("failed to spawn mpris thread");
467}
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>) {
22 }, 22 },
23 }; 23 };
24 24
25 let _ = notify_rust::Notification::new() 25 // show() is a synchronous D-Bus call that blocks until the notification
26 .summary("Now Playing") 26 // daemon responds. Spawn it on a separate thread so it never blocks the
27 .body(&body) 27 // player event loop or CLI command processing.
28 .appname("RSPlayer") 28 std::thread::spawn(move || {
29 .show(); 29 let _ = notify_rust::Notification::new()
30 .summary("Now Playing")
31 .body(&body)
32 .appname("RSPlayer")
33 .show();
34 });
30} 35}
31 36
32pub fn notify_queue_ended() { 37pub fn notify_queue_ended() {
33 let _ = notify_rust::Notification::new() 38 std::thread::spawn(|| {
34 .summary("Playback Ended") 39 let _ = notify_rust::Notification::new()
35 .body("The queue is empty") 40 .summary("Playback Ended")
36 .appname("RSPlayer") 41 .body("The queue is empty")
37 .show(); 42 .appname("RSPlayer")
43 .show();
44 });
38} 45}
39 46
40pub fn notify_change(state: &str) { 47pub fn notify_change(state: &str) {
41 let _ = notify_rust::Notification::new() 48 let summary = state.to_owned();
42 .summary(state) 49 let body = format!("Playback {}", state.to_lowercase());
43 .body(&format!("Playback {}", state.to_lowercase())) 50 std::thread::spawn(move || {
44 .appname("RSPlayer") 51 let _ = notify_rust::Notification::new()
45 .show(); 52 .summary(&summary)
53 .body(&body)
54 .appname("RSPlayer")
55 .show();
56 });
46} 57}
47 58
48pub fn notify_error(context: &str) { 59pub fn notify_error(context: &str) {
49 let _ = notify_rust::Notification::new() 60 let body = format!("{} - Check the logs for details", context);
50 .summary("RSPlayer Error") 61 std::thread::spawn(move || {
51 .body(&format!("{} - Check the logs for details", context)) 62 let _ = notify_rust::Notification::new()
52 .appname("RSPlayer") 63 .summary("RSPlayer Error")
53 .show(); 64 .body(&body)
65 .appname("RSPlayer")
66 .show();
67 });
54} 68}
55 69
56pub fn notify(summary: &str, body: &str) { 70pub fn notify(summary: &str, body: &str) {
57 let _ = notify_rust::Notification::new() 71 let summary = summary.to_owned();
58 .summary(summary) 72 let body = body.to_owned();
59 .body(body) 73 std::thread::spawn(move || {
60 .appname("RSPlayer") 74 let _ = notify_rust::Notification::new()
61 .show(); 75 .summary(&summary)
76 .body(&body)
77 .appname("RSPlayer")
78 .show();
79 });
62} 80}