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",
|
||||
]
|
||||
|
||||
[[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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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
|
||||
/// `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);
|
||||
|
||||
Reference in new issue
Block a user