From d86a51e93375efcfa10b5b98878113989e1b673f Mon Sep 17 00:00:00 2001 From: Ema Suriano Date: Sun, 13 Sep 2026 10:05:52 +0200 Subject: [PATCH] feat: add Windows SMTC media key support (#134) --- .github/workflows/build.yml | 8 +- Cargo.lock | 142 ++++++++++++++++++++- Cargo.toml | 11 ++ README.md | 23 +++- src/player.rs | 5 + src/ui.rs | 11 ++ src/ui/init.rs | 8 +- src/ui/media_controls.rs | 245 ++++++++++++++++++++++++++++++++++++ 8 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 src/ui/media_controls.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ab40c52..8d2189b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,7 @@ jobs: bin: lowfi name: lowfi-linux-x86_64 command: build + features: "" - release_for: Windows x86_64 os: windows-latest @@ -26,6 +27,7 @@ jobs: bin: lowfi.exe name: lowfi-windows-x86_64.exe command: build + features: "--features media-controls" - release_for: MacOS x86_64 os: macos-latest @@ -33,6 +35,7 @@ jobs: bin: lowfi name: lowfi-darwin-x86_64 command: build + features: "" - release_for: MacOS aarch64 os: macos-latest @@ -40,6 +43,7 @@ jobs: bin: lowfi name: lowfi-darwin-aarch64 command: build + features: "" runs-on: ${{ matrix.os }} env: @@ -61,7 +65,7 @@ jobs: with: command: ${{ matrix.command }} target: ${{ matrix.target }} - args: "--locked --release" + args: --locked --release ${{ matrix.features }} - name: Rename binary run: | @@ -72,4 +76,4 @@ jobs: shell: bash run: | gh release upload ${{github.event.release.tag_name}} \ - ./target/${{ matrix.target }}/release/${{ matrix.name }} \ + ./target/${{ matrix.target }}/release/${{ matrix.name }} \ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5943812..a205110 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -281,6 +281,12 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block2" version = "0.6.2" @@ -422,6 +428,36 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types", + "libc", + "objc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -459,6 +495,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -475,6 +521,30 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "coreaudio-rs" version = "0.14.2" @@ -516,7 +586,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows", + "windows 0.62.2", ] [[package]] @@ -632,6 +702,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -802,6 +878,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1449,10 +1540,12 @@ dependencies = [ "scraper", "serde", "serde_json", + "souvlaki", "thiserror 2.0.18", "tokio", "unicode-segmentation", "url", + "windows 0.62.2", ] [[package]] @@ -1470,6 +1563,15 @@ dependencies = [ "libc", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -1629,6 +1731,15 @@ dependencies = [ "syn", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2223,7 +2334,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -2308,7 +2419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2493,6 +2604,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64", + "block", + "cocoa", + "core-graphics", + "dispatch", + "objc", + "thiserror 1.0.69", + "windows 0.44.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3235,6 +3362,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 7eec85c..d2b7b4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ repository = "https://github.com/talwat/lowfi" default = ["default-tracklist"] default-tracklist = [] mpris = ["dep:mpris-server", "dep:arc-swap"] +# Windows System Media Transport Controls (hardware media keys / overlay). +media-controls = ["dep:souvlaki", "dep:windows"] extra-audio-formats = ["rodio/default"] scrape = [ "dep:serde", @@ -66,3 +68,12 @@ indicatif = { version = "0.18.4", optional = true } [target.'cfg(target_os = "linux")'.dependencies] libc = "0.2.186" + +[target.'cfg(windows)'.dependencies] +souvlaki = { version = "0.8.3", optional = true, default-features = false } +windows = { version = "0.62", optional = true, features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_System_LibraryLoader", + "Win32_UI_WindowsAndMessaging", +] } \ No newline at end of file diff --git a/README.md b/README.md index a500fb9..095f2b5 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,11 @@ Then, simply run: ```sh cargo install lowfi -# If you want MPRIS support. +# If you want MPRIS support (Linux). cargo install lowfi --features mpris + +# If you want Windows hardware media keys / SMTC overlay. +cargo install lowfi --features media-controls ``` And make sure `$HOME/.cargo/bin` is added to `$PATH`. @@ -154,6 +157,11 @@ Yeah, that's it. > MPRIS is currently an [optional feature](#extra-features) in cargo (enabled with `--features mpris`) > due to it being only for Linux, as well as the fact that the main point of > lowfi is it's unique & minimal interface. +> +> On **Windows**, terminal media keys (via crossterm) only work while the +> terminal is focused. Build with `--features media-controls` to register +> System Media Transport Controls (SMTC) so play/pause/next (and volume when +> the OS exposes it) work globally, with track metadata in the Windows media overlay. ### Bookmarks @@ -200,6 +208,19 @@ More information can be found by running `lowfi help scrape`. Enables MPRIS. It's not rocket science. +#### `media-controls` - Windows media keys (SMTC) + +Enables System Media Transport Controls on Windows via the `souvlaki` crate. +Hardware play/pause/next keys work while another app is focused, and the current +track can appear in the Windows media overlay. Soft-fails if SMTC cannot be +registered so playback still works. + +```sh +cargo run --features media-controls +# or +cargo build --release --features media-controls +``` + #### `extra-audio-formats` - Extra Audio Formats This is only relevant to those using a custom track list, in which case diff --git a/src/player.rs b/src/player.rs index 40ac008..9e476c8 100644 --- a/src/player.rs +++ b/src/player.rs @@ -201,6 +201,11 @@ impl Player { #[cfg(feature = "mpris")] self.ui.mpris.handle(&message).await?; + + #[cfg(all(feature = "media-controls", windows))] + if let Some(controls) = self.ui.media_controls.as_mut() { + controls.handle(&message)?; + } } Ok(()) diff --git a/src/ui.rs b/src/ui.rs index 9780cd1..1d24f53 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -15,6 +15,9 @@ pub use interface::Interface; #[cfg(feature = "mpris")] pub mod mpris; +#[cfg(all(feature = "media-controls", windows))] +pub mod media_controls; + /// Shorthand for a [`Result`] with a [`ui::Error`]. type Result = std::result::Result; @@ -47,6 +50,10 @@ pub enum Error { #[cfg(feature = "mpris")] #[error("mpris fdo (zbus interface) error: {0}")] Fdo(#[from] mpris_server::zbus::fdo::Error), + + #[cfg(all(feature = "media-controls", windows))] + #[error("media controls error: {0}")] + MediaControls(String), } /// The UI state, which is all of the information that @@ -118,6 +125,10 @@ pub struct Handle { #[cfg(feature = "mpris")] pub mpris: mpris::Server, + /// Windows SMTC handle; None if registration soft-failed. + #[cfg(all(feature = "media-controls", windows))] + pub media_controls: Option, + /// Logger which can be used to log important events. logger: Logger, } diff --git a/src/ui/init.rs b/src/ui/init.rs index 3ce8833..6b327b2 100644 --- a/src/ui/init.rs +++ b/src/ui/init.rs @@ -12,6 +12,10 @@ impl crate::Tasks { #[cfg(feature = "mpris")] let mpris = ui::mpris::Server::new(state.clone(), self.tx(), urx.resubscribe()).await?; + #[cfg(all(feature = "media-controls", windows))] + let media_controls = + ui::media_controls::Server::try_new(state.clone(), self.tx(), urx.resubscribe()); + let params = interface::Params::try_from(args)?; let interface = interface::Interface::new(params)?; let logger = interface.logger.clone(); @@ -26,6 +30,8 @@ impl crate::Tasks { logger, #[cfg(feature = "mpris")] mpris, + #[cfg(all(feature = "media-controls", windows))] + media_controls, }) } -} +} \ No newline at end of file diff --git a/src/ui/media_controls.rs b/src/ui/media_controls.rs new file mode 100644 index 0000000..2c72117 --- /dev/null +++ b/src/ui/media_controls.rs @@ -0,0 +1,245 @@ +//! Windows System Media Transport Controls (SMTC) via [`souvlaki`]. +//! Soft-fails on init so playback still works without SMTC. + +use std::sync::Arc; +use std::time::Duration; + +use souvlaki::{ + MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig, +}; +use tokio::sync::{broadcast, mpsc}; + +use crate::player::Current; +use crate::ui::{self, Update}; +use crate::Message; + +/// Handle to SMTC; keeps the dummy HWND alive and syncs metadata/playback. +pub struct Server { + controls: MediaControls, + /// Must outlive `controls` on Windows. + _dummy_window: windows::DummyWindow, + pump_shutdown: std::sync::mpsc::Sender<()>, + sink: Arc, + current: Current, + list: String, + receiver: broadcast::Receiver, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.pump_shutdown.send(()); + } +} + +impl Server { + fn map_event(event: MediaControlEvent) -> Option { + match event { + MediaControlEvent::Play => Some(Message::Play), + MediaControlEvent::Pause | MediaControlEvent::Stop => Some(Message::Pause), + MediaControlEvent::Toggle => Some(Message::PlayPause), + MediaControlEvent::Next => Some(Message::Next), + MediaControlEvent::Quit => Some(Message::Quit), + MediaControlEvent::SetVolume(v) => Some(Message::SetVolume(v as f32)), + _ => None, + } + } + + /// Creates SMTC controls, or `None` if registration fails (playback continues). + pub fn try_new( + state: ui::State, + sender: mpsc::Sender, + receiver: broadcast::Receiver, + ) -> Option { + match Self::create(state, sender, receiver) { + Ok(server) => Some(server), + Err(err) => { + eprintln!("warning: Windows media controls unavailable: {err}"); + None + } + } + } + + fn create( + state: ui::State, + sender: mpsc::Sender, + receiver: broadcast::Receiver, + ) -> Result { + let dummy_window = windows::DummyWindow::new()?; + let config = PlatformConfig { + dbus_name: "lowfi", + display_name: "lowfi", + hwnd: Some(dummy_window.handle.0 as _), + }; + + let mut controls = + MediaControls::new(config).map_err(|e| format!("MediaControls::new: {e:?}"))?; + controls + .attach(move |event| { + if let Some(msg) = Self::map_event(event) { + let _ = sender.try_send(msg); + } + }) + .map_err(|e| format!("MediaControls::attach: {e:?}"))?; + + let (pump_shutdown, pump_rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("lowfi-smtc-pump".into()) + .spawn(move || { + while pump_rx.try_recv().is_err() { + windows::pump_events(); + std::thread::sleep(Duration::from_millis(50)); + } + }) + .map_err(|e| format!("SMTC pump spawn: {e}"))?; + + let mut server = Self { + controls, + _dummy_window: dummy_window, + pump_shutdown, + sink: state.sink, + current: state.current, + list: state.tracklist, + receiver, + }; + let _ = server.sync_playback(); + let _ = server.sync_metadata(); + Ok(server) + } + + /// Applies player messages to SMTC metadata / playback status. + pub fn handle(&mut self, message: &Message) -> ui::Result<()> { + while let Ok(update) = self.receiver.try_recv() { + if let Update::Track(current) = update { + self.current = current; + } + } + + match message { + Message::Play | Message::Pause | Message::PlayPause => self.sync_playback(), + Message::Init | Message::Loaded | Message::Next => { + self.sync_metadata()?; + self.sync_playback() + } + _ => Ok(()), + } + } + + fn sync_playback(&mut self) -> ui::Result<()> { + let status = if self.current.loading() { + MediaPlayback::Stopped + } else if self.sink.is_paused() { + MediaPlayback::Paused { progress: None } + } else { + MediaPlayback::Playing { progress: None } + }; + self.controls + .set_playback(status) + .map_err(|e| ui::Error::MediaControls(format!("{e:?}"))) + } + + fn sync_metadata(&mut self) -> ui::Result<()> { + let (title, duration) = match &self.current { + Current::Loading(_) => ("Loading...", None), + Current::Track(track) => (track.display.as_str(), track.duration), + }; + self.controls + .set_metadata(MediaMetadata { + title: Some(title), + album: Some(self.list.as_str()), + artist: Some("lowfi"), + duration, + ..Default::default() + }) + .map_err(|e| ui::Error::MediaControls(format!("{e:?}"))) + } +} + +/// Minimal hidden window + Win32 message pump required by SMTC. +mod windows { + use std::io::Error; + use std::mem; + + use ::windows::core::w; + use ::windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM}; + use ::windows::Win32::System::LibraryLoader::GetModuleHandleW; + use ::windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, PeekMessageW, + RegisterClassExW, TranslateMessage, MSG, PM_REMOVE, WINDOW_EX_STYLE, WINDOW_STYLE, + WM_QUIT, WNDCLASSEXW, + }; + + pub struct DummyWindow { + pub handle: HWND, + } + + impl DummyWindow { + pub fn new() -> Result { + let class = w!("lowfi-smtc"); + unsafe { + let instance = GetModuleHandleW(None).map_err(|e| e.to_string())?; + let wnd = WNDCLASSEXW { + cbSize: mem::size_of::() as u32, + hInstance: instance.into(), + lpszClassName: class, + lpfnWndProc: Some(Self::wnd_proc), + ..Default::default() + }; + if RegisterClassExW(&wnd) == 0 { + let err = Error::last_os_error(); + // ERROR_CLASS_ALREADY_EXISTS + if err.raw_os_error() != Some(1410) { + return Err(err.to_string()); + } + } + + let handle = CreateWindowExW( + WINDOW_EX_STYLE::default(), + class, + w!("lowfi"), + WINDOW_STYLE::default(), + 0, + 0, + 0, + 0, + None, + None, + Some(instance.into()), + None, + ) + .map_err(|e| e.to_string())?; + + Ok(Self { handle }) + } + } + + extern "system" fn wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + } + + impl Drop for DummyWindow { + fn drop(&mut self) { + unsafe { + let _ = DestroyWindow(self.handle); + } + } + } + + pub fn pump_events() { + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + if msg.message == WM_QUIT { + break; + } + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + } +} \ No newline at end of file