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

+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;