termusicplayback/
mpris.rs

1use std::sync::mpsc::{self, Receiver};
2
3use base64::Engine;
4use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
5use termusiclib::{
6    common::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                let clamp_100 = volume.clamp(0.0, 1.0) * 100.0;
224                // Default float to int casting will truncate values to the decimal point,
225                // so we round instead.
226                // For example "volume: 0.5000" plus "playerctl volume 0.1+" results in "0.15000", but doing
227                // "playerctl volume 0.1-" now, results in "0.49999", so if we dont round we would get "Volume: 4".
228                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
229                let uvol = clamp_100.round() as u16;
230                // ignore error if sending failed
231                self.cmd_tx.send(PlayerCmd::VolumeSet(uvol)).ok();
232            }
233            MediaControlEvent::Quit => {
234                // ignore error if sending failed
235                self.cmd_tx.send(PlayerCmd::Quit).ok();
236            }
237            MediaControlEvent::Stop => {
238                // TODO: handle "Stop"
239                info!("Unimplemented Event: Stop");
240            }
241            // explicitly unsupported events
242            MediaControlEvent::Raise => {}
243        }
244    }
245
246    /// Handle Media-Controls events, if enabled to be used
247    pub fn mpris_handle_events(&mut self) {
248        if let Some(ref mut mpris) = self.mpris {
249            if let Ok(m) = mpris.rx.try_recv() {
250                self.mpris_handler(m);
251            }
252        }
253    }
254
255    /// Update Media-Controls reported Position & Status, if enabled to be reporting
256    #[inline]
257    pub fn mpris_update_progress(&mut self, progress: &PlayerProgress) {
258        if let Some(ref mut mpris) = self.mpris {
259            mpris.update_progress(progress.position, self.playlist.read_recursive().status());
260        }
261    }
262
263    /// Update Media-Controls reported volume, if enabled to be reporting
264    #[inline]
265    pub fn mpris_volume_update(&mut self) {
266        let volume = self.volume();
267        if let Some(ref mut mpris) = self.mpris {
268            mpris.update_volume(volume);
269        }
270    }
271}
272
273// demonstrates how to make a minimal window to allow use of media keys on the command line
274// ref: https://github.com/Sinono3/souvlaki/blob/master/examples/print_events.rs
275#[cfg(target_os = "windows")]
276#[allow(clippy::cast_possible_truncation, unsafe_code)]
277mod windows {
278    use std::io::Error;
279    use std::mem;
280
281    use windows::core::w;
282    // use windows::core::PCWSTR;
283    use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
284    use windows::Win32::System::LibraryLoader::GetModuleHandleW;
285    use windows::Win32::UI::WindowsAndMessaging::{
286        CreateWindowExW, DefWindowProcW, DestroyWindow, RegisterClassExW, WINDOW_EX_STYLE,
287        WINDOW_STYLE, WNDCLASSEXW,
288    };
289
290    pub struct DummyWindow {
291        pub handle: HWND,
292    }
293
294    impl DummyWindow {
295        pub fn new() -> Result<DummyWindow, String> {
296            let class_name = w!("SimpleTray");
297
298            let handle_result = unsafe {
299                let instance = GetModuleHandleW(None)
300                    .map_err(|e| format!("Getting module handle failed: {e}"))?;
301
302                let wnd_class = WNDCLASSEXW {
303                    cbSize: mem::size_of::<WNDCLASSEXW>() as u32,
304                    hInstance: instance.into(),
305                    lpszClassName: class_name,
306                    lpfnWndProc: Some(Self::wnd_proc),
307                    ..Default::default()
308                };
309
310                if RegisterClassExW(&wnd_class) == 0 {
311                    return Err(format!(
312                        "Registering class failed: {}",
313                        Error::last_os_error()
314                    ));
315                }
316
317                let handle = match CreateWindowExW(
318                    WINDOW_EX_STYLE::default(),
319                    class_name,
320                    w!(""),
321                    WINDOW_STYLE::default(),
322                    0,
323                    0,
324                    0,
325                    0,
326                    None,
327                    None,
328                    instance,
329                    None,
330                ) {
331                    Ok(v) => v,
332                    Err(err) => {
333                        return Err(format!("{err}"));
334                    }
335                };
336
337                if handle.is_invalid() {
338                    Err(format!(
339                        "Message only window creation failed: {}",
340                        Error::last_os_error()
341                    ))
342                } else {
343                    Ok(handle)
344                }
345            };
346
347            handle_result.map(|handle| DummyWindow { handle })
348        }
349        extern "system" fn wnd_proc(
350            hwnd: HWND,
351            msg: u32,
352            wparam: WPARAM,
353            lparam: LPARAM,
354        ) -> LRESULT {
355            unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
356        }
357    }
358
359    impl Drop for DummyWindow {
360        fn drop(&mut self) {
361            unsafe {
362                DestroyWindow(self.handle).unwrap();
363            }
364        }
365    }
366
367    // #[allow(dead_code)]
368    // pub fn pump_event_queue() -> bool {
369    //     unsafe {
370    //         let mut msg: MSG = std::mem::zeroed();
371    //         let mut has_message = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool();
372    //         while msg.message != WM_QUIT && has_message {
373    //             if !IsDialogMessageW(GetAncestor(msg.hwnd, GA_ROOT), &msg).as_bool() {
374    //                 TranslateMessage(&msg);
375    //                 DispatchMessageW(&msg);
376    //             }
377
378    //             has_message = PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool();
379    //         }
380
381    //         msg.message == WM_QUIT
382    //     }
383    // }
384}