termusicplayback/
mpris.rs

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