use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem}; use tauri::tray::{MouseButton, TrayIcon, TrayIconBuilder, TrayIconEvent}; use tauri::{Emitter, Manager}; #[cfg(not(target_os = "windows"))] use tauri::tray::MouseButtonState; use crate::tray_runtime::{ tray_state_icon, TrayMenuItems, TrayMenuItemsState, TrayMenuLabels, TrayMenuLabelsState, TrayPlaybackState, TrayState, TrayTooltip, }; use super::super::ui::{PAUSE_RENDERING_JS, RESUME_RENDERING_JS}; use tauri::image::Image; /// Debug builds: mirror the default app icon horizontally so the tray differs from release. fn app_tray_icon(app: &tauri::AppHandle) -> Image<'static> { let icon = app.default_window_icon().expect("default window icon"); #[cfg(debug_assertions)] { flip_image_horizontal(icon) } #[cfg(not(debug_assertions))] { icon.clone().to_owned() } } #[cfg(debug_assertions)] fn flip_image_horizontal(icon: &Image<'_>) -> Image<'static> { let width = icon.width(); let height = icon.height(); let mut rgba = icon.rgba().to_vec(); flip_rgba_horizontal(&mut rgba, width, height); Image::new_owned(rgba, width, height) } #[cfg(debug_assertions)] fn flip_rgba_horizontal(rgba: &mut [u8], width: u32, height: u32) { let w = width as usize; let h = height as usize; if w == 0 || h == 0 || rgba.len() < w * h * 4 { return; } for y in 0..h { let row = y * w * 4; for x in 0..w / 2 { let l = row + x * 4; let r = row + (w - 1 - x) * 4; let left = [ rgba[l], rgba[l + 1], rgba[l + 2], rgba[l + 3], ]; let right = [ rgba[r], rgba[r + 1], rgba[r + 2], rgba[r + 3], ]; rgba[l..l + 4].copy_from_slice(&right); rgba[r..r + 4].copy_from_slice(&left); } } } /// Stable tray-icon id. Without a fixed id, `TrayIconBuilder::new()` assigns a /// fresh id on every rebuild; on KDE (StatusNotifierItem) each new id registers /// a new item, and KDE keeps the stale ones in its "hidden icons" list — so /// toggling the tray off/on piled up duplicate entries (Asra, 1.47-RC4). A /// constant id makes every rebuild reuse the same item. const TRAY_ICON_ID: &str = "psysonic-tray"; pub(crate) fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { let labels = app .try_state::() .map(|s| s.lock().unwrap().clone()) .unwrap_or_default(); let play_pause = MenuItemBuilder::with_id("play_pause", &labels.play_pause).build(app)?; let next = MenuItemBuilder::with_id("next", &labels.next).build(app)?; let previous = MenuItemBuilder::with_id("previous", &labels.previous).build(app)?; let sep1 = PredefinedMenuItem::separator(app)?; let show_hide = MenuItemBuilder::with_id("show_hide", &labels.show_hide).build(app)?; let sep2 = PredefinedMenuItem::separator(app)?; let quit = MenuItemBuilder::with_id("quit", &labels.quit).build(app)?; let cached_tooltip = app .try_state::() .and_then(|s| { let g = s.lock().ok()?; if g.is_empty() { None } else { Some(g.clone()) } }) .unwrap_or_else(|| "Psysonic".to_string()); #[cfg(any(target_os = "windows", target_os = "linux"))] let playback_state = app .try_state::() .map(|s| s.0.lock().unwrap().clone()) .unwrap_or_else(|| "stop".to_string()); #[cfg(target_os = "windows")] let tooltip_with_icon = format!("{} {}", tray_state_icon(&playback_state), cached_tooltip); // Linux/AppIndicator has no hover tooltip; surface the now-playing track as // a disabled menu entry at the top instead. The label is updated by // `set_tray_tooltip` on every track change. #[cfg(target_os = "linux")] let (now_playing, sep_now_playing) = { let icon = tray_state_icon(&playback_state); let label = if cached_tooltip == "Psysonic" { format!("{icon} {}", labels.nothing_playing) } else { format!("{icon} {cached_tooltip}") }; let item = MenuItemBuilder::with_id("now_playing", &label) .enabled(false) .build(app)?; (item, PredefinedMenuItem::separator(app)?) }; #[cfg(target_os = "linux")] let menu_builder = MenuBuilder::new(app) .item(&now_playing) .item(&sep_now_playing); #[cfg(not(target_os = "linux"))] let menu_builder = MenuBuilder::new(app); let menu = menu_builder .item(&play_pause) .item(&previous) .item(&next) .item(&sep1) .item(&show_hide) .item(&sep2) .item(&quit) .build()?; // Persist handles so set_tray_menu_labels and set_tray_tooltip can update // them without rebuilding the whole tray icon. if let Some(state) = app.try_state::() { *state.lock().unwrap() = Some(TrayMenuItems { play_pause: play_pause.clone(), next: next.clone(), previous: previous.clone(), show_hide: show_hide.clone(), quit: quit.clone(), #[cfg(target_os = "linux")] now_playing: Some(now_playing.clone()), #[cfg(not(target_os = "linux"))] now_playing: None, }); } #[cfg(target_os = "windows")] let tray_builder = TrayIconBuilder::with_id(TRAY_ICON_ID) .icon(app_tray_icon(app)) .menu(&menu) .tooltip(&tooltip_with_icon) // tray-icon defaults to opening the context menu on every WM_LBUTTONUP when this is true. // A left double-click emits Down, Up, DoubleClick, Up — the final Up re-opens the menu right // after we hide the window from DoubleClick. We only use left double-click for show/hide // (see on_tray_icon_event); keep the menu on right-click like typical Windows tray apps. .show_menu_on_left_click(false); #[cfg(not(target_os = "windows"))] let tray_builder = TrayIconBuilder::with_id(TRAY_ICON_ID) .icon(app_tray_icon(app)) .menu(&menu) .tooltip(&cached_tooltip); tray_builder .on_menu_event(|app, event| match event.id.as_ref() { "play_pause" => { let _ = app.emit("tray:play-pause", ()); } "next" => { let _ = app.emit("tray:next", ()); } "previous" => { let _ = app.emit("tray:previous", ()); } "show_hide" => { if let Some(win) = app.get_webview_window("main") { if win.is_visible().unwrap_or(false) { let _ = win.eval(PAUSE_RENDERING_JS); let _ = win.hide(); } else { let _ = win.eval(RESUME_RENDERING_JS); let _ = win.show(); let _ = win.set_focus(); } } } "quit" => { let _ = app.emit("app:force-quit", ()); } _ => {} }) .on_tray_icon_event(|tray, event| { // Windows fires a Click on *every* half of a double-click, so a // double-click would toggle the window visibility twice and end up // back where it started (the bug #cucadmuh reported). Switch to the // Windows-only DoubleClick event there and ignore single clicks; // that matches the standard Windows tray convention (Discord, etc). #[cfg(target_os = "windows")] let should_toggle = matches!( event, TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } ); #[cfg(not(target_os = "windows"))] let should_toggle = matches!( event, TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } ); if should_toggle { let app = tray.app_handle(); if let Some(win) = app.get_webview_window("main") { if win.is_visible().unwrap_or(false) { let _ = win.eval(PAUSE_RENDERING_JS); let _ = win.hide(); } else { let _ = win.eval(RESUME_RENDERING_JS); let _ = win.show(); let _ = win.set_focus(); } } } }) .build(app) } /// Creates the tray icon, or `None` if the OS cannot host one. /// /// On Linux, `libayatana-appindicator3` / `libappindicator3` may be absent (minimal /// installs, wrong `LD_LIBRARY_PATH`). The `tray-icon` stack can **panic** on `dlopen` /// failure instead of returning `Err`, so we catch unwind and keep the app running /// (e.g. cold start with `--player` still works without tray libraries). pub(crate) fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { let app = app.clone(); match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_tray_icon(&app))) { Ok(Ok(tray)) => Some(tray), Ok(Err(e)) => { crate::app_eprintln!("[Psysonic] System tray unavailable: {e}"); None } Err(_) => { crate::app_eprintln!( "[Psysonic] System tray unavailable — missing libayatana-appindicator3 or libappindicator3 \ (install the distro package or set LD_LIBRARY_PATH)" ); None } } } /// Updates the system-tray icon tooltip with the currently playing track. /// /// `tooltip` should be a compact "Artist – Title" form (no app suffix needed — /// the tray icon itself identifies the app). An empty string resets to the /// default `"Psysonic"` tooltip. /// /// The text is truncated to 127 chars defensively to stay under the historical /// Windows `NOTIFYICONDATA.szTip` limit (128 bytes including the null terminator). /// On Linux the visibility depends on the desktop environment / panel — /// StatusNotifierItem-aware panels (KDE, Cinnamon, GNOME with AppIndicator /// extension) show it; pure-GNOME without the extension does not. #[tauri::command] pub(crate) fn set_tray_tooltip( app: tauri::AppHandle, tray_state: tauri::State, tooltip_cache: tauri::State, playback_state_cache: tauri::State, tooltip: String, playback_state: Option, ) -> Result<(), String> { let has_track_input = !tooltip.is_empty(); let state = playback_state.as_deref().unwrap_or(if has_track_input { "play" } else { "stop" }); let icon = tray_state_icon(state); let icon_prefix_len = format!("{icon} ").chars().count(); let max_text_chars = 127usize.saturating_sub(icon_prefix_len); let ellipsis_reserve = 3usize; let truncated = if tooltip.chars().count() > max_text_chars { let take = max_text_chars.saturating_sub(ellipsis_reserve); tooltip.chars().take(take).collect::() + "..." } else { tooltip }; let has_track = !truncated.is_empty(); let effective = if has_track { truncated.clone() } else { "Psysonic".to_string() }; #[cfg(target_os = "windows")] let effective_with_icon = format!("{icon} {effective}"); *tooltip_cache.lock().unwrap() = truncated.clone(); *playback_state_cache.0.lock().unwrap() = state.to_string(); if let Some(tray) = tray_state.lock().unwrap().as_ref() { #[cfg(target_os = "windows")] tray.set_tooltip(Some(&effective_with_icon)).map_err(|e| e.to_string())?; #[cfg(not(target_os = "windows"))] tray.set_tooltip(Some(&effective)).map_err(|e| e.to_string())?; } #[cfg(target_os = "linux")] { if let Some(state) = app.try_state::() { if let Some(items) = state.lock().unwrap().as_ref() { if let Some(np) = items.now_playing.as_ref() { let label = if has_track { format!("{icon} {effective}") } else { let nothing = app.try_state::() .map(|s| s.lock().unwrap().nothing_playing.clone()) .unwrap_or_else(|| "Nothing playing".to_string()); format!("{icon} {nothing}") }; let _ = np.set_text(&label); } } } } #[cfg(not(target_os = "linux"))] let _ = &app; Ok(()) } /// Pushes localized labels into the tray menu. Called from the frontend on /// startup and whenever the i18n language changes. Updates are applied /// immediately to live menu items via `set_text` (no tray rebuild required) /// and cached so the labels survive a tray hide/show cycle. #[tauri::command] #[allow(clippy::too_many_arguments)] pub(crate) fn set_tray_menu_labels( app: tauri::AppHandle, labels_state: tauri::State, items_state: tauri::State, tooltip_cache: tauri::State, play_pause: String, next: String, previous: String, show_hide: String, quit: String, nothing_playing: String, ) -> Result<(), String> { let new_labels = TrayMenuLabels { play_pause, next, previous, show_hide, quit, nothing_playing, }; *labels_state.lock().unwrap() = new_labels.clone(); if let Some(items) = items_state.lock().unwrap().as_ref() { let _ = items.play_pause.set_text(&new_labels.play_pause); let _ = items.next.set_text(&new_labels.next); let _ = items.previous.set_text(&new_labels.previous); let _ = items.show_hide.set_text(&new_labels.show_hide); let _ = items.quit.set_text(&new_labels.quit); // Linux now-playing item: only refresh the placeholder. The track // text itself is owned by `set_tray_tooltip` and shouldn't be // overwritten by an unrelated language change. #[cfg(target_os = "linux")] if let Some(np) = items.now_playing.as_ref() { let has_track = !tooltip_cache.lock().unwrap().is_empty(); if !has_track { let state = app .try_state::() .map(|s| s.0.lock().unwrap().clone()) .unwrap_or_else(|| "stop".to_string()); let label = format!("{} {}", tray_state_icon(&state), new_labels.nothing_playing); let _ = np.set_text(&label); } } } let _ = (&app, &tooltip_cache); Ok(()) } /// Show (`true`) or fully remove (`false`) the system-tray icon. /// /// The command is strictly idempotent: /// - `show=true` when the icon is already present → no-op (prevents duplicate icons). /// - `show=false` when the icon is already absent → no-op. /// /// For removal, `set_visible(false)` is called explicitly before the handle is /// dropped because some platforms (Windows notification area, certain Linux DEs) /// process the OS removal asynchronously — hiding first prevents a brief "ghost" /// icon from appearing alongside a freshly created one. #[tauri::command] pub(crate) fn toggle_tray_icon( app: tauri::AppHandle, tray_state: tauri::State, show: bool, ) -> Result<(), String> { let mut guard = tray_state.lock().unwrap(); if show { // Early-return when already shown — never build a second icon. if guard.is_some() { return Ok(()); } let Some(tray) = try_build_tray_icon(&app) else { return Err( "Tray icon could not be created (missing system libraries on Linux).".into(), ); }; *guard = Some(tray); } else if let Some(tray) = guard.take() { // Hide synchronously before dropping so the OS processes the removal // before any subsequent show=true call can create a new icon. let _ = tray.set_visible(false); // `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem). } Ok(()) } pub(crate) use crate::audio::stop_audio_engine; /// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3, /// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables /// set by the compositor / DE. #[cfg(target_os = "linux")] pub(crate) fn is_tiling_wm() -> bool { // Direct compositor signatures (most reliable). let direct = [ "HYPRLAND_INSTANCE_SIGNATURE", // Hyprland "SWAYSOCK", // Sway "I3SOCK", // i3 ] .iter() .any(|&var| std::env::var_os(var).is_some()); if direct { return true; } // Check XDG_CURRENT_DESKTOP for known tiling WMs. if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") { let desktop = desktop.to_lowercase(); let tiling_wms = [ "hyprland", "sway", "i3", "bspwm", "awesome", "openbox", "xmonad", "dwm", "qtile", "herbstluftwm", "leftwm", ]; if tiling_wms.iter().any(|&wm| desktop.contains(wm)) { return true; } } false } /// Tauri command: returns true when WEBKIT_DISABLE_COMPOSITING_MODE=1 is set. /// The frontend uses this to apply a CSS class that swaps out GPU-only effects /// (backdrop-filter, CSS filter, mask-image) for software-friendly equivalents. #[tauri::command] pub(crate) fn no_compositing_mode() -> bool { std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE") .map(|v| v == "1") .unwrap_or(false) } /// Tauri command: `XDG_SESSION_TYPE` from the host environment (e.g. `wayland`, `x11`). /// Used for Linux-only UI tweaks such as font rasterisation hints; empty string when unset. #[tauri::command] pub(crate) fn linux_xdg_session_type() -> String { #[cfg(target_os = "linux")] { std::env::var("XDG_SESSION_TYPE").unwrap_or_default() } #[cfg(not(target_os = "linux"))] { String::new() } } #[cfg(not(target_os = "linux"))] pub(crate) fn is_tiling_wm() -> bool { false } /// Tauri command: lets the frontend know whether we're running under a tiling /// WM so it can decide whether to render the custom TitleBar component. #[tauri::command] pub(crate) fn is_tiling_wm_cmd() -> bool { is_tiling_wm() } #[cfg(all(test, debug_assertions))] mod tests { use super::*; #[test] fn flip_rgba_horizontal_mirrors_pixels() { // 3×1: A B C → C B A let mut rgba = vec![ 1, 0, 0, 255, // A 2, 0, 0, 255, // B 3, 0, 0, 255, // C ]; flip_rgba_horizontal(&mut rgba, 3, 1); assert_eq!(rgba[0], 3); assert_eq!(rgba[4], 2); assert_eq!(rgba[8], 1); } }