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:
12 files changed
+893
-4
No files matched your search
@@ -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" }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user