From 7a7e5f82c4e2d68f400dedd637dcf29d0e53fe23 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:03:11 -0600 Subject: [PATCH] =?UTF-8?q?feat(slint):=20system=20media=20controls=20?= =?UTF-8?q?=E2=80=94=20MPRIS=20with=20GNOME=20app=20icon=20+=20SMTC/MediaR?= =?UTF-8?q?emote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New frontend-agnostic crate qbz-media-controls (ADR-006): one MediaIntegration trait, cfg-split backends. Linux uses mpris-server and publishes the MPRIS org.mpris.MediaPlayer2.DesktopEntry property as com.blitzfc.qbz — the only way GNOME Shell resolves the app icon for its media widget (souvlaki, the old cross-platform crate, never set it, so GNOME showed no icon; KDE is lenient). macOS/Windows keep souvlaki (MediaRemote/SMTC), where the icon comes from the app bundle, not a DesktopEntry. The Linux server runs on a dedicated thread with its own tokio runtime; state arrives over an async channel. Wired into qbz-slint via src/media_controls.rs: started on shell entry, metadata/playback pushed from the playback paths (mirroring the tray), and inbound control events (media keys, the desktop media widget, Now Playing) dispatched to the player through the existing QConnect-aware helpers. Both Linux and macOS compile clean. cc --- crates/Cargo.lock | 160 ++++++++- crates/Cargo.toml | 1 + crates/qbz-media-controls/Cargo.toml | 25 ++ crates/qbz-media-controls/src/lib.rs | 49 +++ crates/qbz-media-controls/src/linux.rs | 340 ++++++++++++++++++ crates/qbz-media-controls/src/platform.rs | 118 ++++++ crates/qbz-media-controls/src/types.rs | 53 +++ crates/qbz-slint/Cargo.toml | 1 + crates/qbz-slint/src/main.rs | 10 + crates/qbz-slint/src/media_controls.rs | 110 ++++++ crates/qbz-slint/src/playback.rs | 30 +- .../{qbz.desktop => com.blitzfc.qbz.desktop} | 0 12 files changed, 893 insertions(+), 4 deletions(-) create mode 100644 crates/qbz-media-controls/Cargo.toml create mode 100644 crates/qbz-media-controls/src/lib.rs create mode 100644 crates/qbz-media-controls/src/linux.rs create mode 100644 crates/qbz-media-controls/src/platform.rs create mode 100644 crates/qbz-media-controls/src/types.rs create mode 100644 crates/qbz-slint/src/media_controls.rs rename packaging/arch/{qbz.desktop => com.blitzfc.qbz.desktop} (100%) diff --git a/crates/Cargo.lock b/crates/Cargo.lock index c50ec44e6..644906682 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -779,6 +779,12 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -1125,6 +1131,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 0.22.3", + "foreign-types 0.3.2", + "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 = "color_quant" version = "1.1.0" @@ -1261,6 +1297,19 @@ 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 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.23.2" @@ -1270,7 +1319,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1499,6 +1548,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "dbus-crossroads" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" +dependencies = [ + "dbus", +] + [[package]] name = "dbus-secret-service" version = "4.1.0" @@ -2073,6 +2131,15 @@ dependencies = [ "yeslogic-fontconfig-sys", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2080,7 +2147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2094,6 +2161,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -4024,6 +4097,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 = "maybe-rayon" version = "0.1.1" @@ -4118,6 +4200,19 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mpris-server" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "058bc2227727af394f34aa51da3e36aeecf2c808f39315d35f754872660750ae" +dependencies = [ + "async-channel", + "futures-channel", + "serde", + "trait-variant", + "zbus 4.4.0", +] + [[package]] name = "muda" version = "0.18.0" @@ -4372,6 +4467,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -5575,6 +5679,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "qbz-media-controls" +version = "0.1.0" +dependencies = [ + "async-channel", + "log", + "mpris-server", + "souvlaki", + "tokio", +] + [[package]] name = "qbz-models" version = "0.1.0" @@ -5714,6 +5829,7 @@ dependencies = [ "qbz-credentials", "qbz-integrations", "qbz-library", + "qbz-media-controls", "qbz-models", "qbz-offline-cache", "qbz-player", @@ -7060,6 +7176,24 @@ dependencies = [ "x11rb", ] +[[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 0.22.3", + "dbus", + "dbus-crossroads", + "dispatch", + "objc", + "thiserror 1.0.69", + "windows 0.44.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -7924,6 +8058,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "transpose" version = "0.2.3" @@ -8639,6 +8784,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.61.3" @@ -9151,7 +9305,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 31d58a7e4..a48efd304 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -23,6 +23,7 @@ members = [ "qconnect-app", "qbz-slint", "qbz-credentials", + "qbz-media-controls", ] [workspace.package] diff --git a/crates/qbz-media-controls/Cargo.toml b/crates/qbz-media-controls/Cargo.toml new file mode 100644 index 000000000..76c27e326 --- /dev/null +++ b/crates/qbz-media-controls/Cargo.toml @@ -0,0 +1,25 @@ +[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 } + +# 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" +tokio = { workspace = true } + +# 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" diff --git a/crates/qbz-media-controls/src/lib.rs b/crates/qbz-media-controls/src/lib.rs new file mode 100644 index 000000000..332a5a67a --- /dev/null +++ b/crates/qbz-media-controls/src/lib.rs @@ -0,0 +1,49 @@ +//! Frontend-agnostic system media controls (ADR-006). +//! +//! - **Linux:** `mpris-server` — publishes the full MPRIS interface INCLUDING +//! `org.mpris.MediaPlayer2.DesktopEntry = "com.blitzfc.qbz"`, the only way +//! GNOME Shell resolves the application icon for its media widget. (souvlaki, +//! the cross-platform crate, never sets it, so GNOME shows no app icon.) +//! - **macOS / Windows:** `souvlaki` — MediaRemote / SMTC, where there is no +//! DesktopEntry concept (macOS keys the icon off the app bundle). +//! +//! One trait ([`MediaIntegration`]); one factory ([`spawn`]); no winit / Slint +//! / Tauri types — headless/TUI can reuse it. + +use std::sync::Arc; + +mod types; +pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(any(target_os = "macos", target_os = "windows"))] +mod platform; + +/// Spawn the OS media-controls integration. `on_event` receives inbound +/// control events (media keys, the desktop media widget, macOS Now Playing). +/// Returns `None` if the platform backend could not start — the app keeps +/// working without media controls. +/// +/// **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. +pub fn spawn( + on_event: impl Fn(MediaEvent) + Send + Sync + 'static, +) -> Option> { + let cb: Arc = Arc::new(on_event); + + #[cfg(target_os = "linux")] + { + return linux::spawn(cb).map(|h| Box::new(h) as Box); + } + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + return platform::spawn(cb).map(|h| Box::new(h) as Box); + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = cb; + None + } +} diff --git a/crates/qbz-media-controls/src/linux.rs b/crates/qbz-media-controls/src/linux.rs new file mode 100644 index 000000000..0e8c8d2df --- /dev/null +++ b/crates/qbz-media-controls/src/linux.rs @@ -0,0 +1,340 @@ +//! Linux MPRIS backend via `mpris-server`. +//! +//! The whole reason this exists instead of souvlaki: `mpris-server`'s +//! `RootInterface::desktop_entry()` lets us publish the +//! `org.mpris.MediaPlayer2.DesktopEntry` property as `"com.blitzfc.qbz"`, +//! which is the ONLY mechanism GNOME Shell uses to resolve the application +//! icon for its media widget (DesktopEntry → `.desktop` → `Icon=`). +//! souvlaki never sets it, so GNOME shows no icon. (KDE is lenient and works +//! either way.) `mpris:artUrl` is album art — separate and unaffected. +//! +//! The server runs on a dedicated thread with its own current-thread tokio +//! runtime (the workspace forces zbus 4's `tokio` feature via qbz-audio, so a +//! tokio context must be present); state updates arrive over an async channel. + +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, +}; + +use mpris_server::zbus::{self, fdo}; +use mpris_server::{ + LoopStatus, Metadata, PlaybackRate, PlaybackStatus as MprisStatus, PlayerInterface, Property, + RootInterface, Server, Time, TrackId, Volume, +}; + +use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta}; + +const BUS_SUFFIX: &str = "com.blitzfc.qbz"; +const DESKTOP_ENTRY: &str = "com.blitzfc.qbz"; +const IDENTITY: &str = "QBZ"; + +/// Monotonic counter so each track gets a distinct `mpris:trackid` object path +/// (helps clients detect track changes). +static TRACK_SEQ: AtomicU64 = AtomicU64::new(1); + +type EventCb = Arc; + +/// Shared, mutable now-playing state. Read by the MPRIS getter methods (on the +/// zbus task) and written by the update loop (on the same runtime). Never held +/// across an `.await`. +struct State { + metadata: Metadata, + status: MprisStatus, + volume: Volume, + position: Time, +} + +/// Update commands sent from the app to the server thread. +enum Update { + Metadata(Metadata), + Playback { + status: MprisStatus, + position: Option