onedrop_engine/
mpris.rs

1//! MPRIS2 progress reader.
2//!
3//! Polls the freedesktop MPRIS2 D-Bus interface on a background thread
4//! so the engine can derive `progress` from the active media player's
5//! track position (`Position / mpris:length`) instead of the local
6//! preset-display window.
7//!
8//! Enabled via the `mpris` cargo feature; the engine falls back to the
9//! local-window computation when the feature is off, when no D-Bus
10//! session is reachable, or when no player exposes a non-zero
11//! `mpris:length`.
12
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::{Arc, Mutex};
15use std::thread;
16use std::time::Duration;
17
18use dbus::arg::{PropMap, RefArg};
19use dbus::blocking::Connection;
20use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
21
22const POLL_INTERVAL: Duration = Duration::from_millis(250);
23const DBUS_TIMEOUT: Duration = Duration::from_millis(500);
24const MPRIS_NAME_PREFIX: &str = "org.mpris.MediaPlayer2.";
25const MPRIS_PATH: &str = "/org/mpris/MediaPlayer2";
26const MPRIS_PLAYER_IFACE: &str = "org.mpris.MediaPlayer2.Player";
27
28/// Snapshot of the currently-active MPRIS player's track position.
29#[derive(Debug, Clone, Default)]
30pub struct MprisSnapshot {
31    /// `[0, 1]` position within the current track, derived from
32    /// `Position / mpris:length`. `None` when no player is reachable,
33    /// when the player is stopped, or when the metadata exposes a
34    /// zero / missing `mpris:length` (some web players don't publish
35    /// duration for live streams).
36    pub progress: Option<f32>,
37    /// `xesam:title` of the current track, if exposed. Surfaced so a
38    /// future HUD / message overlay can render the song title without
39    /// re-opening its own D-Bus connection.
40    pub title: Option<String>,
41    /// D-Bus name of the player we last read from (e.g.,
42    /// `org.mpris.MediaPlayer2.spotify`). Useful for diagnostics.
43    pub player_name: Option<String>,
44}
45
46/// Background MPRIS2 progress reader.
47///
48/// Polling on a dedicated thread avoids the per-frame D-Bus latency
49/// (a couple ms per round-trip on a quiet desktop, much worse if a
50/// player is slow to respond). The engine consumes the snapshot
51/// through [`MprisPoller::snapshot`] each frame; the call is a cheap
52/// `Mutex` lock + clone.
53pub struct MprisPoller {
54    state: Arc<Mutex<MprisSnapshot>>,
55    stop: Arc<AtomicBool>,
56}
57
58impl MprisPoller {
59    /// Spawn the background poller. Returns `None` when the D-Bus
60    /// session bus isn't reachable (headless CI, sandbox without bus
61    /// access, …) so the caller can fall back to the local-window
62    /// `progress` instead of paying for a poller that will never
63    /// produce a useful snapshot.
64    pub fn spawn() -> Option<Self> {
65        // Verify the session bus is reachable upfront so the caller
66        // gets a definite signal at startup. The polling thread opens
67        // its own connection because `dbus::blocking::Connection` is
68        // not `Send`.
69        Connection::new_session().ok()?;
70
71        let state = Arc::new(Mutex::new(MprisSnapshot::default()));
72        let stop = Arc::new(AtomicBool::new(false));
73        let state_t = Arc::clone(&state);
74        let stop_t = Arc::clone(&stop);
75        thread::Builder::new()
76            .name("onedrop-mpris".into())
77            .spawn(move || {
78                let Ok(conn) = Connection::new_session() else {
79                    log::warn!("MPRIS poller: failed to open session bus, thread exiting");
80                    return;
81                };
82                while !stop_t.load(Ordering::Relaxed) {
83                    let snap = poll_once(&conn).unwrap_or_default();
84                    if let Ok(mut s) = state_t.lock() {
85                        *s = snap;
86                    }
87                    thread::sleep(POLL_INTERVAL);
88                }
89            })
90            .ok()?;
91
92        Some(Self { state, stop })
93    }
94
95    /// Current snapshot. Cheap (mutex lock + clone, no D-Bus traffic).
96    pub fn snapshot(&self) -> MprisSnapshot {
97        self.state.lock().map(|s| s.clone()).unwrap_or_default()
98    }
99}
100
101impl Drop for MprisPoller {
102    fn drop(&mut self) {
103        self.stop.store(true, Ordering::Relaxed);
104    }
105}
106
107fn poll_once(conn: &Connection) -> Result<MprisSnapshot, dbus::Error> {
108    let bus = conn.with_proxy(
109        "org.freedesktop.DBus",
110        "/org/freedesktop/DBus",
111        DBUS_TIMEOUT,
112    );
113    let (names,): (Vec<String>,) = bus.method_call("org.freedesktop.DBus", "ListNames", ())?;
114    let players = names
115        .into_iter()
116        .filter(|n| n.starts_with(MPRIS_NAME_PREFIX));
117
118    // Prefer a player whose status is `Playing`. If none are playing,
119    // keep the first reachable one as a fallback so we still report
120    // the position of a paused track (mirrors MD2 desktop behaviour:
121    // the preset visualises whatever the user has open).
122    let mut fallback: Option<MprisSnapshot> = None;
123    for player_name in players {
124        let proxy = conn.with_proxy(&player_name, MPRIS_PATH, DBUS_TIMEOUT);
125        let status: String = match proxy.get(MPRIS_PLAYER_IFACE, "PlaybackStatus") {
126            Ok(s) => s,
127            Err(_) => continue,
128        };
129        let position: i64 = proxy.get(MPRIS_PLAYER_IFACE, "Position").unwrap_or(0);
130        let metadata: PropMap = proxy
131            .get(MPRIS_PLAYER_IFACE, "Metadata")
132            .unwrap_or_default();
133        let length = metadata
134            .get("mpris:length")
135            .and_then(|v| v.0.as_i64())
136            .unwrap_or(0);
137        let title = metadata
138            .get("xesam:title")
139            .and_then(|v| v.0.as_str())
140            .map(|s| s.to_string());
141
142        let progress = if length > 0 && position >= 0 {
143            Some((position as f64 / length as f64).clamp(0.0, 1.0) as f32)
144        } else {
145            None
146        };
147        let snap = MprisSnapshot {
148            progress,
149            title,
150            player_name: Some(player_name),
151        };
152
153        if status == "Playing" {
154            return Ok(snap);
155        } else if fallback.is_none() {
156            fallback = Some(snap);
157        }
158    }
159    Ok(fallback.unwrap_or_default())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn snapshot_default_is_empty() {
168        let s = MprisSnapshot::default();
169        assert!(s.progress.is_none());
170        assert!(s.title.is_none());
171        assert!(s.player_name.is_none());
172    }
173
174    #[test]
175    fn spawn_returns_some_or_none_without_panicking() {
176        // The poller may or may not connect depending on whether the
177        // test runner has access to a session bus — both outcomes are
178        // valid; just exercise the happy/failure path branches.
179        let poller = MprisPoller::spawn();
180        if let Some(p) = poller {
181            // First snapshot is the default (poller thread may not
182            // have completed a round trip yet) — but it must at
183            // least be readable without panicking.
184            let _ = p.snapshot();
185        }
186    }
187}