Merge feature/internal/tray-rich-tooltip into pre-release

This commit is contained in:
vicrodh committed 2026-04-28 16:22:31 -06:00
commit d3ccf40ddd
5 files changed
+268 -23

No files matched your search

+12
View File
@@ -418,6 +418,7 @@ pub async fn v2_set_media_metadata(
duration_secs: Option<u64>,
cover_url: Option<String>,
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::<crate::tray_linux_ksni::LinuxTrayHandle>() {
tray.set_track(title.clone(), artist.clone(), album.clone());
}
}
let _ = app_handle;
Ok(())
}
+21
View File
@@ -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::<crate::tray_linux_ksni::LinuxTrayHandle>()
{
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 {
+9 -1
View File
@@ -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<dyn std::error::Error>> {
#[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"))]
+211 -22
View File
@@ -5,20 +5,30 @@
//! 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::{
mpsc::{self, Sender},
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 +116,19 @@ fn decode_tray_icons() -> Result<Vec<Icon>, 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<Icon>,
now_playing: Option<NowPlaying>,
is_playing: bool,
}
impl QbzTray {
@@ -135,6 +155,12 @@ impl QbzTray {
let _ = window.emit(event, ());
}
}
fn emit_payload<S: serde::Serialize + Clone>(&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 +186,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<String> = 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<MenuItem<Self>> {
@@ -223,17 +299,132 @@ impl Tray for QbzTray {
}
}
/// Updates dispatched from the rest of the backend into the ksni worker.
///
/// 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 {
sender: Arc<Mutex<Option<Sender<TrayUpdate>>>>,
}
impl LinuxTrayHandle {
pub fn empty() -> Self {
Self {
sender: Arc::new(Mutex::new(None)),
}
}
fn install(&self, handle: ksni::blocking::Handle<QbzTray>) {
let (tx, rx) = mpsc::channel::<TrayUpdate>();
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 send(&self, msg: TrayUpdate) {
let guard = match self.sender.lock() {
Ok(g) => g,
Err(_) => return,
};
if let Some(tx) = guard.as_ref() {
let _ = tx.send(msg);
}
}
pub fn set_track(&self, title: String, artist: String, album: String) {
self.send(TrayUpdate::SetTrack {
title,
artist,
album,
});
}
pub fn clear_track(&self) {
self.send(TrayUpdate::ClearTrack);
}
pub fn set_playing(&self, is_playing: bool) {
self.send(TrayUpdate::SetPlaying(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<dyn std::error::Error>> {
/// 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<LinuxTrayHandle, Box<dyn std::error::Error>> {
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 +436,9 @@ pub fn init(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
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)
}
+15
View File
@@ -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<number>('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?.();