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

+66 -2
View File
@@ -3781,6 +3781,18 @@ dependencies = [
"num-traits",
]
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"time",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -4026,6 +4038,20 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "notify-rust"
version = "4.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus 5.15.0",
]
[[package]]
name = "num"
version = "0.4.3"
@@ -5286,11 +5312,18 @@ dependencies = [
name = "qbz-media-controls"
version = "0.1.0"
dependencies = [
"ashpd 0.13.11",
"async-channel",
"dirs 6.0.0",
"image",
"log",
"md-5",
"mpris-server",
"notify-rust",
"reqwest",
"souvlaki",
"tokio",
"urlencoding",
]
[[package]]
@@ -5524,6 +5557,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -5883,6 +5925,7 @@ dependencies = [
"bytes",
"cookie",
"cookie_store",
"futures-channel",
"futures-core",
"futures-util",
"http",
@@ -7155,6 +7198,18 @@ dependencies = [
"xattr",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows 0.61.3",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.26.0"
@@ -8159,7 +8214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.39.4",
"quote",
]
@@ -8603,6 +8658,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
@@ -9271,7 +9335,7 @@ version = "5.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9"
dependencies = [
"quick-xml",
"quick-xml 0.39.4",
"serde",
"zbus_names 4.3.2",
"zvariant 5.11.0",
+23 -1
View File
@@ -8,6 +8,20 @@ description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on
[dependencies]
log = { workspace = true }
# Notifications (all platforms): the async entry point spawns the HTTP download
# + image resize on `spawn_blocking`, so a tokio runtime must be present.
tokio = { workspace = true }
# Artwork download + cache + (Linux) resize for the notification icon. Matches
# the Tauri notification helpers so output is identical.
reqwest = { workspace = true, features = ["blocking"] }
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"webp",
] }
dirs = "6"
md-5 = { workspace = true }
urlencoding = "2"
# Linux: mpris-server publishes the full MPRIS interface INCLUDING the
# org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry),
@@ -16,10 +30,18 @@ log = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
mpris-server = "0.8"
async-channel = "2"
tokio = { workspace = true }
# XDG notification portal (goes over D-Bus). Same crate + tokio runtime the
# secret portal already pulls in (qbz-credentials uses ashpd 0.13/tokio).
ashpd = { version = "0.13", default-features = false, features = [
"tokio",
"notification",
] }
# 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"
# macOS desktop notifications (NSUserNotification / UserNotifications). Windows
# is a no-op for now (parity with Tauri, which never implemented it).
notify-rust = "4"
+3
View File
@@ -15,6 +15,9 @@ use std::sync::Arc;
mod types;
pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
pub mod notify;
pub use notify::{show_track_notification, NotificationMeta};
#[cfg(target_os = "linux")]
mod linux;
#[cfg(any(target_os = "macos", target_os = "windows"))]
+292
View File
@@ -0,0 +1,292 @@
//! Desktop "now playing" notifications for track changes (frontend-agnostic, ADR-006).
//!
//! 1:1 port of the Tauri notification path (`src-tauri/src/commands_v2/
//! legacy_compat.rs::v2_show_track_notification` + the artwork helpers in
//! `helpers.rs`), lifted out of the Tauri command layer so any frontend
//! (Slint / TUI) can fire it from native Rust instead of a webview `invoke`.
//!
//! - **Linux** → XDG notification portal via `ashpd` (goes over D-Bus). The
//! album art is passed as `Icon::Bytes(png)` — the portal rejects huge
//! payloads, so the cover is center-cropped to a square, downscaled to
//! <=512px, and re-encoded PNG (<=4 MiB).
//! - **macOS** → `notify_rust` with `image_path` (it needs a file on disk, so
//! the cover is cached but NOT resized).
//! - **Windows** → not implemented (parity with Tauri).
//!
//! The whole thing is fire-and-forget: failures are logged, never surfaced, so
//! a missing portal or a slow CDN never blocks playback. The HTTP download +
//! image work run on `spawn_blocking` (a tokio runtime must be present — it is,
//! the app drives one).
use std::path::PathBuf;
/// Everything needed to render a track-change notification. The crate formats
/// the body + quality line itself so the output matches the Tauri notification
/// exactly, regardless of frontend.
#[derive(Debug, Clone, Default)]
pub struct NotificationMeta {
pub title: String,
pub artist: String,
pub album: String,
/// Bit depth (e.g. 16, 24). Drives the quality line.
pub bit_depth: Option<u32>,
/// Sample rate in kHz (e.g. 44.1, 96.0). Drives the quality line.
pub sample_rate: Option<f64>,
/// Album-art URL: http/https (downloaded + cached), `file://`, or
/// `asset://localhost/...` (resolved to a local path). `None` = no art.
pub art_url: Option<String>,
}
/// Format the quality line shown under the artist/album, identical to the Tauri
/// `v2_format_notification_quality`. Empty string = omit the line.
fn format_quality(bit_depth: Option<u32>, sample_rate: Option<f64>) -> String {
match (bit_depth, sample_rate) {
(Some(bits), Some(rate)) if bits >= 24 || rate > 48.0 => {
let rate_str = if rate.fract() == 0.0 {
format!("{}", rate as u32)
} else {
format!("{rate}")
};
format!("Hi-Res - {bits}-bit/{rate_str}kHz")
}
(Some(bits), Some(rate)) => {
let rate_str = if rate.fract() == 0.0 {
format!("{}", rate as u32)
} else {
format!("{rate}")
};
format!("CD Quality - {bits}-bit/{rate_str}kHz")
}
_ => String::new(),
}
}
/// Build the notification body: "artist · album" then a quality line.
/// `·` (middle dot) on macOS, `•` (bullet) elsewhere — matches Tauri.
fn build_body(meta: &NotificationMeta) -> String {
let separator = if cfg!(target_os = "macos") {
" \u{00b7} "
} else {
" \u{2022} "
};
let mut lines = Vec::new();
let mut line1 = Vec::new();
if !meta.artist.is_empty() {
line1.push(meta.artist.clone());
}
if !meta.album.is_empty() {
line1.push(meta.album.clone());
}
if !line1.is_empty() {
lines.push(line1.join(separator));
}
let quality = format_quality(meta.bit_depth, meta.sample_rate);
if !quality.is_empty() {
lines.push(quality);
}
lines.join("\n")
}
// --- artwork cache (Linux + macOS) ------------------------------------------
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn artwork_cache_dir() -> Result<PathBuf, String> {
let dir = dirs::cache_dir()
.ok_or_else(|| "Could not find cache directory".to_string())?
.join("qbz")
.join("artwork");
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create artwork cache dir: {e}"))?;
Ok(dir)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn resolve_local_artwork(url: &str) -> Option<PathBuf> {
if let Some(path) = url.strip_prefix("file://") {
return Some(PathBuf::from(path));
}
if let Some(path) = url.strip_prefix("asset://localhost/") {
let decoded = urlencoding::decode(path).ok()?;
return Some(PathBuf::from(decoded.into_owned()));
}
None
}
/// Shared blocking HTTP client (a fresh client per track leaks an fd → EMFILE
/// over a long session — same reasoning as the Tauri image cache).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn http_client() -> &'static reqwest::blocking::Client {
static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::blocking::Client::builder()
.pool_max_idle_per_host(2)
.build()
.expect("failed to build notification HTTP client")
})
}
/// Resolve `url` to a local image file: a `file://`/`asset://` URL maps
/// straight through, an http(s) URL is downloaded and cached by md5(url).
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn cache_artwork(url: &str) -> Result<PathBuf, String> {
use md5::{Digest, Md5};
use std::io::Write;
if let Some(local) = resolve_local_artwork(url) {
if local.exists() {
return Ok(local);
}
}
let mut hasher = Md5::new();
hasher.update(url.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let cache_path = artwork_cache_dir()?.join(format!("{hash}.jpg"));
if cache_path.exists() {
return Ok(cache_path);
}
let response = http_client()
.get(url)
.header("User-Agent", "Mozilla/5.0")
.timeout(std::time::Duration::from_secs(5))
.send()
.map_err(|e| format!("Failed to download artwork: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Failed to download artwork: HTTP {} (url: {})",
response.status(),
url.split('?').next().unwrap_or(url)
));
}
let bytes = response
.bytes()
.map_err(|e| format!("Failed to read artwork bytes: {e}"))?;
let mut file =
std::fs::File::create(&cache_path).map_err(|e| format!("Failed to create cache file: {e}"))?;
file.write_all(&bytes)
.map_err(|e| format!("Failed to write artwork cache: {e}"))?;
Ok(cache_path)
}
// --- Linux: portal icon bytes -----------------------------------------------
#[cfg(target_os = "linux")]
const PORTAL_ICON_MAX_EDGE: u32 = 512;
#[cfg(target_os = "linux")]
const PORTAL_ICON_MAX_BYTES: usize = 4 * 1024 * 1024;
/// Center-crop to a square, downscale to <=512px, re-encode PNG. Mirrors the
/// Tauri `v2_prepare_notification_icon_bytes`.
#[cfg(target_os = "linux")]
fn prepare_icon_bytes(path: &std::path::Path) -> Result<Vec<u8>, String> {
use std::io::Cursor;
let source = image::open(path).map_err(|e| format!("Failed to decode artwork {path:?}: {e}"))?;
let (w, h) = (source.width(), source.height());
let square = if w == h {
source
} else {
let edge = w.min(h);
source.crop_imm((w - edge) / 2, (h - edge) / 2, edge, edge)
};
let icon = if square.width() > PORTAL_ICON_MAX_EDGE {
square.resize_exact(
PORTAL_ICON_MAX_EDGE,
PORTAL_ICON_MAX_EDGE,
image::imageops::FilterType::Lanczos3,
)
} else {
square
};
let mut buf = Cursor::new(Vec::new());
icon.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode notification PNG: {e}"))?;
let bytes = buf.into_inner();
if bytes.len() > PORTAL_ICON_MAX_BYTES {
return Err(format!(
"Notification icon too large after normalization: {} bytes (max {PORTAL_ICON_MAX_BYTES})",
bytes.len()
));
}
Ok(bytes)
}
// --- public entry point -----------------------------------------------------
/// Show a track-change notification. Fire-and-forget: every failure is logged,
/// none propagated. Must be called from within a tokio runtime (it uses
/// `spawn_blocking` for the HTTP/image work).
pub async fn show_track_notification(meta: NotificationMeta) {
let body = build_body(&meta);
log::info!(
"[notify] track notification: {} by {}",
meta.title,
meta.artist
);
#[cfg(target_os = "linux")]
{
use ashpd::desktop::notification::{Notification as PortalNotification, NotificationProxy};
use ashpd::desktop::Icon;
let mut notification =
PortalNotification::new(&meta.title).body(Some(body.as_str()));
if let Some(url) = meta.art_url.clone() {
let prepared = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let path = cache_artwork(&url)?;
prepare_icon_bytes(&path)
})
.await;
match prepared {
Ok(Ok(bytes)) => {
log::debug!("[notify] artwork prepared: {} bytes", bytes.len());
notification = notification.icon(Icon::Bytes(bytes));
}
Ok(Err(e)) => log::warn!("[notify] could not prepare artwork: {e}"),
Err(e) => log::warn!("[notify] artwork task failed: {e}"),
}
}
match NotificationProxy::new().await {
Ok(proxy) => {
if let Err(e) = proxy
.add_notification("track-now-playing", notification)
.await
{
log::warn!("[notify] XDG portal add_notification failed: {e}");
}
}
Err(e) => log::warn!("[notify] XDG notification portal unavailable: {e}"),
}
}
#[cfg(target_os = "macos")]
{
let _ = tokio::task::spawn_blocking(move || {
let _ = notify_rust::set_application("com.blitzfc.qbz");
let artwork_path = meta.art_url.as_deref().and_then(|url| match cache_artwork(url) {
Ok(path) => Some(path),
Err(e) => {
log::debug!("[notify] could not cache artwork: {e}");
None
}
});
let mut notification = notify_rust::Notification::new();
notification.summary(&meta.title).body(&body);
if let Some(path) = artwork_path.as_ref().and_then(|p| p.to_str()) {
notification.image_path(path);
}
if let Err(e) = notification.show() {
log::warn!("[notify] macOS notification failed: {e}");
}
})
.await;
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = body;
log::info!("[notify] desktop notifications not implemented on this platform");
}
}
+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);