From 2ea22635e5cefd94f45fb8f75f7f01b4d98c7553 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 1 May 2026 12:58:16 +0200 Subject: [PATCH] feat(tray): show now-playing track in tray + i18n menu labels (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tray): show now-playing track in tray icon tooltip Mirrors the current track to the tray tooltip ("Artist – Title") on every play/pause/track change, falling back to "Psysonic" when nothing is playing. The cached value survives tray hide/show via a new TrayTooltip state, and is truncated to 127 chars for Windows NOTIFYICONDATA.szTip. Closes #383 * feat(tray): linux fallback — disabled menu item shows now-playing track AppIndicator on Linux has no hover-tooltip API, so the tooltip command was a silent no-op there. Adds a disabled menu entry at the top of the tray right-click menu that mirrors the same text. Updated in lockstep with the Win/macOS tooltip via set_tray_tooltip. * i18n(tray): localize tray menu labels Adds a `tray` namespace in all 8 locales (en/de/fr/nl/zh/nb/ru/es) for Play/Pause, Next/Previous, Show/Hide, Exit, and the Linux-only "Nothing playing" placeholder. Rust side: TrayMenuLabels state holds the cached translations and TrayMenuItems holds the live MenuItem handles. New set_tray_menu_labels command pushes new strings + applies them via set_text without rebuilding the tray icon. Frontend pushes the labels on mount and on every i18n.on('languageChanged'). --- src-tauri/src/lib.rs | 226 +++++++++++++++++++++++++++++++++++++++++-- src/App.tsx | 22 ++++- src/locales/de.ts | 8 ++ src/locales/en.ts | 8 ++ src/locales/es.ts | 8 ++ src/locales/fr.ts | 8 ++ src/locales/nb.ts | 8 ++ src/locales/nl.ts | 8 ++ src/locales/ru.ts | 8 ++ src/locales/zh.ts | 8 ++ 10 files changed, 302 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f42c993..fd8819f1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -485,6 +485,54 @@ pub(crate) async fn submit_analysis_cpu_seed( /// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms. type TrayState = Mutex>; +/// Cached tray tooltip text. Updated by `set_tray_tooltip` and re-applied when the +/// icon is rebuilt (e.g. after the user toggles the tray off and on again). +/// Empty string means "use the default `Psysonic` tooltip". +type TrayTooltip = Mutex; + +/// Handles to all updatable tray menu items, kept around so `set_tray_menu_labels` +/// (i18n refresh) and `set_tray_tooltip` (track change) can re-text them without +/// rebuilding the whole tray icon. The `now_playing` slot is `Some` on Linux +/// only — it surfaces the current track as a disabled menu entry because +/// AppIndicator has no hover tooltip API. +struct TrayMenuItems { + play_pause: tauri::menu::MenuItem, + next: tauri::menu::MenuItem, + previous: tauri::menu::MenuItem, + show_hide: tauri::menu::MenuItem, + quit: tauri::menu::MenuItem, + now_playing: Option>, +} + +type TrayMenuItemsState = Mutex>; + +/// Cached translations for the tray menu. Defaults to English so the menu has +/// readable labels before the frontend has had a chance to run `set_tray_menu_labels`. +#[derive(Clone)] +struct TrayMenuLabels { + play_pause: String, + next: String, + previous: String, + show_hide: String, + quit: String, + nothing_playing: String, +} + +impl Default for TrayMenuLabels { + fn default() -> Self { + Self { + play_pause: "Play / Pause".into(), + next: "Next Track".into(), + previous: "Previous Track".into(), + show_hide: "Show / Hide".into(), + quit: "Exit Psysonic".into(), + nothing_playing: "Nothing playing".into(), + } + } +} + +type TrayMenuLabelsState = Mutex; + /// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows). /// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux). type MprisControls = Mutex>; @@ -3609,15 +3657,49 @@ async fn delete_device_files(paths: Vec) -> Result { /// Builds and returns a new system-tray icon with all menu items and event handlers. /// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation). fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { - let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?; - let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?; - let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?; - let sep1 = PredefinedMenuItem::separator(app)?; - let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?; - let sep2 = PredefinedMenuItem::separator(app)?; - let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?; + let labels = app + .try_state::() + .map(|s| s.lock().unwrap().clone()) + .unwrap_or_default(); - let menu = MenuBuilder::new(app) + 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()); + + // 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 label = if cached_tooltip == "Psysonic" { + labels.nothing_playing.as_str() + } else { + cached_tooltip.as_str() + }; + let item = MenuItemBuilder::with_id("now_playing", label) + .enabled(false) + .build(app)?; + (item, PredefinedMenuItem::separator(app)?) + }; + + let mut menu_builder = MenuBuilder::new(app); + #[cfg(target_os = "linux")] + { + menu_builder = menu_builder.item(&now_playing).item(&sep_now_playing); + } + let menu = menu_builder .item(&play_pause) .item(&previous) .item(&next) @@ -3627,10 +3709,26 @@ fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result { .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, + }); + } + TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) - .tooltip("Psysonic") + .tooltip(&cached_tooltip) .on_menu_event(|app, event| match event.id.as_ref() { "play_pause" => { let _ = app.emit("tray:play-pause", ()); } "next" => { let _ = app.emit("tray:next", ()); } @@ -3711,6 +3809,111 @@ fn try_build_tray_icon(app: &tauri::AppHandle) -> Option { } } +/// 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] +fn set_tray_tooltip( + app: tauri::AppHandle, + tray_state: tauri::State, + tooltip_cache: tauri::State, + tooltip: String, +) -> Result<(), String> { + let truncated = if tooltip.chars().count() > 127 { + tooltip.chars().take(124).collect::() + "..." + } else { + tooltip + }; + let has_track = !truncated.is_empty(); + let effective = if has_track { truncated.clone() } else { "Psysonic".to_string() }; + + *tooltip_cache.lock().unwrap() = truncated.clone(); + + if let Some(tray) = tray_state.lock().unwrap().as_ref() { + 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 { + effective.clone() + } else { + app.try_state::() + .map(|s| s.lock().unwrap().nothing_playing.clone()) + .unwrap_or_else(|| "Nothing playing".to_string()) + }; + 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] +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 _ = np.set_text(&new_labels.nothing_playing); + } + } + } + + let _ = (&app, &tooltip_cache); + Ok(()) +} + /// Show (`true`) or fully remove (`false`) the system-tray icon. /// /// The command is strictly idempotent: @@ -4330,6 +4533,9 @@ pub fn run() { .manage(discord::DiscordState::new()) .manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore) .manage(TrayState::default()) + .manage(TrayTooltip::default()) + .manage(TrayMenuItemsState::default()) + .manage(TrayMenuLabelsState::default()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin( @@ -4661,6 +4867,8 @@ pub fn run() { write_playlist_m3u8, rename_device_files, toggle_tray_icon, + set_tray_tooltip, + set_tray_menu_labels, check_dir_accessible, download_zip, check_arch_linux, diff --git a/src/App.tsx b/src/App.tsx index 472aa77b..d79922f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -152,7 +152,7 @@ function shouldSuppressQueueResizerMouseDown(clientX: number, clientY: number, q } function AppShell() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const isMobile = useIsMobile(); const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); const [isTilingWm, setIsTilingWm] = useState(false); @@ -311,15 +311,35 @@ function AppShell() { const title = `${state} ${currentTrack.artist} - ${currentTrack.title} | Psysonic`; document.title = title; await appWindow.setTitle(title); + await invoke('set_tray_tooltip', { + tooltip: `${currentTrack.artist} – ${currentTrack.title}`, + }).catch(() => {}); } else { document.title = 'Psysonic'; await appWindow.setTitle('Psysonic'); + await invoke('set_tray_tooltip', { tooltip: '' }).catch(() => {}); } } catch (err) {} }; fn(); }, [currentTrack, isPlaying]); + useEffect(() => { + const apply = () => { + invoke('set_tray_menu_labels', { + playPause: t('tray.playPause'), + next: t('tray.nextTrack'), + previous: t('tray.previousTrack'), + showHide: t('tray.showHide'), + quit: t('tray.exitPsysonic'), + nothingPlaying: t('tray.nothingPlaying'), + }).catch(() => {}); + }; + apply(); + i18n.on('languageChanged', apply); + return () => { i18n.off('languageChanged', apply); }; + }, [t, i18n]); + // Post-update changelog is now surfaced via a dismissible banner in the // sidebar (WhatsNewBanner) that links to the /whats-new page — no auto // modal takeover on startup. diff --git a/src/locales/de.ts b/src/locales/de.ts index 3cd4d9b9..1fb54d4c 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1730,4 +1730,12 @@ export const deTranslation = { exitEndedBody: '„{{name}}" ist zu Ende. Hoffentlich war\'s schön.', exitOk: 'OK', }, + tray: { + playPause: 'Wiedergabe / Pause', + nextTrack: 'Nächster Titel', + previousTrack: 'Vorheriger Titel', + showHide: 'Anzeigen / Verbergen', + exitPsysonic: 'Psysonic beenden', + nothingPlaying: 'Nichts wird abgespielt', + }, }; diff --git a/src/locales/en.ts b/src/locales/en.ts index b5149151..bc40032e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1737,4 +1737,12 @@ export const enTranslation = { exitEndedBody: '"{{name}}" has ended. Hope you had fun.', exitOk: 'OK', }, + tray: { + playPause: 'Play / Pause', + nextTrack: 'Next Track', + previousTrack: 'Previous Track', + showHide: 'Show / Hide', + exitPsysonic: 'Exit Psysonic', + nothingPlaying: 'Nothing playing', + }, }; diff --git a/src/locales/es.ts b/src/locales/es.ts index 5849b7ee..118f7639 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1717,4 +1717,12 @@ export const esTranslation = { exitEndedBody: '"{{name}}" ha terminado. Espero que te hayas divertido.', exitOk: 'OK', }, + tray: { + playPause: 'Reproducir / Pausa', + nextTrack: 'Pista siguiente', + previousTrack: 'Pista anterior', + showHide: 'Mostrar / Ocultar', + exitPsysonic: 'Salir de Psysonic', + nothingPlaying: 'Nada en reproducción', + }, }; diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 67e55593..d9cc57d4 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1712,4 +1712,12 @@ export const frTranslation = { exitEndedBody: '« {{name}} » est terminée. J\'espère que tu t\'es bien amusé.', exitOk: 'OK', }, + tray: { + playPause: 'Lecture / Pause', + nextTrack: 'Piste suivante', + previousTrack: 'Piste précédente', + showHide: 'Afficher / Masquer', + exitPsysonic: 'Quitter Psysonic', + nothingPlaying: 'Aucune lecture', + }, }; diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 668e7856..af709b6a 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1711,4 +1711,12 @@ export const nbTranslation = { exitEndedBody: '"{{name}}" er avsluttet. Håper du hadde det gøy.', exitOk: 'OK', }, + tray: { + playPause: 'Spill / Pause', + nextTrack: 'Neste spor', + previousTrack: 'Forrige spor', + showHide: 'Vis / Skjul', + exitPsysonic: 'Avslutt Psysonic', + nothingPlaying: 'Ingenting spilles', + }, }; diff --git a/src/locales/nl.ts b/src/locales/nl.ts index d3a93f46..0005a29d 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1711,4 +1711,12 @@ export const nlTranslation = { exitEndedBody: '"{{name}}" is beëindigd. Hopelijk had je plezier.', exitOk: 'OK', }, + tray: { + playPause: 'Afspelen / Pauzeren', + nextTrack: 'Volgende nummer', + previousTrack: 'Vorige nummer', + showHide: 'Tonen / Verbergen', + exitPsysonic: 'Psysonic afsluiten', + nothingPlaying: 'Niets wordt afgespeeld', + }, }; diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 88f59755..cf9a4be2 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1798,4 +1798,12 @@ export const ruTranslation = { exitEndedBody: '«{{name}}» завершена. Надеюсь, тебе понравилось.', exitOk: 'ОК', }, + tray: { + playPause: 'Воспроизвести / Пауза', + nextTrack: 'Следующий трек', + previousTrack: 'Предыдущий трек', + showHide: 'Показать / Скрыть', + exitPsysonic: 'Закрыть Psysonic', + nothingPlaying: 'Ничего не воспроизводится', + }, }; diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 3681f551..5e113fb0 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1704,4 +1704,12 @@ export const zhTranslation = { exitEndedBody: '"{{name}}"已结束。希望你玩得开心。', exitOk: '好的', }, + tray: { + playPause: '播放 / 暂停', + nextTrack: '下一首', + previousTrack: '上一首', + showHide: '显示 / 隐藏', + exitPsysonic: '退出 Psysonic', + nothingPlaying: '当前没有播放', + }, };