1use 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#[derive(Debug, Clone, Default)]
30pub struct MprisSnapshot {
31 pub progress: Option<f32>,
37 pub title: Option<String>,
41 pub player_name: Option<String>,
44}
45
46pub struct MprisPoller {
54 state: Arc<Mutex<MprisSnapshot>>,
55 stop: Arc<AtomicBool>,
56}
57
58impl MprisPoller {
59 pub fn spawn() -> Option<Self> {
65 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 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 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 let poller = MprisPoller::spawn();
180 if let Some(p) = poller {
181 let _ = p.snapshot();
185 }
186 }
187}