diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 7f040fc72..0203bde1d 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -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", diff --git a/crates/qbz-media-controls/Cargo.toml b/crates/qbz-media-controls/Cargo.toml index 76c27e326..71970569e 100644 --- a/crates/qbz-media-controls/Cargo.toml +++ b/crates/qbz-media-controls/Cargo.toml @@ -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" diff --git a/crates/qbz-media-controls/src/lib.rs b/crates/qbz-media-controls/src/lib.rs index 332a5a67a..16973ba95 100644 --- a/crates/qbz-media-controls/src/lib.rs +++ b/crates/qbz-media-controls/src/lib.rs @@ -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"))] diff --git a/crates/qbz-media-controls/src/notify.rs b/crates/qbz-media-controls/src/notify.rs new file mode 100644 index 000000000..708e34dd3 --- /dev/null +++ b/crates/qbz-media-controls/src/notify.rs @@ -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, + /// Sample rate in kHz (e.g. 44.1, 96.0). Drives the quality line. + pub sample_rate: Option, + /// Album-art URL: http/https (downloaded + cached), `file://`, or + /// `asset://localhost/...` (resolved to a local path). `None` = no art. + pub art_url: Option, +} + +/// 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, sample_rate: Option) -> 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 { + 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 { + 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 = 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 { + 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, 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, 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"); + } +} diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 91682c5ca..fae1d4203 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -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) { 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::(); np.set_has_track(true);