feat(slint): system "now playing" notifications on track change

Port the Tauri desktop-notification path to a frontend-agnostic crate so the
Slint app (and any future TUI/CLI) fires it from native Rust instead of a
webview invoke.

New qbz-media-controls::notify module:
- Linux: XDG notification portal via ashpd (D-Bus); album art passed as
  Icon::Bytes after center-crop + downscale to <=512px + PNG re-encode (<=4 MiB).
- macOS: notify_rust with image_path (cover cached to disk).
- Windows: no-op (parity with Tauri).
- Self-contained artwork download + md5 cache + resize, matching the Tauri
  helpers, with no dependency on the frontend (ADR-006).

Wired in playback::refresh_now_playing_meta (the same point that pushes the
tray tooltip + OS media controls):
- De-dupe by track id so only an actual track change fires (the refresh also
  runs on resume/seek).
- Skip while a remote QConnect renderer drives playback (matches the Svelte
  skipIfRemote). Fire-and-forget on a tokio task.

Enabled by default, matching the Tauri default; a settings toggle is a
follow-up.

cc
This commit is contained in:
vicrodh committed 2026-06-06 16:45:04 -06:00
1 parent b9434a5034
commit d08c5f6569
5 files changed
+414 -3

No files matched your search

+30
View File
@@ -515,10 +515,18 @@ fn fmt_remaining(position: u64, duration: u64) -> String {
/// Push the now-playing values for the current queue track onto
/// `NowPlayingState`. Called when a new track starts so the song card
/// updates immediately (the poll loop only refreshes position/progress).
/// Last track id we fired a desktop notification for. `refresh_now_playing_meta`
/// runs on resume/seek too, so we de-dupe to only notify on an actual track
/// change. `u64::MAX` = "nothing notified yet" (no real track id collides).
static NOTIFY_LAST_TRACK: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(u64::MAX);
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) + stop media controls.
// Reset the notify guard so replaying the same track after a stop fires.
NOTIFY_LAST_TRACK.store(u64::MAX, std::sync::atomic::Ordering::Relaxed);
if let Some(t) = crate::tray::handle() {
t.clear_track();
}
@@ -579,6 +587,28 @@ pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::We
);
}
// Desktop "now playing" notification (1:1 with the Tauri path). De-dupe so
// only an actual track change fires; skip while a remote QConnect renderer
// drives playback (matches the Svelte `skipIfRemote`). Fire-and-forget.
if NOTIFY_LAST_TRACK.swap(track.id, std::sync::atomic::Ordering::Relaxed) != track.id {
let notify_meta = qbz_media_controls::NotificationMeta {
title: title.clone(),
artist: artist.clone(),
album: album.clone(),
bit_depth: track.bit_depth,
sample_rate: track.sample_rate,
art_url: artwork.to_mpris_url(),
};
tokio::spawn(async move {
if let Some(svc) = crate::qconnect_service::service() {
if svc.is_peer_active().await {
return;
}
}
qbz_media_controls::show_track_notification(notify_meta).await;
});
}
let _ = weak.upgrade_in_event_loop(move |w| {
let np = w.global::<NowPlayingState>();
np.set_has_track(true);