aboutsummaryrefslogtreecommitdiffstats
path: root/src/mpris.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@noreply.codeberg.org>2026-07-06 15:59:34 +0200
committersalmonik <salmonik@noreply.codeberg.org>2026-07-06 15:59:34 +0200
commit338b4d600e2d41ef52858c73adf23473183a60a9 (patch)
treeced63779d57e44fa35a896b27bf07e9eee120d5f /src/mpris.rs
parentfix -a to work properly (diff)
parentadd track index to MPRIS (diff)
downloadrsplayer-338b4d600e2d41ef52858c73adf23473183a60a9.tar.gz
rsplayer-338b4d600e2d41ef52858c73adf23473183a60a9.zip
Merge pull request 'mpris merge' (#2) from mpris into main
Reviewed-on: https://codeberg.org/salmonik/RSPlayer/pulls/2
Diffstat (limited to '')
-rw-r--r--src/mpris.rs470
1 files changed, 470 insertions, 0 deletions
diff --git a/src/mpris.rs b/src/mpris.rs
new file mode 100644
index 0000000..a7ac360
--- /dev/null
+++ b/src/mpris.rs
@@ -0,0 +1,470 @@
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(
132 "xesam:trackNumber".into(),
133 Value::new(track.track_number.unwrap_or(0)),
134 );
135
136 map
137}
138
139fn empty_metadata() -> HashMap<String, Value<'static>> {
140 let mut map: HashMap<String, Value<'static>> = HashMap::new();
141 map.insert(
142 "mpris:trackid".into(),
143 Value::new("/org/mpris/MediaPlayer2/TrackList/NoTrack"),
144 );
145 map
146}
147
148fn playback_status(audio: &AudioEngine) -> String {
149 if audio.is_playing() {
150 "Playing"
151 } else if audio.current_track().is_some() {
152 "Paused"
153 } else {
154 "Stopped"
155 }
156 .to_string()
157}
158
159#[interface(name = "org.mpris.MediaPlayer2.Player")]
160impl MprisPlayer {
161 // -- Methods --
162
163 fn next(&self) {
164 crate::debug_eprintln(format_args!("mpris: next"));
165 if let Ok(mut s) = self.scrobble_state.lock() {
166 crate::daemon::try_scrobble_current(&self.scrobbler, &mut s);
167 }
168 if let Ok(mut q) = self.queue.lock() {
169 if let Some(next_path) = q.next() {
170 self.audio.play(next_path.clone());
171 let track = Track::from_path(next_path);
172 crate::daemon::send_now_playing(&self.scrobbler, &track);
173 if let Ok(mut s) = self.scrobble_state.lock() {
174 s.start(track.clone());
175 }
176 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
177 notification::notify_track(&track, fmt.as_deref());
178 } else {
179 self.audio.stop();
180 notification::notify_queue_ended();
181 q.clear();
182 }
183 }
184 }
185
186 fn previous(&self) {
187 crate::debug_eprintln(format_args!("mpris: previous"));
188 if let Ok(mut s) = self.scrobble_state.lock() {
189 crate::daemon::try_scrobble_current(&self.scrobbler, &mut s);
190 }
191 if let Ok(mut q) = self.queue.lock()
192 && let Some(prev_path) = q.previous()
193 {
194 self.audio.play(prev_path.clone());
195 let track = Track::from_path(prev_path);
196 crate::daemon::send_now_playing(&self.scrobbler, &track);
197 if let Ok(mut s) = self.scrobble_state.lock() {
198 s.start(track.clone());
199 }
200 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
201 notification::notify_track(&track, fmt.as_deref());
202 }
203 }
204
205 fn pause(&self) {
206 crate::debug_eprintln(format_args!("mpris: pause"));
207 self.audio.pause();
208 notification::notify_change("Paused");
209 }
210
211 fn play_pause(&self) {
212 crate::debug_eprintln(format_args!("mpris: play_pause"));
213 if self.audio.is_playing() {
214 self.audio.pause();
215 notification::notify_change("Paused");
216 } else {
217 self.audio.resume();
218 notification::notify_change("Resumed");
219 }
220 }
221
222 fn stop(&self) {
223 crate::debug_eprintln(format_args!("mpris: stop"));
224 if let Ok(mut s) = self.scrobble_state.lock() {
225 crate::daemon::try_scrobble_current(&self.scrobbler, &mut s);
226 }
227 self.audio.stop();
228 if let Ok(mut q) = self.queue.lock() {
229 q.clear();
230 }
231 notification::notify_change("Stopped");
232 }
233
234 fn play(&self) {
235 crate::debug_eprintln(format_args!("mpris: play"));
236 if self.audio.current_track().is_some() {
237 self.audio.resume();
238 notification::notify_change("Resumed");
239 } else if let Ok(q) = self.queue.lock()
240 && let Some(track) = q.current()
241 {
242 self.audio.play(track.path.clone());
243 crate::daemon::send_now_playing(&self.scrobbler, track);
244 if let Ok(mut s) = self.scrobble_state.lock() {
245 s.start(track.clone());
246 }
247 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
248 notification::notify_track(track, fmt.as_deref());
249 }
250 }
251
252 fn seek(&self, _offset: i64) {
253 // Seeking not implemented yet.
254 }
255
256 fn set_position(&self, _track_id: zbus::zvariant::ObjectPath<'_>, _position: i64) {
257 // Seeking not implemented yet.
258 }
259
260 fn open_uri(&self, uri: String) {
261 crate::debug_eprintln(format_args!("mpris: open_uri {}", uri));
262 let path_str = uri
263 .strip_prefix("file://")
264 .map(|s| s.to_string())
265 .unwrap_or_else(|| uri.clone());
266
267 let path = PathBuf::from(&path_str);
268 if let Ok(mut q) = self.queue.lock() {
269 let was_empty = q.is_empty();
270 let was_playing = self.audio.is_playing();
271
272 if path_str.starts_with("http://") || path_str.starts_with("https://") {
273 let track = Track::from_url(&path_str);
274 q.push_track(track.clone());
275 if was_empty
276 && !was_playing
277 && let Some(t) = q.current()
278 {
279 self.audio.play(t.path.clone());
280 crate::daemon::send_now_playing(&self.scrobbler, t);
281 if let Ok(mut s) = self.scrobble_state.lock() {
282 s.start(t.clone());
283 }
284 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
285 notification::notify_track(t, fmt.as_deref());
286 }
287 } else if path.is_file() {
288 q.add_track(path.clone());
289 if was_empty && !was_playing {
290 let track = Track::from_path(path);
291 self.audio.play(track.path.clone());
292 crate::daemon::send_now_playing(&self.scrobbler, &track);
293 if let Ok(mut s) = self.scrobble_state.lock() {
294 s.start(track.clone());
295 }
296 let fmt = Config::load().ok().and_then(|c| c.display_format.clone());
297 notification::notify_track(&track, fmt.as_deref());
298 }
299 }
300 }
301 }
302
303 // -- Properties --
304
305 #[zbus(property)]
306 fn playback_status(&self) -> String {
307 playback_status(&self.audio)
308 }
309
310 #[zbus(property)]
311 fn loop_status(&self) -> String {
312 "None".to_string()
313 }
314
315 #[zbus(property)]
316 fn rate(&self) -> f64 {
317 1.0
318 }
319
320 #[zbus(property)]
321 fn shuffle(&self) -> bool {
322 self.queue
323 .lock()
324 .map(|q| !matches!(q.shuffle_mode(), ShuffleMode::None))
325 .unwrap_or(false)
326 }
327
328 #[zbus(property)]
329 fn volume(&self) -> f64 {
330 self.audio.get_volume() as f64 / 100.0
331 }
332
333 #[zbus(property)]
334 #[zbus(name = "Volume")]
335 fn set_volume(&self, value: f64) {
336 let level = (value * 100.0).clamp(0.0, 100.0) as u8;
337 self.audio.set_volume(level as f32 / 100.0);
338 if let Ok(mut config) = Config::load() {
339 config.volume = Some(level);
340 let _ = config.save();
341 }
342 }
343
344 #[zbus(property)]
345 fn position(&self) -> i64 {
346 (self.audio.position() * 1_000_000.0) as i64
347 }
348
349 #[zbus(property)]
350 fn minimum_rate(&self) -> f64 {
351 1.0
352 }
353
354 #[zbus(property)]
355 fn maximum_rate(&self) -> f64 {
356 1.0
357 }
358
359 #[zbus(property)]
360 fn can_go_next(&self) -> bool {
361 true
362 }
363
364 #[zbus(property)]
365 fn can_go_previous(&self) -> bool {
366 true
367 }
368
369 #[zbus(property)]
370 fn can_play(&self) -> bool {
371 true
372 }
373
374 #[zbus(property)]
375 fn can_pause(&self) -> bool {
376 true
377 }
378
379 #[zbus(property)]
380 fn can_seek(&self) -> bool {
381 false
382 }
383
384 #[zbus(property)]
385 fn can_control(&self) -> bool {
386 true
387 }
388
389 #[zbus(property)]
390 fn metadata(&self) -> HashMap<String, Value<'static>> {
391 if let Some(track_path) = self.audio.current_track() {
392 let track = Track::from_path(track_path);
393 build_metadata(&track)
394 } else if let Ok(q) = self.queue.lock()
395 && let Some(track) = q.current()
396 {
397 build_metadata(track)
398 } else {
399 empty_metadata()
400 }
401 }
402}
403
404// ---------------------------------------------------------------------------
405// Public entry-point: spawn the MPRIS D-Bus server in a background thread
406// ---------------------------------------------------------------------------
407
408pub fn start_mpris_server(
409 audio: Arc<AudioEngine>,
410 queue: Arc<Mutex<Queue>>,
411 scrobbler: Arc<RwLock<Scrobbler>>,
412 scrobble_state: Arc<Mutex<ScrobbleState>>,
413 shutdown_flag: Arc<AtomicBool>,
414) {
415 thread::Builder::new()
416 .name("mpris".into())
417 .spawn(move || {
418 let conn = match Connection::session() {
419 Ok(c) => c,
420 Err(e) => {
421 crate::debug_eprintln(format_args!(
422 "mpris: failed to connect to session bus: {}",
423 e
424 ));
425 return;
426 }
427 };
428
429 // Register both MPRIS interfaces on the same object path
430 let player = MprisPlayer {
431 audio,
432 queue,
433 scrobbler,
434 scrobble_state,
435 };
436
437 if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", Mpris2) {
438 crate::debug_eprintln(format_args!(
439 "mpris: failed to register MediaPlayer2: {}",
440 e
441 ));
442 return;
443 }
444
445 if let Err(e) = conn.object_server().at("/org/mpris/MediaPlayer2", player) {
446 crate::debug_eprintln(format_args!(
447 "mpris: failed to register MediaPlayer2.Player: {}",
448 e
449 ));
450 return;
451 }
452
453 if let Err(e) = conn.request_name("org.mpris.MediaPlayer2.rsplayer") {
454 crate::debug_eprintln(format_args!("mpris: failed to request bus name: {}", e));
455 return;
456 }
457
458 crate::debug_eprintln(format_args!(
459 "mpris: serving on org.mpris.MediaPlayer2.rsplayer"
460 ));
461
462 // Keep the connection alive until shutdown.
463 while !shutdown_flag.load(std::sync::atomic::Ordering::SeqCst) {
464 thread::sleep(Duration::from_millis(500));
465 }
466
467 crate::debug_eprintln(format_args!("mpris: shutting down"));
468 })
469 .expect("failed to spawn mpris thread");
470}