Skip to main content

player/systemint/
linux.rs

1use mpris_server::{
2    Metadata, PlaybackStatus, PlayerInterface, Property, RootInterface, Server, Time, zbus::fdo,
3};
4use std::sync::{
5    Arc, Mutex, OnceLock,
6    mpsc::{self, Receiver, Sender},
7};
8
9#[derive(Debug)]
10pub enum SystemEvent {
11    Play,
12    Pause,
13    Toggle,
14    Next,
15    Prev,
16}
17
18static TX: OnceLock<Sender<SystemEvent>> = OnceLock::new();
19static RX: OnceLock<Mutex<Receiver<SystemEvent>>> = OnceLock::new();
20static STATE: OnceLock<Arc<Mutex<(Metadata, PlaybackStatus, Time)>>> = OnceLock::new();
21static NOTIFY: OnceLock<tokio::sync::mpsc::UnboundedSender<bool>> = OnceLock::new();
22
23fn tx() -> Sender<SystemEvent> {
24    TX.get_or_init(|| {
25        let (tx, rx) = mpsc::channel();
26        RX.set(Mutex::new(rx)).ok();
27        tx
28    })
29    .clone()
30}
31
32fn state() -> Arc<Mutex<(Metadata, PlaybackStatus, Time)>> {
33    STATE
34        .get_or_init(|| {
35            Arc::new(Mutex::new((
36                Metadata::new(),
37                PlaybackStatus::Stopped,
38                Time::ZERO,
39            )))
40        })
41        .clone()
42}
43
44struct P(
45    Arc<Mutex<(Metadata, PlaybackStatus, Time)>>,
46    Sender<SystemEvent>,
47);
48
49impl RootInterface for P {
50    async fn raise(&self) -> fdo::Result<()> {
51        Ok(())
52    }
53    async fn quit(&self) -> fdo::Result<()> {
54        Ok(())
55    }
56    async fn can_quit(&self) -> fdo::Result<bool> {
57        Ok(false)
58    }
59    async fn fullscreen(&self) -> fdo::Result<bool> {
60        Ok(false)
61    }
62    async fn set_fullscreen(&self, _: bool) -> mpris_server::zbus::Result<()> {
63        Ok(())
64    }
65    async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
66        Ok(false)
67    }
68    async fn can_raise(&self) -> fdo::Result<bool> {
69        Ok(false)
70    }
71    async fn has_track_list(&self) -> fdo::Result<bool> {
72        Ok(false)
73    }
74    async fn identity(&self) -> fdo::Result<String> {
75        Ok("Kopuz".into())
76    }
77    async fn desktop_entry(&self) -> fdo::Result<String> {
78        Ok("kopuz".into())
79    }
80    async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
81        Ok(vec![])
82    }
83    async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
84        Ok(vec![])
85    }
86}
87
88impl PlayerInterface for P {
89    async fn next(&self) -> fdo::Result<()> {
90        self.1.send(SystemEvent::Next).ok();
91        Ok(())
92    }
93    async fn previous(&self) -> fdo::Result<()> {
94        self.1.send(SystemEvent::Prev).ok();
95        Ok(())
96    }
97    async fn pause(&self) -> fdo::Result<()> {
98        self.1.send(SystemEvent::Pause).ok();
99        Ok(())
100    }
101    async fn play_pause(&self) -> fdo::Result<()> {
102        self.1.send(SystemEvent::Toggle).ok();
103        Ok(())
104    }
105    async fn stop(&self) -> fdo::Result<()> {
106        self.1.send(SystemEvent::Pause).ok();
107        Ok(())
108    }
109    async fn play(&self) -> fdo::Result<()> {
110        self.1.send(SystemEvent::Play).ok();
111        Ok(())
112    }
113    async fn seek(&self, _: Time) -> fdo::Result<()> {
114        Ok(())
115    }
116    async fn set_position(&self, _: mpris_server::TrackId, _: Time) -> fdo::Result<()> {
117        Ok(())
118    }
119    async fn open_uri(&self, _: String) -> fdo::Result<()> {
120        Ok(())
121    }
122    async fn playback_status(&self) -> fdo::Result<PlaybackStatus> {
123        Ok(self
124            .0
125            .lock()
126            .map(|s| s.1)
127            .unwrap_or(PlaybackStatus::Stopped))
128    }
129    async fn loop_status(&self) -> fdo::Result<mpris_server::LoopStatus> {
130        Ok(mpris_server::LoopStatus::None)
131    }
132    async fn set_loop_status(&self, _: mpris_server::LoopStatus) -> mpris_server::zbus::Result<()> {
133        Ok(())
134    }
135    async fn rate(&self) -> fdo::Result<f64> {
136        Ok(1.0)
137    }
138    async fn set_rate(&self, _: f64) -> mpris_server::zbus::Result<()> {
139        Ok(())
140    }
141    async fn shuffle(&self) -> fdo::Result<bool> {
142        Ok(false)
143    }
144    async fn set_shuffle(&self, _: bool) -> mpris_server::zbus::Result<()> {
145        Ok(())
146    }
147    async fn metadata(&self) -> fdo::Result<Metadata> {
148        Ok(self.0.lock().map(|s| s.0.clone()).unwrap_or_default())
149    }
150    async fn volume(&self) -> fdo::Result<f64> {
151        Ok(1.0)
152    }
153    async fn set_volume(&self, _: f64) -> mpris_server::zbus::Result<()> {
154        Ok(())
155    }
156    async fn position(&self) -> fdo::Result<Time> {
157        Ok(self.0.lock().map(|s| s.2).unwrap_or(Time::ZERO))
158    }
159    async fn minimum_rate(&self) -> fdo::Result<f64> {
160        Ok(1.0)
161    }
162    async fn maximum_rate(&self) -> fdo::Result<f64> {
163        Ok(1.0)
164    }
165    async fn can_go_next(&self) -> fdo::Result<bool> {
166        Ok(true)
167    }
168    async fn can_go_previous(&self) -> fdo::Result<bool> {
169        Ok(true)
170    }
171    async fn can_play(&self) -> fdo::Result<bool> {
172        Ok(true)
173    }
174    async fn can_pause(&self) -> fdo::Result<bool> {
175        Ok(true)
176    }
177    async fn can_seek(&self) -> fdo::Result<bool> {
178        Ok(false)
179    }
180    async fn can_control(&self) -> fdo::Result<bool> {
181        Ok(true)
182    }
183}
184
185pub fn update_position(position: f64) {
186    setup();
187    if let Ok(mut s) = state().lock() {
188        s.2 = Time::from_micros((position * 1e6) as i64);
189    }
190}
191
192fn setup() {
193    static ONCE: OnceLock<()> = OnceLock::new();
194    ONCE.get_or_init(|| {
195        let (ntx, mut nrx) = tokio::sync::mpsc::unbounded_channel();
196        NOTIFY.set(ntx).ok();
197        let st = state();
198        std::thread::spawn(move || {
199            tokio::runtime::Builder::new_current_thread()
200                .enable_all()
201                .build()
202                .unwrap()
203                .block_on(async {
204                    if let Ok(srv) = Server::new("kopuz", P(st.clone(), tx())).await {
205                        while let Some(seeked) = nrx.recv().await {
206                            if seeked {
207                                let (metadata, status, position) = match st.lock() {
208                                    Ok(s) => (s.0.clone(), s.1, s.2),
209                                    Err(_) => continue,
210                                };
211                                srv.properties_changed([
212                                    Property::Metadata(metadata),
213                                    Property::PlaybackStatus(status),
214                                ])
215                                .await
216                                .ok();
217                                srv.emit(mpris_server::Signal::Seeked { position })
218                                    .await
219                                    .ok();
220                            }
221                        }
222                    }
223                });
224        });
225    });
226}
227
228pub fn poll_event() -> Option<SystemEvent> {
229    setup();
230    RX.get()?.lock().ok()?.try_recv().ok()
231}
232
233pub fn update_now_playing(
234    title: &str,
235    artist: &str,
236    album: &str,
237    duration: f64,
238    position: f64,
239    playing: bool,
240    artwork_path: Option<&str>,
241) {
242    setup();
243    if let Ok(mut s) = state().lock() {
244        let mut b = Metadata::builder()
245            .title(title)
246            .artist([artist])
247            .album(album)
248            .length(Time::from_micros((duration * 1e6) as i64));
249        if let Some(art) = artwork_path {
250            // MPRIS art_url accepts any URI. Pass remote URLs (Jellyfin
251            // thumbs, YT Music covers) through unchanged so clients can
252            // fetch them directly; only wrap actual local file paths
253            // with file://.
254            b = b.art_url(
255                if art.starts_with("http://")
256                    || art.starts_with("https://")
257                    || art.starts_with("file://")
258                {
259                    art.to_string()
260                } else if art.starts_with('/') {
261                    format!("file://{art}")
262                } else {
263                    format!(
264                        "file://{}/{art}",
265                        std::env::current_dir().unwrap_or_default().display()
266                    )
267                },
268            );
269        }
270        *s = (
271            b.build(),
272            if playing {
273                PlaybackStatus::Playing
274            } else {
275                PlaybackStatus::Paused
276            },
277            Time::from_micros((position * 1e6) as i64),
278        );
279    }
280    NOTIFY.get().map(|tx| tx.send(true));
281}