termusicplayback/
mpris.rs

1use base64::Engine;
2use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
3use std::sync::mpsc::{self, Receiver};
4use termusiclib::{
5    library_db::const_unknown::{UNKNOWN_ARTIST, UNKNOWN_TITLE},
6    track::Track,
7};
8
9use crate::{
10    GeneralPlayer, PlayerCmd, PlayerProgress, PlayerTimeUnit, PlayerTrait, Status, Volume,
11};
12
13pub struct Mpris {
14    controls: MediaControls,
15    pub rx: Receiver<MediaControlEvent>,
16}
17
18impl Mpris {
19    pub fn new(cmd_tx: crate::PlayerCmdSender) -> Self {
20        // #[cfg(not(target_os = "windows"))]
21        // let hwnd = None;
22
23        // #[cfg(target_os = "windows")]
24        // let hwnd = {
25        //     use raw_window_handle::windows::WindowsHandle;
26
27        //     let handle: WindowsHandle = unimplemented!();
28        //     Some(handle.hwnd)
29        // };
30
31        #[cfg(not(target_os = "windows"))]
32        let hwnd = None;
33
34        #[cfg(target_os = "windows")]
35        let (hwnd, _dummy_window) = {
36            let dummy_window = windows::DummyWindow::new().unwrap();
37            let handle = Some(dummy_window.handle.0);
38            (handle, dummy_window)
39        };
40
41        let config = PlatformConfig {
42            dbus_name: "termusic",
43            display_name: "Termusic in Rust",
44            hwnd,
45        };
46
47        let mut controls = MediaControls::new(config).unwrap();
48
49        let (tx, rx) = mpsc::sync_channel(32);
50        // The closure must be Send and have a static lifetime.
51        controls
52            .attach(move |event: MediaControlEvent| {
53                tx.send(event).ok();
54                // immediately process any mpris commands, current update is inside PlayerCmd::Tick
55                // TODO: this should likely be refactored
56                cmd_tx.send(PlayerCmd::Tick).ok();
57            })
58            .ok();
59
60        Self { controls, rx }
61    }
62}
63
64impl Mpris {
65    pub fn add_and_play(&mut self, track: &Track) {
66        // This is to fix a bug that the first track is not updated
67        std::thread::sleep(std::time::Duration::from_millis(100));
68        self.controls
69            .set_playback(MediaPlayback::Playing { progress: None })
70            .ok();
71
72        let cover_art = track.picture().map(|picture| {
73            format!(
74                "data:{};base64,{}",
75                picture.mime_type().map_or_else(
76                    || {
77                        error!(
78                            "Unknown mimetype for picture of track {}",
79                            track.file().unwrap_or("<unknown file>")
80                        );
81                        "application/octet-stream"
82                    },
83                    |v| v.as_str()
84                ),
85                base64::engine::general_purpose::STANDARD_NO_PAD.encode(picture.data())
86            )
87        });
88
89        self.controls
90            .set_metadata(MediaMetadata {
91                title: Some(track.title().unwrap_or(UNKNOWN_TITLE)),
92                artist: Some(track.artist().unwrap_or(UNKNOWN_ARTIST)),
93                album: Some(track.album().unwrap_or("")),
94                cover_url: cover_art.as_deref(),
95                duration: Some(track.duration()),
96            })
97            .ok();
98    }
99
100    pub fn pause(&mut self) {
101        self.controls
102            .set_playback(MediaPlayback::Paused { progress: None })
103            .ok();
104    }
105    pub fn resume(&mut self) {
106        self.controls
107            .set_playback(MediaPlayback::Playing { progress: None })
108            .ok();
109    }
110
111    /// Update Track position / progress, requires `playlist_status` because [`MediaControls`] only allows `set_playback`, not `set_position` or `get_playback`
112    pub fn update_progress(&mut self, position: Option<PlayerTimeUnit>, playlist_status: Status) {
113        if let Some(position) = position {
114            match playlist_status {
115                Status::Running => self
116                    .controls
117                    .set_playback(MediaPlayback::Playing {
118                        progress: Some(souvlaki::MediaPosition(position)),
119                    })
120                    .ok(),
121                Status::Paused | Status::Stopped => self
122                    .controls
123                    .set_playback(MediaPlayback::Paused {
124                        progress: Some(souvlaki::MediaPosition(position)),
125                    })
126                    .ok(),
127            };
128        }
129    }
130
131    /// Update the Volume reported by Media-Controls
132    ///
133    /// currently only does something on linux (mpris)
134    #[allow(unused_variables, clippy::unused_self)] // non-linux targets will complain about unused parameters
135    pub fn update_volume(&mut self, volume: Volume) {
136        // currently "set_volume" only exists for "linux"(mpris)
137        #[cfg(target_os = "linux")]
138        {
139            // update the reported volume in mpris
140            let vol = f64::from(volume) / 100.0;
141            let _ = self.controls.set_volume(vol);
142        }
143    }
144}
145
146impl GeneralPlayer {
147    pub fn mpris_handler(&mut self, e: MediaControlEvent) {
148        match e {
149            MediaControlEvent::Next => {
150                self.next();
151            }
152            MediaControlEvent::Previous => {
153                self.previous();
154            }
155            MediaControlEvent::Pause => {
156                self.pause();
157            }
158            MediaControlEvent::Toggle => {
159                self.toggle_pause();
160            }
161            MediaControlEvent::Play => {
162                self.play();
163            }
164            // The "Seek" even seems to currently only be used for windows, mpris uses "SeekBy"
165            MediaControlEvent::Seek(direction) => {
166                let cmd = match direction {
167                    souvlaki::SeekDirection::Forward => PlayerCmd::SeekForward,
168                    souvlaki::SeekDirection::Backward => PlayerCmd::SeekBackward,
169                };
170
171                // ignore error if sending failed
172                self.cmd_tx.send(cmd).ok();
173            }
174            MediaControlEvent::SetPosition(position) => {
175                self.seek_to(position.0);
176            }
177            MediaControlEvent::OpenUri(_uri) => {
178                // let wait = async {
179                //     self.player.add_and_play(&uri).await;
180                // };
181                // let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
182                // rt.block_on(wait);
183                // TODO: handle "OpenUri"
184                info!("Unimplemented Event: OpenUri");
185            }
186            MediaControlEvent::SeekBy(direction, duration) => {
187                #[allow(clippy::cast_possible_wrap)]
188                let as_secs = duration.as_secs().min(i64::MAX as u64) as i64;
189
190                // mpris seeking is in micro-seconds (not milliseconds or seconds)
191                if as_secs == 0 {
192                    warn!("can only seek in seconds, got less than 0 seconds");
193                    return;
194                }
195
196                let offset = match direction {
197                    souvlaki::SeekDirection::Forward => as_secs,
198                    souvlaki::SeekDirection::Backward => -as_secs,
199                };
200
201                // make use of "PlayerTrait" impl on "GeneralPlayer"
202                // ignore result
203                let _ = self.seek(offset);
204            }
205            MediaControlEvent::SetVolume(volume) => {
206                debug!("got souvlaki SetVolume: {:#}", volume);
207                // volume can be anything above 0; 1.0 means a sensible max; termusic currently does not support more than 100 volume
208                // warn users trying to set higher than max via logging
209                if volume > 1.0 {
210                    error!("SetVolume above 1.0 will be clamped to 1.0!");
211                }
212                // convert a 0.0 to 1.0 range to 0 to 100, because that is what termusic uses for volume
213                // default float to int casting will truncate values to the decimal point
214                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
215                let uvol = (volume.clamp(0.0, 1.0) * 100.0) as u16;
216                self.set_volume(uvol);
217            }
218            MediaControlEvent::Quit => {
219                // ignore error if sending failed
220                self.cmd_tx.send(PlayerCmd::Quit).ok();
221            }
222            MediaControlEvent::Stop => {
223                // TODO: handle "Stop"
224                info!("Unimplemented Event: Stop");
225            }
226            // explicitly unsupported events
227            MediaControlEvent::Raise => {}
228        }
229    }
230
231    /// Handle Media-Controls events, if enabled to be used
232    pub fn mpris_handle_events(&mut self) {
233        if let Some(ref mut mpris) = self.mpris {
234            if let Ok(m) = mpris.rx.try_recv() {
235                self.mpris_handler(m);
236            }
237        }
238    }
239
240    /// Update Media-Controls reported Position & Status, if enabled to be reporting
241    #[inline]
242    pub fn mpris_update_progress(&mut self, progress: &PlayerProgress) {
243        if let Some(ref mut mpris) = self.mpris {
244            mpris.update_progress(progress.position, self.playlist.status());
245        }
246    }
247
248    /// Update Media-Controls reported volume, if enabled to be reporting
249    #[inline]
250    pub fn mpris_volume_update(&mut self) {
251        let volume = self.volume();
252        if let Some(ref mut mpris) = self.mpris {
253            mpris.update_volume(volume);
254        }
255    }
256}
257
258// demonstrates how to make a minimal window to allow use of media keys on the command line
259// ref: https://github.com/Sinono3/souvlaki/blob/master/examples/print_events.rs
260#[cfg(target_os = "windows")]
261#[allow(clippy::cast_possible_truncation, unsafe_code)]
262mod windows {
263    use std::io::Error;
264    use std::mem;
265
266    use windows::core::w;
267    // use windows::core::PCWSTR;
268    use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
269    use windows::Win32::System::LibraryLoader::GetModuleHandleW;
270    use windows::Win32::UI::WindowsAndMessaging::{
271        CreateWindowExW, DefWindowProcW, DestroyWindow, RegisterClassExW, WINDOW_EX_STYLE,
272        WINDOW_STYLE, WNDCLASSEXW,
273    };
274
275    pub struct DummyWindow {
276        pub handle: HWND,
277    }
278
279    impl DummyWindow {
280        pub fn new() -> Result<DummyWindow, String> {
281            let class_name = w!("SimpleTray");
282
283            let handle_result = unsafe {
284                let instance = GetModuleHandleW(None)
285                    .map_err(|e| (format!("Getting module handle failed: {e}")))?;
286
287                let wnd_class = WNDCLASSEXW {
288                    cbSize: mem::size_of::<WNDCLASSEXW>() as u32,
289                    hInstance: instance.into(),
290                    lpszClassName: class_name,
291                    lpfnWndProc: Some(Self::wnd_proc),
292                    ..Default::default()
293                };
294
295                if RegisterClassExW(&wnd_class) == 0 {
296                    return Err(format!(
297                        "Registering class failed: {}",
298                        Error::last_os_error()
299                    ));
300                }
301
302                let handle = match CreateWindowExW(
303                    WINDOW_EX_STYLE::default(),
304                    class_name,
305                    w!(""),
306                    WINDOW_STYLE::default(),
307                    0,
308                    0,
309                    0,
310                    0,
311                    None,
312                    None,
313                    instance,
314                    None,
315                ) {
316                    Ok(v) => v,
317                    Err(err) => {
318                        return Err(format!("{err}"));
319                    }
320                };
321
322                if handle.is_invalid() {
323                    Err(format!(
324                        "Message only window creation failed: {}",
325                        Error::last_os_error()
326                    ))
327                } else {
328                    Ok(handle)
329                }
330            };
331
332            handle_result.map(|handle| DummyWindow { handle })
333        }
334        extern "system" fn wnd_proc(
335            hwnd: HWND,
336            msg: u32,
337            wparam: WPARAM,
338            lparam: LPARAM,
339        ) -> LRESULT {
340            unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
341        }
342    }
343
344    impl Drop for DummyWindow {
345        fn drop(&mut self) {
346            unsafe {
347                DestroyWindow(self.handle).unwrap();
348            }
349        }
350    }
351
352    // #[allow(dead_code)]
353    // pub fn pump_event_queue() -> bool {
354    //     unsafe {
355    //         let mut msg: MSG = std::mem::zeroed();
356    //         let mut has_message = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool();
357    //         while msg.message != WM_QUIT && has_message {
358    //             if !IsDialogMessageW(GetAncestor(msg.hwnd, GA_ROOT), &msg).as_bool() {
359    //                 TranslateMessage(&msg);
360    //                 DispatchMessageW(&msg);
361    //             }
362
363    //             has_message = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool();
364    //         }
365
366    //         msg.message == WM_QUIT
367    //     }
368    // }
369}