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:
5 files changed
+414
-3
No files matched your search
Generated
+66
-2
@@ -3781,6 +3781,18 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -4026,6 +4038,20 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
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]]
|
[[package]]
|
||||||
name = "num"
|
name = "num"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -5286,11 +5312,18 @@ dependencies = [
|
|||||||
name = "qbz-media-controls"
|
name = "qbz-media-controls"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ashpd 0.13.11",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
"dirs 6.0.0",
|
||||||
|
"image",
|
||||||
"log",
|
"log",
|
||||||
|
"md-5",
|
||||||
"mpris-server",
|
"mpris-server",
|
||||||
|
"notify-rust",
|
||||||
|
"reqwest",
|
||||||
"souvlaki",
|
"souvlaki",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"urlencoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5524,6 +5557,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -5883,6 +5925,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -7155,6 +7198,18 @@ dependencies = [
|
|||||||
"xattr",
|
"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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.26.0"
|
version = "3.26.0"
|
||||||
@@ -8159,7 +8214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8603,6 +8658,15 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -9271,7 +9335,7 @@ version = "5.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9"
|
checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quick-xml",
|
"quick-xml 0.39.4",
|
||||||
"serde",
|
"serde",
|
||||||
"zbus_names 4.3.2",
|
"zbus_names 4.3.2",
|
||||||
"zvariant 5.11.0",
|
"zvariant 5.11.0",
|
||||||
|
|||||||
@@ -8,6 +8,20 @@ description = "Frontend-agnostic system media controls: MPRIS (mpris-server) on
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
log = { workspace = true }
|
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
|
# Linux: mpris-server publishes the full MPRIS interface INCLUDING the
|
||||||
# org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry),
|
# org.mpris.MediaPlayer2.DesktopEntry property (RootInterface::desktop_entry),
|
||||||
@@ -16,10 +30,18 @@ log = { workspace = true }
|
|||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
mpris-server = "0.8"
|
mpris-server = "0.8"
|
||||||
async-channel = "2"
|
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 —
|
# macOS (MediaRemote / MPNowPlayingInfoCenter) + Windows (SMTC) via souvlaki —
|
||||||
# there is no DesktopEntry concept there (macOS keys the icon off the app
|
# there is no DesktopEntry concept there (macOS keys the icon off the app
|
||||||
# bundle), so souvlaki stays the right tool off-Linux.
|
# bundle), so souvlaki stays the right tool off-Linux.
|
||||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||||
souvlaki = "0.8"
|
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"
|
||||||
@@ -15,6 +15,9 @@ use std::sync::Arc;
|
|||||||
mod types;
|
mod types;
|
||||||
pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
pub use types::{MediaEvent, MediaIntegration, PlaybackStatus, TrackMeta};
|
||||||
|
|
||||||
|
pub mod notify;
|
||||||
|
pub use notify::{show_track_notification, NotificationMeta};
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux;
|
mod linux;
|
||||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -515,10 +515,18 @@ fn fmt_remaining(position: u64, duration: u64) -> String {
|
|||||||
/// Push the now-playing values for the current queue track onto
|
/// Push the now-playing values for the current queue track onto
|
||||||
/// `NowPlayingState`. Called when a new track starts so the song card
|
/// `NowPlayingState`. Called when a new track starts so the song card
|
||||||
/// updates immediately (the poll loop only refreshes position/progress).
|
/// 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>) {
|
pub(crate) async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
|
||||||
let state = runtime.core().get_queue_state().await;
|
let state = runtime.core().get_queue_state().await;
|
||||||
let Some(track) = state.current_track else {
|
let Some(track) = state.current_track else {
|
||||||
// No current track → clear the tray tooltip (Linux) + stop media controls.
|
// 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() {
|
if let Some(t) = crate::tray::handle() {
|
||||||
t.clear_track();
|
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 _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
let np = w.global::<NowPlayingState>();
|
let np = w.global::<NowPlayingState>();
|
||||||
np.set_has_track(true);
|
np.set_has_track(true);
|
||||||
|
|||||||
Reference in new issue
Block a user