feat(slint): system media controls — MPRIS with GNOME app icon + SMTC/MediaRemote

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
This commit is contained in:
vicrodh committed 2026-06-06 11:03:11 -06:00
1 parent 682e735dc2
commit 7a7e5f82c4
12 files changed
+893 -4

No files matched your search

+157 -3
View File
@@ -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",
+1
View File
@@ -23,6 +23,7 @@ members = [
"qconnect-app",
"qbz-slint",
"qbz-credentials",
"qbz-media-controls",
]
[workspace.package]
+25
View File
@@ -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"
+49
View File
@@ -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<Box<dyn MediaIntegration>> {
let cb: Arc<dyn Fn(MediaEvent) + Send + Sync> = Arc::new(on_event);
#[cfg(target_os = "linux")]
{
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>);
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = cb;
None
}
}
+340
View File
@@ -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 → `<name>.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<dyn Fn(MediaEvent) + Send + Sync>;
/// 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<Time>,
},
Volume(Volume),
}
/// The cloneable handle returned to the app. Pushing state is a non-blocking
/// channel send from any thread/context.
pub struct LinuxHandle {
tx: async_channel::Sender<Update>,
}
impl MediaIntegration for LinuxHandle {
fn set_metadata(&self, meta: &TrackMeta) {
let _ = self.tx.try_send(Update::Metadata(build_metadata(meta)));
}
fn set_playback(&self, status: PlaybackStatus, position: Option<std::time::Duration>) {
let _ = self.tx.try_send(Update::Playback {
status: map_status(status),
position: position.map(|d| Time::from_micros(d.as_micros() as i64)),
});
}
fn set_volume(&self, vol: f64) {
let _ = self.tx.try_send(Update::Volume(vol.clamp(0.0, 1.0)));
}
}
fn map_status(s: PlaybackStatus) -> MprisStatus {
match s {
PlaybackStatus::Playing => MprisStatus::Playing,
PlaybackStatus::Paused => MprisStatus::Paused,
PlaybackStatus::Stopped => MprisStatus::Stopped,
}
}
fn build_metadata(meta: &TrackMeta) -> Metadata {
let seq = TRACK_SEQ.fetch_add(1, Ordering::Relaxed);
let trackid = TrackId::try_from(format!("/com/blitzfc/qbz/track/{seq}"))
.unwrap_or(TrackId::NO_TRACK);
let mut b = Metadata::builder().trackid(trackid).title(meta.title.clone());
if !meta.artist.is_empty() {
b = b.artist([meta.artist.clone()]);
}
if !meta.album.is_empty() {
b = b.album(meta.album.clone());
}
if let Some(d) = meta.duration {
b = b.length(Time::from_micros(d.as_micros() as i64));
}
if let Some(url) = &meta.art_url {
b = b.art_url(url.clone());
}
b.build()
}
/// The MPRIS interface implementation. Getters read shared `State`; the action
/// methods forward to the app via `on_event`.
struct QbzMpris {
on_event: EventCb,
state: Arc<Mutex<State>>,
}
impl QbzMpris {
fn emit(&self, ev: MediaEvent) {
(self.on_event)(ev);
}
}
impl RootInterface for QbzMpris {
async fn raise(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Raise);
Ok(())
}
async fn quit(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Quit);
Ok(())
}
async fn can_quit(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_fullscreen(&self, _fullscreen: bool) -> zbus::Result<()> {
Ok(())
}
async fn can_set_fullscreen(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn can_raise(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn has_track_list(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn identity(&self) -> fdo::Result<String> {
Ok(IDENTITY.to_string())
}
/// The GNOME app-icon fix: GNOME resolves the icon from this property.
async fn desktop_entry(&self) -> fdo::Result<String> {
Ok(DESKTOP_ENTRY.to_string())
}
async fn supported_uri_schemes(&self) -> fdo::Result<Vec<String>> {
Ok(vec![])
}
async fn supported_mime_types(&self) -> fdo::Result<Vec<String>> {
Ok(vec![])
}
}
impl PlayerInterface for QbzMpris {
async fn next(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Next);
Ok(())
}
async fn previous(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Previous);
Ok(())
}
async fn pause(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Pause);
Ok(())
}
async fn play_pause(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Toggle);
Ok(())
}
async fn stop(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Stop);
Ok(())
}
async fn play(&self) -> fdo::Result<()> {
self.emit(MediaEvent::Play);
Ok(())
}
async fn seek(&self, offset: Time) -> fdo::Result<()> {
self.emit(MediaEvent::SeekBy(offset.as_micros()));
Ok(())
}
async fn set_position(&self, _track_id: TrackId, position: Time) -> fdo::Result<()> {
self.emit(MediaEvent::SetPosition(position.as_micros()));
Ok(())
}
async fn open_uri(&self, _uri: String) -> fdo::Result<()> {
Ok(())
}
async fn playback_status(&self) -> fdo::Result<MprisStatus> {
Ok(self.state.lock().unwrap().status)
}
async fn loop_status(&self) -> fdo::Result<LoopStatus> {
Ok(LoopStatus::None)
}
async fn set_loop_status(&self, _loop_status: LoopStatus) -> zbus::Result<()> {
Ok(())
}
async fn rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn set_rate(&self, _rate: PlaybackRate) -> zbus::Result<()> {
Ok(())
}
async fn shuffle(&self) -> fdo::Result<bool> {
Ok(false)
}
async fn set_shuffle(&self, _shuffle: bool) -> zbus::Result<()> {
Ok(())
}
async fn metadata(&self) -> fdo::Result<Metadata> {
Ok(self.state.lock().unwrap().metadata.clone())
}
async fn volume(&self) -> fdo::Result<Volume> {
Ok(self.state.lock().unwrap().volume)
}
async fn set_volume(&self, volume: Volume) -> zbus::Result<()> {
self.emit(MediaEvent::SetVolume(volume.clamp(0.0, 1.0)));
Ok(())
}
async fn position(&self) -> fdo::Result<Time> {
Ok(self.state.lock().unwrap().position)
}
async fn minimum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn maximum_rate(&self) -> fdo::Result<PlaybackRate> {
Ok(1.0)
}
async fn can_go_next(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_go_previous(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_play(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_pause(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_seek(&self) -> fdo::Result<bool> {
Ok(true)
}
async fn can_control(&self) -> fdo::Result<bool> {
Ok(true)
}
}
async fn apply(server: &Server<QbzMpris>, state: &Arc<Mutex<State>>, update: Update) {
match update {
Update::Metadata(m) => {
state.lock().unwrap().metadata = m.clone();
let _ = server.properties_changed([Property::Metadata(m)]).await;
}
Update::Playback { status, position } => {
{
let mut st = state.lock().unwrap();
st.status = status;
if let Some(p) = position {
st.position = p;
}
}
let _ = server
.properties_changed([Property::PlaybackStatus(status)])
.await;
}
Update::Volume(v) => {
state.lock().unwrap().volume = v;
let _ = server.properties_changed([Property::Volume(v)]).await;
}
}
}
/// Spawn the MPRIS server on a dedicated thread. Returns `None` if the thread
/// or runtime can't start (the bus registration happens async on the thread;
/// failures there are logged, not surfaced — the app keeps running).
pub fn spawn(on_event: EventCb) -> Option<LinuxHandle> {
let (tx, rx) = async_channel::unbounded::<Update>();
let spawned = std::thread::Builder::new()
.name("qbz-mpris".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::error!("[mpris] runtime build failed: {e}");
return;
}
};
rt.block_on(async move {
let state = Arc::new(Mutex::new(State {
metadata: Metadata::new(),
status: MprisStatus::Stopped,
volume: 1.0,
position: Time::ZERO,
}));
let imp = QbzMpris {
on_event,
state: state.clone(),
};
let server = match Server::new(BUS_SUFFIX, imp).await {
Ok(s) => s,
Err(e) => {
log::error!("[mpris] failed to register org.mpris.MediaPlayer2.{BUS_SUFFIX}: {e}");
return;
}
};
log::info!(
"[mpris] registered org.mpris.MediaPlayer2.{BUS_SUFFIX} (DesktopEntry={DESKTOP_ENTRY})"
);
while let Ok(update) = rx.recv().await {
apply(&server, &state, update).await;
}
log::debug!("[mpris] update channel closed, server shutting down");
});
});
match spawned {
Ok(_) => Some(LinuxHandle { tx }),
Err(e) => {
log::error!("[mpris] failed to spawn server thread: {e}");
None
}
}
}
+118
View File
@@ -0,0 +1,118 @@
//! macOS (MediaRemote / MPNowPlayingInfoCenter) + Windows (SMTC) backend via
//! souvlaki. No MPRIS / DesktopEntry here — macOS keys the Now Playing icon off
//! the app bundle; Windows SMTC off the package. On macOS `MediaControls` is a
//! zero-sized handle over global objc singletons (Send + Sync) and its command
//! callbacks fire on the app's run loop (Slint's winit loop), so it is driven
//! from any thread without main-thread marshaling — same as the Tauri build.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
SeekDirection,
};
use crate::types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
/// Default step for a magnitude-less MPRIS-style `Seek` (5 seconds, micros).
const SEEK_STEP_MICROS: i64 = 5_000_000;
type EventCb = Arc<dyn Fn(MediaEvent) + Send + Sync>;
pub struct PlatformHandle {
controls: Arc<Mutex<Option<MediaControls>>>,
}
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() {
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(),
};
let _ = c.set_metadata(md);
}
}
}
fn set_playback(&self, status: PlaybackStatus, position: Option<Duration>) {
if let Ok(mut guard) = self.controls.lock() {
if let Some(c) = guard.as_mut() {
let progress = position.map(MediaPosition);
let pb = match status {
PlaybackStatus::Playing => MediaPlayback::Playing { progress },
PlaybackStatus::Paused => MediaPlayback::Paused { progress },
PlaybackStatus::Stopped => MediaPlayback::Stopped,
};
let _ = c.set_playback(pb);
}
}
}
fn set_volume(&self, _vol: f64) {
// souvlaki exposes no outbound volume (SMTC/MediaRemote manage it);
// inbound SetVolume still arrives as a MediaEvent. No-op here.
}
}
fn map_event(e: MediaControlEvent) -> Option<MediaEvent> {
Some(match e {
MediaControlEvent::Play => MediaEvent::Play,
MediaControlEvent::Pause => MediaEvent::Pause,
MediaControlEvent::Toggle => MediaEvent::Toggle,
MediaControlEvent::Next => MediaEvent::Next,
MediaControlEvent::Previous => MediaEvent::Previous,
MediaControlEvent::Stop => MediaEvent::Stop,
MediaControlEvent::Raise => MediaEvent::Raise,
MediaControlEvent::Quit => MediaEvent::Quit,
MediaControlEvent::Seek(SeekDirection::Forward) => MediaEvent::SeekBy(SEEK_STEP_MICROS),
MediaControlEvent::Seek(SeekDirection::Backward) => MediaEvent::SeekBy(-SEEK_STEP_MICROS),
MediaControlEvent::SeekBy(dir, dur) => {
let micros = dur.as_micros() as i64;
MediaEvent::SeekBy(match dir {
SeekDirection::Forward => micros,
SeekDirection::Backward => -micros,
})
}
MediaControlEvent::SetPosition(pos) => MediaEvent::SetPosition(pos.0.as_micros() as i64),
MediaControlEvent::SetVolume(v) => MediaEvent::SetVolume(v),
MediaControlEvent::OpenUri(_) => return None,
})
}
pub fn spawn(on_event: EventCb) -> Option<PlatformHandle> {
let config = PlatformConfig {
dbus_name: "com.blitzfc.qbz",
display_name: "QBZ",
// macOS: unused. Windows SMTC needs the window HWND; not shipped/tested,
// so left None (init may fail there → None handle, no media controls).
hwnd: None,
};
let mut controls = match MediaControls::new(config) {
Ok(c) => c,
Err(e) => {
log::warn!("[media-controls] souvlaki init failed: {e:?}");
return None;
}
};
if let Err(e) = controls.attach(move |event: MediaControlEvent| {
if let Some(ev) = map_event(event) {
on_event(ev);
}
}) {
log::warn!("[media-controls] souvlaki attach failed: {e:?}");
return None;
}
log::info!("[media-controls] souvlaki (SMTC/MediaRemote) initialized");
Some(PlatformHandle {
controls: Arc::new(Mutex::new(Some(controls))),
})
}
+53
View File
@@ -0,0 +1,53 @@
//! Frontend-agnostic media-control types — no winit / Slint / Tauri here.
use std::time::Duration;
/// Now-playing metadata pushed to the OS media controls.
#[derive(Debug, Clone, Default)]
pub struct TrackMeta {
pub title: String,
pub artist: String,
pub album: String,
pub duration: Option<Duration>,
/// `mpris:artUrl` — the ALBUM ART for the track (http/https/file URL). This
/// is NOT the application icon (that is resolved by GNOME from the MPRIS
/// `DesktopEntry` property → the installed .desktop → `Icon=`).
pub art_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaybackStatus {
Playing,
Paused,
Stopped,
}
/// Inbound events from the OS media controls (media keys, GNOME/KDE widget,
/// macOS Now Playing, Windows SMTC). Delivered to the callback passed to
/// [`crate::spawn`]. All time values are MICROSECONDS.
#[derive(Debug, Clone)]
pub enum MediaEvent {
Play,
Pause,
Toggle,
Next,
Previous,
Stop,
/// Relative seek by the given micros (signed; MPRIS `Seek`).
SeekBy(i64),
/// Absolute seek to the given micros (MPRIS `SetPosition`).
SetPosition(i64),
/// Set volume 0.0..=1.0.
SetVolume(f64),
Raise,
Quit,
}
/// A live handle to the OS media-controls integration. Cloneable callers hold
/// it for the app lifetime and push state through it; dropping the last one
/// tears the integration down.
pub trait MediaIntegration: Send + Sync {
fn set_metadata(&self, meta: &TrackMeta);
fn set_playback(&self, status: PlaybackStatus, position: Option<Duration>);
fn set_volume(&self, vol: f64);
}
+1
View File
@@ -43,6 +43,7 @@ serde_json = { workspace = true }
dirs = "5"
chrono = { workspace = true, features = ["unstable-locales"] }
qbz-app = { path = "../qbz-app" }
qbz-media-controls = { path = "../qbz-media-controls" }
qbz-audio = { path = "../qbz-audio" }
qbz-core = { path = "../qbz-core" }
qbz-player = { path = "../qbz-player" }
+10
View File
@@ -49,6 +49,7 @@ mod folders;
mod library_db;
mod local_library;
mod local_library_settings;
mod media_controls;
mod locallibrary_prefs;
mod tag_editor;
mod offline;
@@ -113,6 +114,15 @@ async fn enter_shell(
tray.enable_tray,
);
// System media controls — MPRIS on Linux (publishes DesktopEntry so GNOME
// shows the app icon), SMTC/MediaRemote on macOS/Windows. Independent of
// the tray; pushes metadata/state from the playback paths.
media_controls::init(
runtime.clone(),
weak.clone(),
tokio::runtime::Handle::current(),
);
let _ = weak.upgrade_in_event_loop(move |w| {
let state = w.global::<SessionState>();
state.set_user_name(session.display_name.into());
+110
View File
@@ -0,0 +1,110 @@
//! System media controls wiring (MPRIS on Linux — with the app icon via the
//! `DesktopEntry` property — SMTC/MediaRemote on macOS/Windows).
//!
//! The backend lives in the frontend-agnostic `qbz-media-controls` crate; this
//! module owns the process-global handle and bridges inbound control events
//! (media keys, the GNOME/KDE media widget, macOS Now Playing) to the player.
//! Playback metadata/state is pushed from `playback.rs` (mirroring the tray).
use std::sync::{Arc, OnceLock};
use qbz_media_controls::{MediaEvent, MediaIntegration};
use crate::adapter::SlintAdapter;
use crate::AppWindow;
use qbz_app::shell::AppRuntime;
type Runtime = Arc<AppRuntime<SlintAdapter>>;
static CONTROLS: OnceLock<Box<dyn MediaIntegration>> = OnceLock::new();
/// The live integration, if it started. Playback pushes metadata/state through
/// it (`set_metadata`/`set_playback`).
pub fn handle() -> Option<&'static dyn MediaIntegration> {
CONTROLS.get().map(|b| b.as_ref())
}
/// Start the OS media-controls integration once. No-op if already started or
/// unavailable on this platform.
pub fn init(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
if CONTROLS.get().is_some() {
return;
}
let on_event = move |ev: MediaEvent| {
dispatch(ev, runtime.clone(), weak.clone(), handle.clone());
};
match qbz_media_controls::spawn(on_event) {
Some(c) => {
let _ = CONTROLS.set(c);
log::info!("[media-controls] integration started");
}
None => log::info!("[media-controls] no integration on this platform"),
}
}
fn dispatch(ev: MediaEvent, rt: Runtime, weak: slint::Weak<AppWindow>, h: tokio::runtime::Handle) {
match ev {
// The OS only sends Play when paused and Pause when playing (it reads
// PlaybackStatus), so routing all three through the toggle is correct
// and keeps the QConnect-aware path (shared with the tray).
MediaEvent::Play | MediaEvent::Pause | MediaEvent::Toggle => {
crate::tray::dispatch_play_pause(rt, weak, h)
}
MediaEvent::Next => crate::tray::dispatch_next(rt, weak, h),
MediaEvent::Previous => crate::tray::dispatch_previous(rt, weak, h),
MediaEvent::Stop => {
h.spawn(async move {
if let Err(e) = rt.core().stop() {
log::warn!("[media-controls] stop failed: {e}");
}
});
}
MediaEvent::Raise => crate::tray::show_window(&weak),
MediaEvent::Quit => crate::tray::quit(),
MediaEvent::SetVolume(v) => crate::playback::set_volume(rt, weak, h, v as f32),
MediaEvent::SetPosition(micros) => seek_to_micros(rt, h, micros),
MediaEvent::SeekBy(delta_micros) => seek_by_micros(rt, h, delta_micros),
}
}
fn seek_to_micros(rt: Runtime, h: tokio::runtime::Handle, micros: i64) {
let spawn_h = h.clone();
h.spawn(async move {
let dur = rt.core().get_playback_state().duration; // seconds
if dur == 0 {
return;
}
let fraction = (micros as f64 / 1_000_000.0 / dur as f64).clamp(0.0, 1.0) as f32;
do_seek(rt, spawn_h, fraction).await;
});
}
fn seek_by_micros(rt: Runtime, h: tokio::runtime::Handle, delta_micros: i64) {
let spawn_h = h.clone();
h.spawn(async move {
let st = rt.core().get_playback_state();
if st.duration == 0 {
return;
}
let target = ((st.position as i64) * 1_000_000 + delta_micros).max(0);
let fraction = (target as f64 / 1_000_000.0 / st.duration as f64).clamp(0.0, 1.0) as f32;
do_seek(rt, spawn_h, fraction).await;
});
}
/// QConnect-aware seek (mirrors the now-playing bar's `on_seek`).
async fn do_seek(rt: Runtime, h: tokio::runtime::Handle, fraction: f32) {
if let Some(svc) = crate::qconnect_service::service() {
let position_ms =
(fraction as f64 * rt.core().get_playback_state().duration as f64 * 1000.0).round() as i64;
match svc.set_position_if_remote(position_ms).await {
Ok(true) => return,
Ok(false) => {}
Err(e) => {
log::warn!("[media-controls] seek handoff: {e}");
return;
}
}
}
crate::playback::seek(rt, h, fraction);
}
+29 -1
View File
@@ -518,10 +518,13 @@ fn fmt_remaining(position: u64, duration: u64) -> String {
pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
let state = runtime.core().get_queue_state().await;
let Some(track) = state.current_track else {
// No current track → clear the tray tooltip (Linux).
// No current track → clear the tray tooltip (Linux) + stop media controls.
if let Some(t) = crate::tray::handle() {
t.clear_track();
}
if let Some(mc) = crate::media_controls::handle() {
mc.set_playback(qbz_media_controls::PlaybackStatus::Stopped, None);
}
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<NowPlayingState>().set_has_track(false);
});
@@ -558,6 +561,23 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
t.set_track(title.clone(), artist.clone(), album.clone());
}
// Push to the OS media controls (MPRIS / SMTC / MediaRemote). The app icon
// GNOME shows comes from the MPRIS DesktopEntry, not from album art; art_url
// (album art) is a later refinement.
if let Some(mc) = crate::media_controls::handle() {
mc.set_metadata(&qbz_media_controls::TrackMeta {
title: title.clone(),
artist: artist.clone(),
album: album.clone(),
duration: (duration > 0).then(|| std::time::Duration::from_secs(duration as u64)),
art_url: None,
});
mc.set_playback(
qbz_media_controls::PlaybackStatus::Playing,
Some(std::time::Duration::ZERO),
);
}
let _ = weak.upgrade_in_event_loop(move |w| {
let np = w.global::<NowPlayingState>();
np.set_has_track(true);
@@ -2409,6 +2429,14 @@ pub fn start_poll_loop(
if let Some(t) = crate::tray::handle() {
t.set_playing(is_playing);
}
if let Some(mc) = crate::media_controls::handle() {
let status = if is_playing {
qbz_media_controls::PlaybackStatus::Playing
} else {
qbz_media_controls::PlaybackStatus::Paused
};
mc.set_playback(status, Some(std::time::Duration::from_secs(position as u64)));
}
}
was_playing = is_playing;