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
This commit is contained in:
vicrodh committed 2026-08-27 13:37:24 -06:00
1 parent ff6f40f9dd
commit 114824b7bc
10 files changed
+397 -42

No files matched your search

+3
View File
@@ -4367,10 +4367,12 @@ dependencies = [
"md-5",
"mpris-server",
"notify-rust",
"qbz-models",
"reqwest",
"souvlaki",
"tokio",
"urlencoding",
"windows-sys 0.61.2",
]
[[package]]
@@ -4581,6 +4583,7 @@ dependencies = [
"tokio",
"urlencoding",
"uuid 1.22.0",
"windows-sys 0.61.2",
"winresource",
"zbus 5.15.0",
]
+4
View File
@@ -8,6 +8,10 @@ description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on
[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 }
+22 -2
View File
@@ -33,22 +33,42 @@ mod platform;
/// **macOS:** souvlaki's command callbacks fire on the app run loop, so this is
/// safe to call from any thread, but the run loop (Slint's winit loop) must be
/// running for inbound events to arrive.
/// The platform window identity the OS integration needs.
///
/// Windows SMTC is registered against a window: souvlaki's backend calls
/// `config.hwnd.expect(..)`, so a missing handle is a PANIC, not a graceful
/// degrade. Linux and macOS key off the D-Bus name and the app bundle
/// respectively and ignore this entirely, which is why `default()` is the
/// right thing to pass there and from headless callers.
#[derive(Clone, Copy, Debug, Default)]
pub struct NativeWindow {
pub hwnd: Option<*mut core::ffi::c_void>,
}
// SAFETY: an HWND is an opaque process-wide identifier, not a pointer that is
// dereferenced here. It is read once by souvlaki on the thread that receives
// it. Windows itself allows a window handle to be named from any thread; what
// is thread-affine is the window's message loop, which this never touches.
unsafe impl Send for NativeWindow {}
pub fn spawn(
on_event: impl Fn(MediaEvent) + Send + Sync + 'static,
native: NativeWindow,
) -> Option<Box<dyn MediaIntegration>> {
let cb: Arc<dyn Fn(MediaEvent) + Send + Sync> = Arc::new(on_event);
#[cfg(target_os = "linux")]
{
let _ = native;
return linux::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
return platform::spawn(cb).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
return platform::spawn(cb, native).map(|h| Box::new(h) as Box<dyn MediaIntegration>);
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = cb;
let _ = (cb, native);
None
}
}
+180 -30
View File
@@ -9,16 +9,12 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition};
// Only `spawn_native` and `map_event` touch these, and both are compiled out
// on Windows (souvlaki's SMTC backend panics without an HWND — see `spawn`).
#[cfg(not(target_os = "windows"))]
use souvlaki::{MediaControlEvent, PlatformConfig, SeekDirection};
use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
use crate::NativeWindow;
/// Default step for a magnitude-less MPRIS-style `Seek` (5 seconds, micros).
// Only `map_event` reads it, and that is compiled out on Windows.
#[cfg(not(target_os = "windows"))]
const SEEK_STEP_MICROS: i64 = 5_000_000;
type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>;
@@ -31,12 +27,19 @@ impl MediaIntegration for PlatformHandle {
fn set_metadata(&self, meta: &TrackMeta) {
if let Ok(mut guard) = self.controls.lock() {
if let Some(c) = guard.as_mut() {
// SMTC wants a NATIVE path where MPRIS wants a URL; see
// `cover_for_souvlaki`. Bound to a local so the &str borrowed
// into MediaMetadata outlives the call.
let cover = meta
.art_url
.as_deref()
.map(|u| cover_for_souvlaki(u, cfg!(target_os = "windows")));
let md = MediaMetadata {
title: Some(meta.title.as_str()),
artist: Some(meta.artist.as_str()),
album: Some(meta.album.as_str()),
duration: meta.duration,
cover_url: meta.art_url.as_deref(),
cover_url: cover.as_deref(),
};
let _ = c.set_metadata(md);
}
@@ -63,8 +66,6 @@ impl MediaIntegration for PlatformHandle {
}
}
// Only `spawn_native` installs it, and that is compiled out on Windows.
#[cfg(not(target_os = "windows"))]
fn map_event(e: MediaControlEvent) -> Option<MediaEvent> {
Some(match e {
MediaControlEvent::Play => MediaEvent::Play,
@@ -90,34 +91,96 @@ fn map_event(e: MediaControlEvent) -> Option<MediaEvent> {
})
}
pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> {
// souvlaki's Windows backend does NOT degrade gracefully without an HWND:
// `config.hwnd.expect(...)` at souvlaki-0.8.3/src/platform/windows/mod.rs:57-59
// PANICS, and `MediaControls::new` runs inside a tokio task, so the first
// session would abort the task rather than return the Err the match below
// is written for. (The old comment on `hwnd: None` claimed the opposite;
// it was read from the comment, not the mechanism.) No HWND is wired yet
// — that is plan A-2's "SMTC real" task — so bow out here on Windows.
#[cfg(target_os = "windows")]
{
let _ = on_event;
log::warn!("[media-controls] SMTC disabled: no window handle wired yet (Phase A)");
return None;
}
#[cfg(not(target_os = "windows"))]
{
return spawn_native(on_event);
/// Convert a cover-art URL into the exact string souvlaki's Windows arm can
/// open.
///
/// souvlaki 0.8.3 (`src/platform/windows/mod.rs:192-203`) branches like this:
///
/// ```text
/// if url.starts_with("file://") {
/// let path = url.trim_start_matches("file://");
/// StorageFile::GetFileFromPathAsync(path) // wants C:\x or \\nas\x
/// } else {
/// RandomAccessStreamReference::CreateFromUri(url)
/// }
/// ```
///
/// So `file://` is the DISCRIMINATOR and must survive, while everything after
/// it has to be a native Windows path. Two ways to get this wrong, and the
/// first draft of this function managed one of them:
///
/// - Hand over `file:///C:/x.jpg` and souvlaki trims seven characters, leaving
/// `/C:/x.jpg` -- a leading slash before a drive letter, which never opens.
/// - Hand over a bare `C:\x.jpg` and it no longer starts with `file://`, so it
/// takes the REMOTE branch and `Uri::CreateUri` fails on it.
///
/// The answer is the hybrid `file://C:\x.jpg`: prefix kept, remainder native.
///
/// UNC is a real Windows path too. `file://nas/music/a.jpg` names
/// `\\nas\music\a.jpg`, and leaving it alone would hand souvlaki the
/// relative `nas/music/a.jpg`.
///
/// The percent-decode is FULL and applied ONCE. `art_url` reaches this from
/// two producers -- `url::Url::from_file_path`, which escapes a space as
/// `%20`, and `fs_url::file_url`, which escapes only `%`, `#` and `?` -- and a
/// single full decode is correct for both. `fs_url::local_path` is NOT used
/// here for that reason: its deliberate three-escape decode would leave a
/// `%20` from the other producer intact.
pub(crate) fn cover_for_souvlaki(url: &str, windows: bool) -> String {
if !windows {
return url.to_string();
}
let Some(rest) = url.strip_prefix("file://") else {
// http(s) and anything else: souvlaki's Uri branch is the right one.
return url.to_string();
};
let decoded = urlencoding::decode(rest)
.map(|c| c.into_owned())
.unwrap_or_else(|_| rest.to_string());
let b = decoded.as_bytes();
let native = if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' {
// `file:///C:/x` -> the empty authority's slash sits in front of the
// drive letter; drop it.
decoded[1..].to_string()
} else if !decoded.starts_with('/') && !decoded.is_empty() {
// A non-empty authority is a UNC server: `file://nas/share/x`.
format!("//{decoded}")
} else {
// A POSIX absolute path. Not a Windows path at all, and nothing here
// can make it one -- hand it back untouched rather than inventing
// `\home\...`.
return url.to_string();
};
format!("file://{}", native.replace('/', "\\"))
}
#[cfg(not(target_os = "windows"))]
fn spawn_native(on_event: EventCb) -> Option<PlatformHandle> {
pub fn spawn(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> {
// souvlaki's Windows backend does NOT degrade gracefully without an HWND:
// `config.hwnd.expect(...)` at souvlaki-0.8.3/src/platform/windows/mod.rs:57-59
// PANICS. The guard therefore stays even now that a handle IS wired:
// a missing integration is a missing feature, a panic inside a tokio task
// is a dead session. The caller reads the HWND on the GUI thread after the
// window is shown, so None here means it asked too early.
#[cfg(target_os = "windows")]
if native.hwnd.is_none() {
log::warn!(
"[media-controls] SMTC disabled: no top-level window yet (init ran before it was shown)"
);
return None;
}
spawn_native(on_event, native)
}
fn spawn_native(on_event: EventCb, native: NativeWindow) -> Option<PlatformHandle> {
let config = PlatformConfig {
dbus_name: "com.blitzfc.qbz",
display_name: "QBZ",
// macOS: unused. Windows: unreachable — see the early return in
// `spawn` above.
hwnd: None,
// macOS ignores it; Windows registers SMTC against this window.
hwnd: native.hwnd,
};
let mut controls = match MediaControls::new(config) {
@@ -142,3 +205,90 @@ fn spawn_native(on_event: EventCb) -> Option<PlatformHandle> {
controls: Arc::new(Mutex::new(Some(controls))),
})
}
#[cfg(test)]
mod tests {
use super::cover_for_souvlaki;
#[test]
fn windows_keeps_the_file_prefix_and_makes_the_rest_native() {
// The prefix is souvlaki's discriminator: drop it and the string takes
// the remote-URI branch. Keep it, and what it trims must be a real
// Windows path.
assert_eq!(
cover_for_souvlaki("file:///C:/Users/v/c.jpg", true),
"file://C:\\Users\\v\\c.jpg"
);
}
#[test]
fn non_windows_is_byte_identical() {
assert_eq!(
cover_for_souvlaki("file:///home/v/c.jpg", false),
"file:///home/v/c.jpg"
);
assert_eq!(
cover_for_souvlaki("file:///C:/Users/v/c.jpg", false),
"file:///C:/Users/v/c.jpg"
);
}
#[test]
fn remote_urls_are_never_touched() {
assert_eq!(cover_for_souvlaki("https://x/y.jpg", true), "https://x/y.jpg");
}
#[test]
fn a_unc_url_becomes_a_unc_path() {
// `file://nas/music/a.jpg` is a real Windows path. Left alone, souvlaki
// would trim it to the RELATIVE `nas/music/a.jpg`.
assert_eq!(
cover_for_souvlaki("file://nas/music/a.jpg", true),
"file://\\\\nas\\music\\a.jpg"
);
}
#[test]
fn a_posix_path_is_left_alone_even_on_windows() {
// No drive, no authority: nothing here can turn it into a Windows path.
assert_eq!(
cover_for_souvlaki("file:///home/v/c.jpg", true),
"file:///home/v/c.jpg"
);
}
#[test]
fn both_producers_percent_decode_the_same_way() {
// fs_url::file_url leaves a space LITERAL...
assert_eq!(
cover_for_souvlaki("file:///C:/My Music/a b.jpg", true),
"file://C:\\My Music\\a b.jpg"
);
// ...while url::Url::from_file_path escapes it as %20. One full decode
// is right for both.
assert_eq!(
cover_for_souvlaki("file:///C:/My%20Music/a.jpg", true),
"file://C:\\My Music\\a.jpg"
);
}
#[test]
fn the_escapes_that_matter_are_undone() {
// A # in a filename reaches SMTC as %23 otherwise, and the cover
// silently never loads.
assert_eq!(
cover_for_souvlaki("file:///C:/m/a%23b%3Fc.jpg", true),
"file://C:\\m\\a#b?c.jpg"
);
}
#[test]
fn a_literal_percent_twenty_is_not_double_decoded() {
// A filename that really contains "%20" is escaped to %2520 by either
// producer; ONE decode must give back "%20", not a space.
assert_eq!(
cover_for_souvlaki("file:///C:/m/a%2520b.jpg", true),
"file://C:\\m\\a%20b.jpg"
);
}
}
+2
View File
@@ -377,6 +377,8 @@ fn build_rhi_items() {
// font through QFontDatabase before Main.qml constructs any text.
println!("cargo:rerun-if-changed=cxx/font_fallback.cpp");
cc.file("cxx/font_fallback.cpp");
println!("cargo:rerun-if-changed=cxx/win_shell.cpp");
cc.file("cxx/win_shell.cpp");
for p in qtbuild.include_paths() {
cc.include(p);
}
+52
View File
@@ -0,0 +1,52 @@
// Windows shell helpers with no Q_OBJECT of their own.
//
// Compiled on EVERY platform on purpose: the bodies are portable Qt and only
// the caller's use of the WId is Windows-specific. Keeping it unconditional
// means the file is type-checked by the Linux and macOS builds too, which is
// where a signature drift would otherwise go unnoticed until a Windows CI run.
#include <QtCore/QObject>
#include <QtGui/QGuiApplication>
#include <QtGui/QSessionManager>
#include <QtGui/QWindow>
// The top-level window's native handle, or nullptr before one is shown.
//
// MUST NOT be called before exec(): QWindow::winId() CREATES the platform
// window as a side effect, so an early call would hand back a handle for a
// window Qt has not finished setting up, and the real one would then get a
// different HWND. Callers hop to the GUI thread first; winId() is not
// thread-safe.
extern "C" void *qbz_main_window_hwnd()
{
const auto windows = QGuiApplication::topLevelWindows();
for (QWindow *w : windows)
if (w->isVisible() && w->type() == Qt::Window)
return reinterpret_cast<void *>(w->winId());
return nullptr;
}
// WM_QUERYENDSESSION -> QGuiApplication::commitDataRequest, via Qt's Windows
// session manager. It fires BEFORE Windows decides whether to proceed with the
// logoff and Qt blocks until the handler returns, which makes it the one safe
// place to persist synchronously: anything deferred to a queued connection or
// another thread races the process being killed.
extern "C" void qbz_install_commit_data_handler(void (*cb)())
{
#ifndef QT_NO_SESSIONMANAGER
// Once. Qt::UniqueConnection does NOT deduplicate lambdas -- each connect
// makes an independent connection -- so a second install would run the
// session persist twice on every logoff.
static bool installed = false;
if (installed)
return;
installed = true;
QObject::connect(qApp, &QGuiApplication::commitDataRequest, qApp,
[cb](QSessionManager &) { cb(); });
#else
// A Qt built without the session manager has no commitDataRequest at all,
// and this file compiles on every platform.
(void)cb;
#endif
}
+3
View File
@@ -392,6 +392,9 @@ mod tray_macos;
// macOS) rather than a gated `mod` line, so the QbzShell invokable that
// calls it needs no cfg of its own.
mod macos_chrome;
// Ungated: the C++ behind it is portable Qt, so every platform
// type-checks the seam even though only Windows uses the handle.
mod win_shell;
// MPRIS / media keys (owner ruling K3, REVERSED by the owner on 2026-08-04
// after smoking the tray: "no aparece por ejemplo en el widget de now playing
// de KDE Plasma"). Plasma reads MPRIS, so this is what makes the desktop see
+82 -5
View File
@@ -105,12 +105,89 @@ pub(crate) fn init() {
if CONTROLS.get().is_some() {
return;
}
match qbz_media_controls::spawn(dispatch) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started");
// NON-WINDOWS: unchanged, deliberately. An earlier draft routed every
// platform through the GUI hop and that was a Linux/macOS regression on
// three counts: `tray_bridge::ui` is a NO-OP until `QbzTray::boot`
// registers the Qt thread (`tray_bridge.rs:172`) and `QbzSession.boot()`
// runs first (`Main.qml:254` vs `:380`), so initialization could be
// dropped outright; 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 that had no reason to.
#[cfg(not(target_os = "windows"))]
{
match qbz_media_controls::spawn(dispatch, qbz_media_controls::NativeWindow::default()) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started");
}
None => log::info!("[media-controls] no integration on this platform"),
}
None => log::info!("[media-controls] no integration on this platform"),
}
#[cfg(target_os = "windows")]
init_windows();
}
/// Windows needs the main window's HWND before SMTC can register, and it needs
/// to read it on the GUI thread. Both of the things it waits for -- the tray
/// bridge's Qt thread and a shown top-level window -- are "not yet", never
/// "not ever", so this retries on a bounded schedule instead of giving up on
/// the first miss the way a single hop would.
#[cfg(target_os = "windows")]
fn init_windows() {
use std::sync::atomic::{AtomicBool, Ordering};
// Gate on QUEUED, not on CONTROLS: `init` can be called more than once,
// and CONTROLS is only set at the END of a successful spawn, so gating on
// it alone would let several initializers race and build duplicate
// integrations before one wins the OnceLock.
static QUEUED: AtomicBool = AtomicBool::new(false);
if QUEUED.swap(true, Ordering::SeqCst) {
return;
}
// A plain thread, because it is allowed to sleep: this must not occupy a
// tokio worker, and it must not be the GUI thread it is posting to.
let spawned = std::thread::Builder::new()
.name("qbz-smtc-init".to_string())
.spawn(|| {
// 40 x 250 ms = 10 s, comfortably longer than a cold window show.
for _ in 0..40 {
if CONTROLS.get().is_some() {
return;
}
crate::tray_bridge::ui(|_b| {
if CONTROLS.get().is_some() {
return;
}
let native = qbz_media_controls::NativeWindow {
hwnd: crate::win_shell::main_window_hwnd().map(|p| p.as_ptr()),
};
if native.hwnd.is_none() {
// No top-level window yet. Say nothing and let the
// next tick try -- logging here would print forty
// times for a condition that resolves itself.
return;
}
match qbz_media_controls::spawn(dispatch, native) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started (SMTC)");
}
None => log::info!("[media-controls] no integration on this platform"),
}
});
std::thread::sleep(std::time::Duration::from_millis(250));
}
if CONTROLS.get().is_none() {
log::warn!(
"[media-controls] SMTC never started: no top-level window after 10 s"
);
}
});
if let Err(e) = spawned {
log::warn!("[media-controls] could not spawn the SMTC init thread: {e}");
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Windows shell helpers backed by `cxx/win_shell.cpp` (the `font_qt.rs`
//! pattern: a thin `extern "C"` seam, one safety note per call).
//!
//! The C++ half compiles on every platform, so this module does too. What is
//! Windows-specific is what the callers do with the handle.
use std::ffi::c_void;
use std::ptr::NonNull;
unsafe extern "C" {
fn qbz_main_window_hwnd() -> *mut c_void;
fn qbz_install_commit_data_handler(cb: extern "C" fn());
}
/// The top-level window's native handle, or `None` before it is shown.
///
/// GUI THREAD ONLY, and never before `exec()`: `QWindow::winId()` creates the
/// platform window as a side effect, so calling it early both returns a handle
/// for a half-built window and changes the handle the real one ends up with.
#[allow(dead_code)] // consumed by Task 2 (SMTC) and Task 5 (tray)
pub(crate) fn main_window_hwnd() -> Option<NonNull<c_void>> {
// SAFETY: takes no arguments and only reads QGuiApplication state. The
// caller guarantees the GUI thread and a live QGuiApplication.
NonNull::new(unsafe { qbz_main_window_hwnd() })
}
/// Run `cb` when Windows asks whether the session may end (WM_QUERYENDSESSION).
///
/// Qt blocks inside the signal until the handler returns, so `cb` must do its
/// work synchronously -- that is the point of the hook, and the reason a
/// queued connection would be wrong here.
#[allow(dead_code)] // consumed by Task 8 (logoff persistence)
pub(crate) fn install_commit_data_handler(cb: extern "C" fn()) {
// SAFETY: `qApp` exists (this runs after `QGuiApplication::new`), and the
// lambda stored on the C++ side only calls `cb`, which is a `'static` fn
// pointer with no captured state.
unsafe { qbz_install_commit_data_handler(cb) }
}
+11 -5
View File
@@ -81,11 +81,17 @@ pub fn spawn(
// integration never keeps the runtime alive.
let weak: Weak<AppRuntime<DaemonAdapter>> = Arc::downgrade(runtime);
let cb_handle = handle.clone();
let integration: Arc<dyn MediaIntegration> = Arc::from(qbz_media_controls::spawn(move |ev| {
if let Some(rt) = weak.upgrade() {
handle_media_event(&rt, &roots, &cb_handle, ev);
}
})?);
// Headless: the daemon owns no window, so there is no HWND to give.
// On Linux, which is the only place qbzd ships, MPRIS keys off the D-Bus
// name and ignores this entirely.
let integration: Arc<dyn MediaIntegration> = Arc::from(qbz_media_controls::spawn(
move |ev| {
if let Some(rt) = weak.upgrade() {
handle_media_event(&rt, &roots, &cb_handle, ev);
}
},
qbz_media_controls::NativeWindow::default(),
)?);
log::info!("[mpris] publishing org.mpris.MediaPlayer2 (desktop media controls + media keys)");
// OUTBOUND: seed once from live state, then follow the bus.