Files
qbz/crates/qbz-media-controls/Cargo.toml
T
vicrodh 114824b7bc feat(media-controls): SMTC with a real HWND, and a native cover path
Windows media keys never worked because SMTC never started. souvlaki's Windows
backend does `config.hwnd.expect(..)`, which PANICS rather than degrading, and
`MediaControls::new` runs inside a tokio task, so a missing handle aborted the
task instead of returning the Err the caller was written for. Phase A bowed out
before reaching it and logged "SMTC disabled: no window handle wired yet".

`spawn` now takes a `NativeWindow`, and `cxx/win_shell.cpp` supplies the
top-level HWND through `QGuiApplication::topLevelWindows()`. The guard stays:
a missing integration is a missing feature, a panic in a tokio task is a dead
session.

Windows reads the handle from a bounded retry on the GUI thread. Both things it
waits for -- the tray bridge's Qt thread and a shown window -- are "not yet",
never "not ever": `tray_bridge::ui` is a no-op until `QbzTray::boot` registers
the thread, and `QbzSession.boot()` runs first (Main.qml:254 vs :380).

NON-WINDOWS IS UNCHANGED, deliberately. An earlier draft routed every platform
through that hop and it was a Linux/macOS regression three ways: initialization
could be dropped outright before boot, MPRIS would start only when the Qt queue
drained (losing the first metadata push), and `main_window_hwnd` calls
`QWindow::winId()`, which CREATES the platform window as a side effect on
platforms with no reason to.

Cover art needed its own fix. souvlaki 0.8.3 branches on
`url.starts_with("file://")`, then trims exactly that prefix and hands the rest
to `GetFileFromPathAsync`. So `file:///C:/x.jpg` arrives as `/C:/x.jpg` -- a
slash before a drive letter, which never opens -- while a bare `C:\x.jpg` no
longer matches the prefix and takes the remote-URI branch instead. The value
that works is the hybrid `file://C:\x.jpg`. UNC is handled too: `file://nas/m/a`
is a real Windows path and was being passed through as the relative `nas/m/a`.

The percent-decode is full and applied once: `art_url` arrives from
`url::Url::from_file_path` (space as %20) and from `fs_url::file_url` (only
% # ? escaped), and one full decode reads both -- `%2520` gives back a literal
`%20` rather than a space. `fs_url::local_path` is deliberately not used here;
its three-escape decode would leave the other producer's %20 intact.

Eight tests cover the matrix. Reviewed adversarially before commit, which is
where the inverted cover conversion and the non-Windows regression were caught;
the souvlaki branch was then read at the source to confirm.

cc
2026-08-27 13:37:24 -06:00

56 lines
2.5 KiB
TOML

[package]
name = "qbz-media-controls"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on Linux with a DesktopEntry so GNOME shows the app icon; SMTC/MediaRemote (souvlaki) on Windows/macOS"
[dependencies]
log = { workspace = true }
# fs_url::local_path -- the ONE file-URL helper (W2). SMTC needs a native
# Windows path where MPRIS needs a file:// URL, and hand-rolling that
# conversion is exactly the bug class that helper exists to prevent.
qbz-models = { path = "../qbz-models" }
# Notifications (all platforms): the async entry point spawns the HTTP download
# + image resize on `spawn_blocking`, so a tokio runtime must be present.
tokio = { workspace = true }
# Artwork download + cache + (Linux) resize for the notification icon. Matches
# the Tauri notification helpers so output is identical.
reqwest = { workspace = true, features = ["blocking"] }
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"webp",
] }
dirs = "6"
md-5 = { workspace = true }
urlencoding = "2"
# Linux: mpris-server publishes the full MPRIS interface INCLUDING the
# org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry),
# which is the only way GNOME Shell resolves the app icon. souvlaki never sets
# it. tokio feature so the server shares the app's tokio runtime.
[target.'cfg(target_os = "linux")'.dependencies]
mpris-server = "0.8"
async-channel = "2"
# XDG notification portal (goes over D-Bus). async-io, NOT tokio: zbus 5's
# "tokio" feature unifies graph-wide and its executor then requires a tokio
# reactor on EVERY thread that opens a zbus connection — accesskit_unix/atspi
# (Slint accessibility) and ksni run theirs on plain threads and panicked at
# startup ("there is no reactor running"). async-io futures still await fine
# inside our tokio runtime. Keep in sync with qbz-credentials/Cargo.toml.
ashpd = { version = "0.13", default-features = false, features = [
"async-io",
"notification",
] }
# macOS (MediaRemote / MPNowPlayingInfoCenter) + Windows (SMTC) via souvlaki —
# there is no DesktopEntry concept there (macOS keys the icon off the app
# bundle), so souvlaki stays the right tool off-Linux.
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
souvlaki = "0.8"
# macOS desktop notifications (NSUserNotification / UserNotifications). Windows
# is a no-op for now (parity with Tauri, which never implemented it).
notify-rust = "4"