feat(tray): live tooltip and scroll/middle-click controls on Linux
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
This commit is contained in:
5 files changed
+209
-23
No files matched your search
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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<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 +152,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 +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<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 +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<Mutex<Option<ksni::blocking::Handle<QbzTray>>>>,
|
||||
}
|
||||
|
||||
impl LinuxTrayHandle {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn install(&self, handle: ksni::blocking::Handle<QbzTray>) {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
*guard = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
fn with<F: FnOnce(&mut QbzTray)>(&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<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 +377,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)
|
||||
}
|
||||
Reference in new issue
Block a user