aboutsummaryrefslogtreecommitdiffstats
path: root/src/mpris.rs
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 /src/mpris.rs
parentfix -a to work properly (diff)
downloadrsplayer-547e7ac5218327216704e1fb72004a3de6992940.tar.gz
rsplayer-547e7ac5218327216704e1fb72004a3de6992940.zip
add MPRIS support and non-blocking notifs
Diffstat (limited to 'src/mpris.rs')
-rw-r--r--src/mpris.rs467
1 files changed, 467 insertions, 0 deletions
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}