feat: add Windows SMTC media key support (#134)

This commit is contained in:
Ema Suriano 2026-09-13 10:05:52 +02:00 committed by GitHub
parent 53ac389b7d
commit d86a51e933
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 446 additions and 7 deletions

View File

@ -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 }} \

142
Cargo.lock generated
View File

@ -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"

View File

@ -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",
] }

View File

@ -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

View File

@ -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(())

View File

@ -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<T> = std::result::Result<T, Error>;
@ -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<media_controls::Server>,
/// Logger which can be used to log important events.
logger: Logger,
}

View File

@ -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,
})
}
}
}

245
src/ui/media_controls.rs Normal file
View File

@ -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<rodio::Player>,
current: Current,
list: String,
receiver: broadcast::Receiver<Update>,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.pump_shutdown.send(());
}
}
impl Server {
fn map_event(event: MediaControlEvent) -> Option<Message> {
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<Message>,
receiver: broadcast::Receiver<Update>,
) -> Option<Self> {
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<Message>,
receiver: broadcast::Receiver<Update>,
) -> Result<Self, String> {
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<Self, String> {
let class = w!("lowfi-smtc");
unsafe {
let instance = GetModuleHandleW(None).map_err(|e| e.to_string())?;
let wnd = WNDCLASSEXW {
cbSize: mem::size_of::<WNDCLASSEXW>() 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);
}
}
}
}