From 789b0262b68326d32a547bbcb56bc4064f317874 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:29:04 -0600 Subject: [PATCH 1/2] feat(tray): live tooltip and scroll/middle-click controls on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicate the Plasma media plasmoid tray behavior on Linux SNI panels: - Tooltip now shows live track title, "by Artist", album line, and "Middle-click to play/pause" + "Scroll to adjust volume" hints. - Middle-click (SecondaryActivate) emits tray:play_pause instead of toggling the window — the window toggle stays on left-click. - Mouse wheel (Scroll) emits tray:volume_delta with normalised ticks (~120 SNI units per notch -> +/-5% volume per tick), wired to the existing handleVolumeChange path on the frontend so QConnect remote renderers and the local player both respond. Architecture: ksni 0.3.4's blocking Handle is now stored as LinuxTrayHandle in Tauri state. v2_set_media_metadata pushes track fields into the tooltip, and the playback poll loop mirrors the play/pause flag so the hint flips while the panel popup is open. cc --- src-tauri/src/commands_v2/playback.rs | 12 ++ src-tauri/src/lib.rs | 21 ++++ src-tauri/src/tray.rs | 10 +- src-tauri/src/tray_linux_ksni.rs | 174 ++++++++++++++++++++++---- src/routes/+page.svelte | 15 +++ 5 files changed, 209 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/commands_v2/playback.rs b/src-tauri/src/commands_v2/playback.rs index e45e17c9f..6fffb00b5 100644 --- a/src-tauri/src/commands_v2/playback.rs +++ b/src-tauri/src/commands_v2/playback.rs @@ -418,6 +418,7 @@ pub async fn v2_set_media_metadata( duration_secs: Option, cover_url: Option, app_state: State<'_, AppState>, + app_handle: tauri::AppHandle, ) -> Result<(), RuntimeError> { log::info!("[V2] Command: set_media_metadata - {} by {}", title, artist); crate::update_media_controls_metadata( @@ -428,6 +429,17 @@ pub async fn v2_set_media_metadata( duration_secs, cover_url, ); + + // Push the same metadata into the Linux SNI tooltip so panel hover shows + // the live track info instead of the static "QBZ — Music Player" string. + #[cfg(target_os = "linux")] + { + use tauri::Manager; + if let Some(tray) = app_handle.try_state::() { + tray.set_track(title.clone(), artist.clone(), album.clone()); + } + } + let _ = app_handle; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60505e175..7e347a1e1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1211,6 +1211,27 @@ pub fn run() { } } + // Mirror playback state into the Linux SNI tooltip so the + // tray hover hint flips between "Middle-click to pause" + // and "Middle-click to play". Track metadata is pushed + // separately via v2_set_media_metadata; here we only + // touch the play/pause flag and clear on track-stop. + // `should_update_mpris` already debounces to actual + // state transitions (plus position ticks while playing), + // so the cost is bounded. + #[cfg(target_os = "linux")] + if should_update_mpris { + if let Some(tray) = app_handle + .try_state::() + { + if track_id == 0 { + tray.clear_track(); + } else { + tray.set_playing(is_playing); + } + } + } + // Idle inhibit: prevent screen/suspend while playing (Linux only, via XDG Portal) #[cfg(target_os = "linux")] if is_playing != last_is_playing || track_cleared { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 5672fdf7f..1ce258d95 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -19,6 +19,8 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, }; +#[cfg(target_os = "linux")] +use tauri::Manager; use tauri::AppHandle; #[cfg(not(target_os = "linux"))] @@ -94,10 +96,16 @@ fn load_tray_icon() -> Image<'static> { /// Initialize the system tray icon. Dispatches to the platform-specific /// backend: ksni on Linux (see `tray_linux_ksni`), Tauri's built-in tray on /// macOS. Falls back to a clean error on unknown targets. +/// +/// On Linux this also installs the live `LinuxTrayHandle` into Tauri state +/// so the rest of the backend can push live tooltip updates as the player +/// state changes. pub fn init_tray(app: &AppHandle) -> Result<(), Box> { #[cfg(target_os = "linux")] { - return tray_linux_ksni::init(app); + let handle = tray_linux_ksni::init(app)?; + app.manage(handle); + return Ok(()); } #[cfg(not(target_os = "linux"))] diff --git a/src-tauri/src/tray_linux_ksni.rs b/src-tauri/src/tray_linux_ksni.rs index ce67298d8..1716be44f 100644 --- a/src-tauri/src/tray_linux_ksni.rs +++ b/src-tauri/src/tray_linux_ksni.rs @@ -5,20 +5,27 @@ //! always shows the menu. See issue #310. //! //! `ksni` is a pure-Rust SNI implementation that exposes the full protocol -//! including `Activate` (left-click), `SecondaryActivate` (middle-click) and -//! `ContextMenu` (right-click → menu). Same SNI compatibility as the old -//! tray (both need an SNI-aware panel: KDE native, GNOME w/ extension, -//! XFCE/Cinnamon/MATE/Budgie, wlroots tray widgets). +//! including `Activate` (left-click), `SecondaryActivate` (middle-click), +//! `Scroll` (wheel) and `ContextMenu` (right-click → menu). Same SNI +//! compatibility as the old tray (both need an SNI-aware panel: KDE native, +//! GNOME w/ extension, XFCE/Cinnamon/MATE/Budgie, wlroots tray widgets). //! //! This module runs a ksni `TrayService` in a dedicated background thread //! (via the blocking API). All callbacks receive `&mut QbzTray` and close //! over the Tauri `AppHandle` to emit events and manipulate the main window. +//! +//! The returned `LinuxTrayHandle` is stored in Tauri state so the rest of +//! the backend (metadata setter, playback poll loop) can push live updates +//! into the SNI tooltip — replicating the rich tooltip the Plasma media +//! plasmoid shows on hover. + +use std::sync::{Arc, Mutex}; use image::GenericImageView; use ksni::{ blocking::TrayMethods, menu::StandardItem, - Icon, MenuItem, ToolTip, Tray, + Icon, MenuItem, Orientation, ToolTip, Tray, }; use tauri::{AppHandle, Emitter, Manager}; @@ -106,9 +113,19 @@ fn decode_tray_icons() -> Result, String> { sources.iter().map(|b| decode_pixmap(b)).collect() } +/// Now-playing info shown in the tooltip. Cleared when no track is loaded. +#[derive(Clone, Debug, Default)] +struct NowPlaying { + title: String, + artist: String, + album: String, +} + struct QbzTray { app: AppHandle, icons: Vec, + now_playing: Option, + is_playing: bool, } impl QbzTray { @@ -135,6 +152,12 @@ impl QbzTray { let _ = window.emit(event, ()); } } + + fn emit_payload(&self, event: &str, payload: S) { + if let Some(window) = self.app.get_webview_window("main") { + let _ = window.emit(event, payload); + } + } } impl Tray for QbzTray { @@ -160,26 +183,76 @@ impl Tray for QbzTray { } fn tool_tip(&self) -> ToolTip { + // Plasma's media plasmoid renders a multi-line tooltip with the track + // title bolded on top and secondary lines below. SNI's ToolTip has a + // (title, description) split that maps onto the same visual: panels + // typically render `title` bold and `description` as wrapping body + // text that respects '\n'. + let (title, description) = match &self.now_playing { + Some(np) => { + let header = if np.title.is_empty() { + "QBZ".to_string() + } else { + np.title.clone() + }; + let mut lines: Vec = Vec::with_capacity(3); + if !np.artist.is_empty() { + lines.push(format!("by {}", np.artist)); + } + if !np.album.is_empty() { + lines.push(np.album.clone()); + } + lines.push(if self.is_playing { + "Middle-click to pause".to_string() + } else { + "Middle-click to play".to_string() + }); + lines.push("Scroll to adjust volume".to_string()); + (header, lines.join("\n")) + } + None => ( + "QBZ".to_string(), + "Music Player\nNothing playing".to_string(), + ), + }; ToolTip { - title: "QBZ".into(), - description: "Music Player".into(), + title, + description, icon_name: String::new(), icon_pixmap: vec![], } } - /// Primary click (left) — the headline feature of switching to ksni. + /// Primary click (left) — toggle main window visibility. fn activate(&mut self, _x: i32, _y: i32) { log::info!("[tray] primary activate (left click)"); self.toggle_window(); } - /// Secondary click (middle) — same as left for symmetry; users who were - /// middle-clicking on the old build to open the menu still get a useful - /// behavior. + /// Secondary click (middle) — play/pause, mirroring the Plasma media + /// plasmoid behaviour. When nothing is loaded the frontend simply ignores + /// the toggle, so emitting unconditionally is safe. fn secondary_activate(&mut self, _x: i32, _y: i32) { - log::info!("[tray] secondary activate (middle click)"); - self.toggle_window(); + log::info!("[tray] secondary activate (middle click) -> play/pause"); + self.emit_to_main("tray:play_pause"); + } + + /// Mouse wheel — adjust volume in 5%-per-notch steps. Most panels (KDE + /// Plasma, GNOME Shell appindicator) report ±120 per wheel notch + /// following the X11/wayland convention; touch-pad scrolls produce + /// smaller fractional deltas. We normalise by dividing by 120 and fall + /// back to `signum()` so very small deltas still register one tick. + fn scroll(&mut self, delta: i32, orientation: Orientation) { + if !matches!(orientation, Orientation::Vertical) || delta == 0 { + return; + } + let mut ticks = delta / 120; + if ticks == 0 { + ticks = delta.signum(); + } + log::debug!("[tray] scroll delta={} ticks={}", delta, ticks); + // Positive ticks = wheel-up = volume up, matching Plasma plasmoid. + self.emit_payload("tray:volume_delta", ticks); } fn menu(&self) -> Vec> { @@ -223,17 +296,76 @@ impl Tray for QbzTray { } } +/// Cross-thread handle to the live ksni tray. Cloneable; updates funnel +/// through `Handle::update`, which schedules a property-changed signal so +/// SNI panels redraw the tooltip on the next hover. +/// +/// When the tray is disabled or failed to start, the inner option stays +/// `None` and every mutator becomes a no-op. +#[derive(Clone)] +pub struct LinuxTrayHandle { + inner: Arc>>>, +} + +impl LinuxTrayHandle { + pub fn empty() -> Self { + Self { + inner: Arc::new(Mutex::new(None)), + } + } + + fn install(&self, handle: ksni::blocking::Handle) { + if let Ok(mut guard) = self.inner.lock() { + *guard = Some(handle); + } + } + + fn with(&self, f: F) { + let guard = match self.inner.lock() { + Ok(g) => g, + Err(_) => return, + }; + if let Some(handle) = guard.as_ref() { + handle.update(f); + } + } + + pub fn set_track(&self, title: String, artist: String, album: String) { + self.with(move |tray| { + tray.now_playing = Some(NowPlaying { + title, + artist, + album, + }); + }); + } + + pub fn clear_track(&self) { + self.with(|tray| { + tray.now_playing = None; + tray.is_playing = false; + }); + } + + pub fn set_playing(&self, is_playing: bool) { + self.with(move |tray| { + tray.is_playing = is_playing; + }); + } +} + /// Initialize the Linux ksni tray service. Spawns a background thread that -/// owns the SNI service; the returned Handle is intentionally leaked because -/// the tray is a singleton that lives for the app's lifetime (dropping the -/// handle would tear down the tray). -pub fn init(app: &AppHandle) -> Result<(), Box> { +/// owns the SNI service and returns a cloneable handle so live tooltip +/// updates can be pushed in from the rest of the backend. +pub fn init(app: &AppHandle) -> Result> { log::info!("Initializing ksni tray (Linux, SNI primary-activate enabled)"); let icons = decode_tray_icons()?; let tray = QbzTray { app: app.clone(), icons, + now_playing: None, + is_playing: false, }; // Flatpak requires disabling the well-known DBus name because the sandbox @@ -245,11 +377,9 @@ pub fn init(app: &AppHandle) -> Result<(), Box> { tray.spawn()? }; - // Keep the service alive for the app lifetime. We don't need to update the - // tray dynamically yet; if we do in the future (e.g., change icon when - // playing), we'd store this handle in AppState instead. - std::mem::forget(handle); + let live = LinuxTrayHandle::empty(); + live.install(handle); log::info!("ksni tray initialized"); - Ok(()) + Ok(live) } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1de02be00..f408c03e6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5210,6 +5210,7 @@ let unlistenTrayPlayPause: UnlistenFn | null = null; let unlistenTrayNext: UnlistenFn | null = null; let unlistenTrayPrevious: UnlistenFn | null = null; + let unlistenTrayVolumeDelta: UnlistenFn | null = null; let unlistenMediaControls: UnlistenFn | null = null; let unlistenLinkResolved: UnlistenFn | null = null; let unlistenQconnectEvent: UnlistenFn | null = null; @@ -5242,6 +5243,19 @@ if (disposed) { unlisten3(); return; } unlistenTrayPrevious = unlisten3; + // Tray scroll wheel: backend emits a normalised tick count (positive + // = wheel-up = volume up, negative = wheel-down). Each tick = 5%. + const unlistenVol = await listen('tray:volume_delta', async (event) => { + const ticks = typeof event.payload === 'number' ? event.payload : 0; + if (!ticks) return; + const delta = ticks * 5; + const next = Math.max(0, Math.min(100, Math.round(volume + delta))); + if (next === volume) return; + await handleVolumeChange(next); + }); + if (disposed) { unlistenVol(); return; } + unlistenTrayVolumeDelta = unlistenVol; + const unlisten4 = await listen('media:control', async (event) => { const payload = event.payload as MediaControlPayload; if (!payload?.action) return; @@ -5446,6 +5460,7 @@ unlistenTrayPlayPause?.(); unlistenTrayNext?.(); unlistenTrayPrevious?.(); + unlistenTrayVolumeDelta?.(); unlistenMediaControls?.(); unlistenLinkResolved?.(); unlistenQconnectEvent?.(); From 7714d832125fcd157fac766a1ed249072fb6a022 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:59:25 -0600 Subject: [PATCH 2/2] fix(tray): apply tooltip updates from a dedicated thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ksni 0.3.4 with the "blocking" feature wraps every Handle::update in Runtime::block_on, which panics when called from inside an existing tokio runtime — exactly where Tauri commands and our async polling loop run. The panic was being swallowed by the runtime, so set_track and set_playing looked like no-ops and the SNI tooltip kept rendering the static "QBZ / Music Player / Nothing playing" fallback. Move all updates onto a dedicated std::thread that owns the ksni blocking handle and processes mutations from a std::sync::mpsc channel, fully outside any tokio context. cc --- src-tauri/src/tray_linux_ksni.rs | 113 +++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/tray_linux_ksni.rs b/src-tauri/src/tray_linux_ksni.rs index 1716be44f..06414e268 100644 --- a/src-tauri/src/tray_linux_ksni.rs +++ b/src-tauri/src/tray_linux_ksni.rs @@ -19,7 +19,10 @@ //! into the SNI tooltip — replicating the rich tooltip the Plasma media //! plasmoid shows on hover. -use std::sync::{Arc, Mutex}; +use std::sync::{ + mpsc::{self, Sender}, + Arc, Mutex, +}; use image::GenericImageView; use ksni::{ @@ -296,61 +299,117 @@ impl Tray for QbzTray { } } -/// Cross-thread handle to the live ksni tray. Cloneable; updates funnel -/// through `Handle::update`, which schedules a property-changed signal so -/// SNI panels redraw the tooltip on the next hover. +/// Updates dispatched from the rest of the backend into the ksni worker. /// -/// When the tray is disabled or failed to start, the inner option stays -/// `None` and every mutator becomes a no-op. +/// We cannot call `ksni::blocking::Handle::update` directly from a Tauri +/// command or from the playback poll loop — `ksni` 0.3 with `feature = +/// "blocking"` wraps every update in `Runtime::block_on`, which panics when +/// invoked from within an existing tokio runtime (which is exactly what +/// Tauri commands and `tauri::async_runtime::spawn` tasks run on). The +/// panic is silently swallowed by the runtime, so updates appear to +/// succeed but the tooltip never changes. +/// +/// To avoid the nested-runtime issue we serialise updates over a +/// `std::sync::mpsc` channel and apply them from a dedicated `std::thread` +/// that lives entirely outside any tokio context. +enum TrayUpdate { + SetTrack { + title: String, + artist: String, + album: String, + }, + ClearTrack, + SetPlaying(bool), +} + +/// Cross-thread handle to the live ksni tray. Cloneable; mutators just +/// forward to the worker thread, so they are safe to call from any +/// async context. When the tray is disabled or failed to start, the +/// inner sender stays `None` and every mutator becomes a no-op. #[derive(Clone)] pub struct LinuxTrayHandle { - inner: Arc>>>, + sender: Arc>>>, } impl LinuxTrayHandle { pub fn empty() -> Self { Self { - inner: Arc::new(Mutex::new(None)), + sender: Arc::new(Mutex::new(None)), } } fn install(&self, handle: ksni::blocking::Handle) { - if let Ok(mut guard) = self.inner.lock() { - *guard = Some(handle); + let (tx, rx) = mpsc::channel::(); + std::thread::Builder::new() + .name("qbz-tray-updater".into()) + .spawn(move || { + while let Ok(msg) = rx.recv() { + match msg { + TrayUpdate::SetTrack { + title, + artist, + album, + } => { + log::debug!( + "[tray] tooltip update -> {} / {} / {}", + title, + artist, + album + ); + handle.update(move |tray| { + tray.now_playing = Some(NowPlaying { + title, + artist, + album, + }); + }); + } + TrayUpdate::ClearTrack => { + log::debug!("[tray] tooltip cleared"); + handle.update(|tray| { + tray.now_playing = None; + tray.is_playing = false; + }); + } + TrayUpdate::SetPlaying(is_playing) => { + handle.update(move |tray| { + tray.is_playing = is_playing; + }); + } + } + } + log::debug!("[tray] updater thread exiting"); + }) + .expect("spawn tray updater thread"); + if let Ok(mut guard) = self.sender.lock() { + *guard = Some(tx); } } - fn with(&self, f: F) { - let guard = match self.inner.lock() { + fn send(&self, msg: TrayUpdate) { + let guard = match self.sender.lock() { Ok(g) => g, Err(_) => return, }; - if let Some(handle) = guard.as_ref() { - handle.update(f); + if let Some(tx) = guard.as_ref() { + let _ = tx.send(msg); } } pub fn set_track(&self, title: String, artist: String, album: String) { - self.with(move |tray| { - tray.now_playing = Some(NowPlaying { - title, - artist, - album, - }); + self.send(TrayUpdate::SetTrack { + title, + artist, + album, }); } pub fn clear_track(&self) { - self.with(|tray| { - tray.now_playing = None; - tray.is_playing = false; - }); + self.send(TrayUpdate::ClearTrack); } pub fn set_playing(&self, is_playing: bool) { - self.with(move |tray| { - tray.is_playing = is_playing; - }); + self.send(TrayUpdate::SetPlaying(is_playing)); } }