diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a6b90b..0fd178bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The **Server** lyrics source now highlights lyrics word by word, so karaoke sync no longer depends on the third-party YouLyPlus backend. Requires Navidrome 0.63 or newer and lyrics that carry word timing (TTML or Enhanced LRC); anything else keeps highlighting line by line. * **Settings → Lyrics → Lyrics Sources** spells out those requirements, and the block now follows the standard settings sub-card layout. +### Start minimized to tray + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1271](https://github.com/Psychotoxical/psysonic/pull/1271)** + +* New **Start Minimized to Tray** toggle under **Settings → System → Behavior**. When enabled, the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon. +* Requires **Show Tray Icon** (turning this on enables the tray automatically; hiding the tray clears the setting). The choice applies on the next launch only — toggling it in Settings does not hide the window immediately. + ## Changed diff --git a/public/startup-splash-preflight.js b/public/startup-splash-preflight.js index 34099c32..4a30b9a1 100644 --- a/public/startup-splash-preflight.js +++ b/public/startup-splash-preflight.js @@ -155,7 +155,25 @@ } var persisted = readThemeState(); + function readStartMinimizedToTray() { + try { + var raw = localStorage.getItem('psysonic-auth'); + if (!raw) return false; + var parsed = JSON.parse(raw); + var state = parsed && parsed.state; + if (!state || !state.startMinimizedToTray) return false; + return state.showTrayIcon !== false; + } catch (_err) { + return false; + } + } + var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha'; var palette = paletteForTheme(themeId, readInstalledThemes()); applyPalette(themeId, palette); + var trayHandled = false; + try { + trayHandled = sessionStorage.getItem('psy-startup-tray-handled') === '1'; + } catch (_err) {} + window.__psyStartMinimizedToTray = readStartMinimizedToTray() && !trayHandled; })(); diff --git a/public/startup-splash-reveal.js b/public/startup-splash-reveal.js index 7c3c9513..a4c95bc7 100644 --- a/public/startup-splash-reveal.js +++ b/public/startup-splash-reveal.js @@ -13,6 +13,7 @@ } function reveal(attempt) { + if (window.__psyStartMinimizedToTray) return; if (tryShowMainWindow()) return; if (attempt >= MAX_ATTEMPTS) return; window.setTimeout(function () { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c3b78d6..964ae6be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -895,9 +895,7 @@ pub fn run() { } let _ = window.hide(); if let Some(main) = window.app_handle().get_webview_window("main") { - let _ = main.unminimize(); - let _ = main.show(); - let _ = main.set_focus(); + let _ = crate::lib_commands::ui::mini::restore_main_window(&main); } } } @@ -909,14 +907,18 @@ pub fn run() { match payload.event() { tauri::webview::PageLoadEvent::Started => { - let window = webview.window().clone(); + let app = webview.app_handle().clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(48)); - let _ = window.show(); + if let Some(window) = app.get_webview_window("main") { + crate::lib_commands::ui::mini::eval_startup_main_window_visibility(&window); + } }); } tauri::webview::PageLoadEvent::Finished => { - let _ = webview.window().show(); + if let Some(window) = webview.app_handle().get_webview_window("main") { + crate::lib_commands::ui::mini::eval_startup_main_window_visibility(&window); + } } } }) diff --git a/src-tauri/src/lib_commands/ui/mini.rs b/src-tauri/src/lib_commands/ui/mini.rs index 54b6bbeb..602cd470 100644 --- a/src-tauri/src/lib_commands/ui/mini.rs +++ b/src-tauri/src/lib_commands/ui/mini.rs @@ -169,6 +169,51 @@ document.documentElement.style.removeProperty('--psy-anim-speed'); })(); "#; +/// Show the main window after startup splash paint, or pause rendering when the +/// user chose "start minimized to tray" (flag set in `startup-splash-preflight.js`). +pub(crate) fn eval_startup_main_window_visibility(window: &tauri::WebviewWindow) { + let js = format!( + "(function () {{ + try {{ + if (sessionStorage.getItem('psy-startup-tray-handled') === '1') return; + }} catch (e) {{}} + var deferToTray = !!window.__psyStartMinimizedToTray; + if (!deferToTray) {{ + try {{ + var raw = localStorage.getItem('psysonic-auth'); + if (raw) {{ + var state = JSON.parse(raw).state; + deferToTray = !!(state && state.startMinimizedToTray && state.showTrayIcon !== false); + }} + }} catch (e) {{}} + }} + var internals = window.__TAURI_INTERNALS__; + if (deferToTray) {{ + {pause} + try {{ sessionStorage.setItem('psy-startup-tray-handled', '1'); }} catch (e) {{}} + if (internals && typeof internals.invoke === 'function') {{ + internals.invoke('plugin:window|hide', {{ label: 'main' }}).catch(function () {{}}); + }} + return; + }} + if (internals && typeof internals.invoke === 'function') {{ + internals.invoke('plugin:window|show', {{ label: 'main' }}).catch(function () {{}}); + }} +}})();", + pause = PAUSE_RENDERING_JS.trim(), + ); + let _ = window.eval(&js); +} + +/// Resume rendering and bring the main window to the foreground. +pub(crate) fn restore_main_window(main: &tauri::WebviewWindow) -> Result<(), String> { + main.eval(RESUME_RENDERING_JS).map_err(|e| e.to_string())?; + main.unminimize().map_err(|e| e.to_string())?; + main.show().map_err(|e| e.to_string())?; + main.set_focus().map_err(|e| e.to_string())?; + Ok(()) +} + /// Build the mini player webview window. Caller decides `visible` so the /// same code path serves both pre-creation (Windows, hidden at app start) /// and lazy creation (other platforms, shown on demand). @@ -299,9 +344,7 @@ pub(crate) fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { let _ = win.eval(PAUSE_RENDERING_JS); win.hide().map_err(|e| e.to_string())?; if let Some(main) = app.get_webview_window("main") { - let _ = main.unminimize(); - let _ = main.show(); - let _ = main.set_focus(); + let _ = restore_main_window(&main); } } else { // Resume rendering before showing — the window needs to be ready @@ -341,9 +384,7 @@ pub(crate) fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> { win.hide().map_err(|e| e.to_string())?; } if let Some(main) = app.get_webview_window("main") { - let _ = main.unminimize(); - let _ = main.show(); - let _ = main.set_focus(); + restore_main_window(&main)?; } Ok(()) } @@ -360,10 +401,7 @@ pub(crate) fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { let _ = mini.hide(); } if let Some(main) = app.get_webview_window("main") { - let _ = main.eval(RESUME_RENDERING_JS); - main.unminimize().map_err(|e| e.to_string())?; - main.show().map_err(|e| e.to_string())?; - main.set_focus().map_err(|e| e.to_string())?; + restore_main_window(&main)?; } Ok(()) } diff --git a/src/app/startupSplash.test.ts b/src/app/startupSplash.test.ts index 6f85b2a8..884e94c2 100644 --- a/src/app/startupSplash.test.ts +++ b/src/app/startupSplash.test.ts @@ -13,12 +13,18 @@ vi.mock('@/lib/themes/startupThemeAppearance', () => ({ applyStartupSplashThemeFromStorage: vi.fn(() => 'mocha'), })); +vi.mock('@/lib/settings/readStartMinimizedToTray', () => ({ + shouldDeferMainWindowReveal: vi.fn(() => false), +})); + vi.mock('@tauri-apps/api/webviewWindow', () => ({ getCurrentWebviewWindow: vi.fn(() => ({ show: vi.fn(() => Promise.resolve()) })), })); import { getWindowKind } from './windowKind'; import { applyStartupSplashThemeFromStorage } from '@/lib/themes/startupThemeAppearance'; +import { shouldDeferMainWindowReveal } from '@/lib/settings/readStartMinimizedToTray'; +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; describe('startupSplash', () => { beforeEach(() => { @@ -42,6 +48,14 @@ describe('startupSplash', () => { expect(applyStartupSplashThemeFromStorage).toHaveBeenCalled(); }); + it('skips reveal when start minimized to tray is enabled', () => { + vi.mocked(shouldDeferMainWindowReveal).mockReturnValue(true); + const show = vi.fn(() => Promise.resolve()); + vi.mocked(getCurrentWebviewWindow).mockReturnValue({ show } as never); + configureStartupSplash(); + expect(show).not.toHaveBeenCalled(); + }); + it('fades out and removes splash', () => { vi.useFakeTimers(); dismissStartupSplash(); diff --git a/src/app/startupSplash.ts b/src/app/startupSplash.ts index 592319db..3ac8df13 100644 --- a/src/app/startupSplash.ts +++ b/src/app/startupSplash.ts @@ -1,4 +1,5 @@ import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; +import { shouldDeferMainWindowReveal } from '@/lib/settings/readStartMinimizedToTray'; import { applyStartupSplashThemeFromStorage } from '@/lib/themes/startupThemeAppearance'; import { getWindowKind } from './windowKind'; @@ -7,6 +8,7 @@ export const STARTUP_SPLASH_ID = 'app-startup-splash'; /** Ensure the native shell is visible once the webview bundle is alive. */ export function revealStartupWindow(): void { if (getWindowKind() === 'mini') return; + if (shouldDeferMainWindowReveal()) return; void getCurrentWebviewWindow().show().catch(() => {}); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index e2b0b309..6077780a 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -185,6 +185,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Queue — resolve thin-state rows off the visible range so off-window items stop rendering as "…" placeholders (desktop panel, mobile drawer, fullscreen up-next) (PR #1236)', 'Artists browse — case-insensitive Cyrillic/non-ASCII name search when local index is enabled (PR #1237)', 'CLI — relative volume via signed `volume` argument (+/− percent delta); suppress WebKit NVIDIA stderr notes on CLI argv; faster Linux CLI forward before WebKit init (PR #1238)', + 'Settings — start minimized to tray on cold launch; session-gated startup hide; tray icon coupling (PR #1271)', 'Multi-library filter — priority-ordered multi-select scope across browse/search/detail, sargable library_id + FTS-first SQL, rebuildable library-cluster.db identity keys, locale-aware name normalization (PR #1241)', 'Genres — full catalog via indexed SQL when All libraries is selected; no longer samples first album page on large libraries (PR #1242)', 'Offline browse — on-disk-only Artists/Albums/Tracks/Genres (pins, favorites-auto, hot-cache); reactive sidebar gates and sync-idle reload; local credit mode and genre scope (PR #1243)', diff --git a/src/features/settings/components/SystemTab.tsx b/src/features/settings/components/SystemTab.tsx index eccdfe73..0497a58a 100644 --- a/src/features/settings/components/SystemTab.tsx +++ b/src/features/settings/components/SystemTab.tsx @@ -161,6 +161,18 @@ export function SystemTab() { checked={auth.minimizeToTray} onChange={auth.setMinimizeToTray} /> +
+ {IS_LINUX && ( diff --git a/src/features/settings/components/settingsTabs.ts b/src/features/settings/components/settingsTabs.ts index 8b646fc2..16730c4c 100644 --- a/src/features/settings/components/settingsTabs.ts +++ b/src/features/settings/components/settingsTabs.ts @@ -71,7 +71,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' }, { tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' }, { tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' }, - { tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start smooth scroll linux' }, + { tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start minimized launch hidden smooth scroll linux' }, { tab: 'system', titleKey: 'settings.backupTitle', keywords: 'backup export import settings restore' }, { tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' }, { tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' }, diff --git a/src/lib/settings/readStartMinimizedToTray.test.ts b/src/lib/settings/readStartMinimizedToTray.test.ts new file mode 100644 index 00000000..a1ecc599 --- /dev/null +++ b/src/lib/settings/readStartMinimizedToTray.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + STARTUP_TRAY_HANDLED_KEY, + isStartupTrayHandledThisSession, + markStartupTrayHandledThisSession, + readStartMinimizedToTray, + shouldDeferMainWindowReveal, + shouldDeferMainWindowRevealThisSession, +} from './readStartMinimizedToTray'; + +describe('readStartMinimizedToTray', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + delete window.__psyStartMinimizedToTray; + }); + + it('returns false when unset', () => { + expect(readStartMinimizedToTray()).toBe(false); + }); + + it('reads persisted value from psysonic-auth', () => { + localStorage.setItem( + 'psysonic-auth', + JSON.stringify({ state: { startMinimizedToTray: true } }), + ); + expect(readStartMinimizedToTray()).toBe(true); + }); + + it('returns false when tray icon is disabled', () => { + localStorage.setItem( + 'psysonic-auth', + JSON.stringify({ state: { startMinimizedToTray: true, showTrayIcon: false } }), + ); + expect(readStartMinimizedToTray()).toBe(false); + }); +}); + +describe('startup tray session gate', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + delete window.__psyStartMinimizedToTray; + }); + + it('defers reveal only on the first load when the setting is on', () => { + localStorage.setItem( + 'psysonic-auth', + JSON.stringify({ state: { startMinimizedToTray: true } }), + ); + expect(shouldDeferMainWindowRevealThisSession()).toBe(true); + markStartupTrayHandledThisSession(); + expect(shouldDeferMainWindowRevealThisSession()).toBe(false); + expect(isStartupTrayHandledThisSession()).toBe(true); + expect(sessionStorage.getItem(STARTUP_TRAY_HANDLED_KEY)).toBe('1'); + }); + + it('prefers the preflight flag when present', () => { + window.__psyStartMinimizedToTray = true; + expect(shouldDeferMainWindowReveal()).toBe(true); + window.__psyStartMinimizedToTray = false; + expect(shouldDeferMainWindowReveal()).toBe(false); + }); +}); diff --git a/src/lib/settings/readStartMinimizedToTray.ts b/src/lib/settings/readStartMinimizedToTray.ts new file mode 100644 index 00000000..09ba3197 --- /dev/null +++ b/src/lib/settings/readStartMinimizedToTray.ts @@ -0,0 +1,51 @@ +const AUTH_STORAGE_KEY = 'psysonic-auth'; + +/** Keep in sync with `public/startup-splash-preflight.js` and Rust `eval_startup_main_window_visibility`. */ +export const STARTUP_TRAY_HANDLED_KEY = 'psy-startup-tray-handled'; + +/** Read persisted "start minimized to tray" before Zustand rehydrates. */ +export function readStartMinimizedToTray(): boolean { + try { + const raw = localStorage.getItem(AUTH_STORAGE_KEY); + if (!raw) return false; + const parsed = JSON.parse(raw) as { + state?: { startMinimizedToTray?: boolean; showTrayIcon?: boolean }; + }; + const state = parsed.state; + if (!state?.startMinimizedToTray) return false; + // Tray icon must be available to restore the window after a hidden start. + return state.showTrayIcon !== false; + } catch { + return false; + } +} + +/** Whether startup-to-tray already ran (or was skipped) this app process session. */ +export function isStartupTrayHandledThisSession(): boolean { + try { + return sessionStorage.getItem(STARTUP_TRAY_HANDLED_KEY) === '1'; + } catch { + return false; + } +} + +/** True only on the first document load of a process when the setting is on. */ +export function shouldDeferMainWindowRevealThisSession(): boolean { + return readStartMinimizedToTray() && !isStartupTrayHandledThisSession(); +} + +/** Prefer the preflight flag when set; otherwise compute from storage + session. */ +export function shouldDeferMainWindowReveal(): boolean { + if (typeof window.__psyStartMinimizedToTray === 'boolean') { + return window.__psyStartMinimizedToTray; + } + return shouldDeferMainWindowRevealThisSession(); +} + +export function markStartupTrayHandledThisSession(): void { + try { + sessionStorage.setItem(STARTUP_TRAY_HANDLED_KEY, '1'); + } catch { + // Non-fatal — worst case we re-apply pause once on reload. + } +} diff --git a/src/locales/bg/settings.ts b/src/locales/bg/settings.ts index 1f042a60..696f1b9a 100644 --- a/src/locales/bg/settings.ts +++ b/src/locales/bg/settings.ts @@ -255,6 +255,9 @@ export const settings = { showTrayIconDesc: 'Показва иконата на Psysonic в областта за системни известия / менюто.', minimizeToTray: 'Минимизирай в системната лента', minimizeToTrayDesc: 'При затваряне на прозореца, запази Psysonic да работи в системната лента, вместо да излиза.', + startMinimizedToTray: 'Стартирай минимизиран в трея', + startMinimizedToTrayDesc: 'Стартирай Psysonic със скрит главен прозорец; покажи го чрез иконата в трея.', + startMinimizedToTrayRequiresTray: 'Първо включи „Показвай икона в системната лента“ — без трей не можеш да отвориш прозореца след скрит старт.', clockFormat: 'Формат на часовника', clockFormatDesc: 'Показване на часа, използвано от прогнозното време на опашката и предварителния преглед на таймера за заспиване.', clockFormatAuto: 'Автоматично (системен регион)', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index e3752ef5..26b3bc03 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -252,6 +252,9 @@ export const settings = { showTrayIconDesc: 'Psysonic-Icon im System-Tray / in der Menüleiste anzeigen.', minimizeToTray: 'Im Tray minimieren', minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.', + startMinimizedToTray: 'Im Tray starten', + startMinimizedToTrayDesc: 'Psysonic mit verstecktem Hauptfenster starten; über das Tray-Icon wieder anzeigen.', + startMinimizedToTrayRequiresTray: 'Zuerst „Tray-Icon anzeigen“ aktivieren — ohne Tray lässt sich das Fenster nach verstecktem Start nicht öffnen.', clockFormat: 'Uhrzeitformat', clockFormatDesc: 'Format für die Queue-ETA und die Vorschau des Sleep-Timers.', clockFormatAuto: 'Automatisch (Systemsprache)', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 2d6ef34f..37976c0c 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -255,6 +255,9 @@ export const settings = { showTrayIconDesc: 'Display the Psysonic icon in the system notification area / menu bar.', minimizeToTray: 'Minimize to Tray', minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.', + startMinimizedToTray: 'Start Minimized to Tray', + startMinimizedToTrayDesc: 'Launch Psysonic with the main window hidden; use the tray icon to show it.', + startMinimizedToTrayRequiresTray: 'Enable "Show Tray Icon" first — you need the tray to open the window after a hidden start.', clockFormat: 'Clock Format', clockFormatDesc: 'Wall-clock display used by the queue ETA and the sleep-timer preview.', clockFormatAuto: 'Auto (system locale)', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 19dc724e..3bf2d8e1 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -251,6 +251,9 @@ export const settings = { showTrayIconDesc: 'Muestra el icono de Psysonic en el área de notificación / barra de menú.', minimizeToTray: 'Minimizar a Bandeja', minimizeToTrayDesc: 'Al cerrar la ventana, mantener Psysonic ejecutándose en la bandeja del sistema en lugar de salir.', + startMinimizedToTray: 'Iniciar minimizado en la bandeja', + startMinimizedToTrayDesc: 'Iniciar Psysonic con la ventana principal oculta; usa el icono de la bandeja para mostrarla.', + startMinimizedToTrayRequiresTray: 'Activa primero «Mostrar icono en bandeja»; sin él no podrás abrir la ventana tras un inicio oculto.', clockFormat: 'Formato de hora', clockFormatDesc: 'Formato del reloj utilizado por la ETA de la cola y la vista previa del temporizador de suspensión.', clockFormatAuto: 'Automático (idioma del sistema)', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index ce6dd066..f41b7a38 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -251,6 +251,9 @@ export const settings = { showTrayIconDesc: 'Affiche l\'icône Psysonic dans la zone de notification / barre des menus.', minimizeToTray: 'Réduire dans la barre système', minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.', + startMinimizedToTray: 'Démarrer réduit dans la barre système', + startMinimizedToTrayDesc: 'Lance Psysonic avec la fenêtre principale masquée ; utilise l\'icône de la barre système pour l\'afficher.', + startMinimizedToTrayRequiresTray: 'Activez d\'abord « Afficher l\'icône dans la barre système » — sans elle, la fenêtre ne s\'ouvre pas après un démarrage masqué.', clockFormat: 'Format de l\'heure', clockFormatDesc: 'Format de l\'horloge utilisé par l\'ETA de la file d\'attente et l\'aperçu du minuteur de mise en veille.', clockFormatAuto: 'Automatique (langue du système)', diff --git a/src/locales/hu/settings.ts b/src/locales/hu/settings.ts index 1c189809..834bded7 100644 --- a/src/locales/hu/settings.ts +++ b/src/locales/hu/settings.ts @@ -255,6 +255,9 @@ export const settings = { showTrayIconDesc: 'A Psysonic ikon megjelenítése a rendszer értesítési területén / menüsorban.', minimizeToTray: 'Kis méretre a tálcára', minimizeToTrayDesc: 'Az ablak bezárásakor a Psysonic kilépés helyett a rendszer tálcáján fut tovább.', + startMinimizedToTray: 'Indítás a tálcán', + startMinimizedToTrayDesc: 'A Psysonic rejtett főablakkal indul; a megjelenítéshez használd a tálca ikont.', + startMinimizedToTrayRequiresTray: 'Először kapcsold be a „Tálcaikon megjelenítése” beállítást — rejtett indítás után csak így nyitható meg az ablak.', clockFormat: 'Óraformátum', clockFormatDesc: 'A sor várható befejezési idejéhez és az alvásidőzítő előnézetéhez használt óramegjelenítés.', clockFormatAuto: 'Automatikus (rendszer beállítása)', diff --git a/src/locales/it/settings.ts b/src/locales/it/settings.ts index 252cb8a4..a8551afc 100644 --- a/src/locales/it/settings.ts +++ b/src/locales/it/settings.ts @@ -255,6 +255,9 @@ export const settings = { showTrayIconDesc: 'Mostra l\'icona di Psysonic nell\'area di notifica di sistema / barra dei menu.', minimizeToTray: 'Riduci nell\'area di notifica', minimizeToTrayDesc: 'Alla chiusura della finestra, mantieni Psysonic in esecuzione nell\'area di notifica invece di chiuderlo.', + startMinimizedToTray: 'Avvia ridotto nell\'area di notifica', + startMinimizedToTrayDesc: 'Avvia Psysonic con la finestra principale nascosta; usa l\'icona nell\'area di notifica per mostrarla.', + startMinimizedToTrayRequiresTray: 'Abilita prima «Mostra icona nell\'area di notifica» — senza tray non puoi aprire la finestra dopo un avvio nascosto.', clockFormat: 'Formato orario', clockFormatDesc: 'Formato dell\'orario usato nella stima della coda e nell\'anteprima del timer di spegnimento.', clockFormatAuto: 'Automatico (lingua di sistema)', diff --git a/src/locales/ja/settings.ts b/src/locales/ja/settings.ts index d0fcfb94..fe49f992 100644 --- a/src/locales/ja/settings.ts +++ b/src/locales/ja/settings.ts @@ -255,6 +255,9 @@ export const settings = { showTrayIconDesc: 'システム通知領域 / メニューバーに Psysonic アイコンを表示します。', minimizeToTray: 'トレイへ最小化', minimizeToTrayDesc: 'ウィンドウを閉じたとき、終了せずにシステムトレイで Psysonic を実行し続けます。', + startMinimizedToTray: 'トレイで起動', + startMinimizedToTrayDesc: 'メインウィンドウを非表示のまま起動します。表示するにはトレイアイコンを使います。', + startMinimizedToTrayRequiresTray: '先に「トレイアイコンを表示」を有効にしてください。非表示起動後はトレイから開けます。', clockFormat: '時計形式', clockFormatDesc: 'キューの終了予定時刻とスリープタイマーのプレビューに使う時計表示です。', clockFormatAuto: '自動 (システムロケール)', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index ed5f15ff..8388eecd 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -250,6 +250,9 @@ export const settings = { showOrbitTriggerDesc: 'Knappen i toppen for å starte eller bli med i en delt lytteøkt. Skjul den hvis du ikke bruker Orbit — du kan slå den på igjen her.', minimizeToTray: 'Minimer til oppgavelinjen', minimizeToTrayDesc: 'Når vinduet lukkes, vil Psysonic bli kjørende i oppgavelinjen fremfor å bli avsluttet.', + startMinimizedToTray: 'Start minimert til oppgavelinjen', + startMinimizedToTrayDesc: 'Start Psysonic med hovedvinduet skjult; bruk oppgavelinjeikonet for å vise det.', + startMinimizedToTrayRequiresTray: 'Aktiver «Vis systemstatusikon» først — uten oppgavelinjen kan du ikke åpne vinduet etter skjult oppstart.', clockFormat: 'Klokkeformat', clockFormatDesc: 'Klokkevisning brukt av kø-ETA og forhåndsvisningen av hviletimeren.', clockFormatAuto: 'Automatisk (systemspråk)', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 10ab9883..b6dffa5e 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -251,6 +251,9 @@ export const settings = { showTrayIconDesc: 'Toont het Psysonic-pictogram in het systeemvak / de menubalk.', minimizeToTray: 'Minimaliseren naar systeemvak', minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.', + startMinimizedToTray: 'Start geminimaliseerd in systeemvak', + startMinimizedToTrayDesc: 'Start Psysonic met het hoofdvenster verborgen; gebruik het tray-pictogram om het te tonen.', + startMinimizedToTrayRequiresTray: 'Schakel eerst „Tray-pictogram weergeven” in — zonder tray kun je het venster na een verborgen start niet openen.', clockFormat: 'Tijdformaat', clockFormatDesc: 'Tijdsweergave voor de wachtrij-ETA en het voorbeeld van de slaaptimer.', clockFormatAuto: 'Automatisch (systeemtaal)', diff --git a/src/locales/pl/settings.ts b/src/locales/pl/settings.ts index 4b8e818e..161e7227 100644 --- a/src/locales/pl/settings.ts +++ b/src/locales/pl/settings.ts @@ -255,6 +255,9 @@ export const settings = { showTrayIconDesc: 'Wyświetl ikonę Psysonic w obszarze powiadomień systemu/pasku menu.', minimizeToTray: 'Minimalizuj do zasobnika systemowego', minimizeToTrayDesc: 'Podczas zamykania okna, zamiast wyłączać Psysonic, pozostaw go uruchomionym w zasobniku systemowym.', + startMinimizedToTray: 'Uruchamiaj zminimalizowany w zasobniku', + startMinimizedToTrayDesc: 'Uruchamiaj Psysonic z ukrytym oknem głównym; pokaż je przez ikonę w zasobniku.', + startMinimizedToTrayRequiresTray: 'Najpierw włącz „Pokaż ikonę zasobnika” — bez niej nie otworzysz okna po ukrytym starcie.', clockFormat: 'Format zegara', clockFormatDesc: 'Wyświetlacz zegara ściennego używany przez kolejkę ETA i podgląd timera uśpienia.', clockFormatAuto: 'Auto (ustawienia regionalne systemu)', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index e5ae60fd..6f011038 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -254,6 +254,9 @@ export const settings = { showTrayIconDesc: 'Afișează iconița Psysonic în zona notificărilor de sistem / bara de meniu.', minimizeToTray: 'Minimizează în Tavă', minimizeToTrayDesc: 'La închiderea ferestrei, continuă rularea Psysonic în tava de sistem în loc de ieșire', + startMinimizedToTray: 'Pornește minimizat în tavă', + startMinimizedToTrayDesc: 'Lansează Psysonic cu fereastra principală ascunsă; folosește iconița din tavă pentru a o afișa.', + startMinimizedToTrayRequiresTray: 'Activează mai întâi „Afișează iconița tăvii” — fără tavă nu poți deschide fereastra după un start ascuns.', clockFormat: 'Format Oră', clockFormatDesc: 'Format al ceasului folosit pentru ETA-ul cozii și previzualizarea cronometrului de somn.', clockFormatAuto: 'Automat (limba sistemului)', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 156c2197..e25d12a9 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -256,6 +256,9 @@ export const settings = { showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.', minimizeToTray: 'Сворачивать в трей', minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.', + startMinimizedToTray: 'Запускать свёрнутым в трей', + startMinimizedToTrayDesc: 'При запуске скрывать главное окно; открыть приложение можно через иконку в трее.', + startMinimizedToTrayRequiresTray: 'Сначала включите «Иконка в трее» — без неё окно не открыть после скрытого запуска.', clockFormat: 'Формат времени', clockFormatDesc: 'Формат времени для ETA очереди и предпросмотра таймера сна.', clockFormatAuto: 'Авто (системный)', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 72abb1d4..2c4e8159 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -251,6 +251,9 @@ export const settings = { showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。', minimizeToTray: '最小化到托盘', minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。', + startMinimizedToTray: '启动时最小化到托盘', + startMinimizedToTrayDesc: '启动时隐藏主窗口;通过托盘图标显示窗口。', + startMinimizedToTrayRequiresTray: '请先启用“显示托盘图标”——隐藏启动后需要通过托盘打开窗口。', clockFormat: '时间格式', clockFormatDesc: '队列预计结束时间和睡眠定时器预览使用的时间格式。', clockFormatAuto: '自动(跟随系统)', diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index fd2a8144..f1e1337f 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -54,6 +54,7 @@ describe('trivial pass-through setters', () => { ['setArtistBrowseCreditMode', 'artistBrowseCreditMode', 'track'], ['setShowTrayIcon', 'showTrayIcon', false], ['setMinimizeToTray', 'minimizeToTray', true], + ['setStartMinimizedToTray', 'startMinimizedToTray', true], ['setClockFormat', 'clockFormat', '24h'], ['setShowOrbitTrigger', 'showOrbitTrigger', false], ['setDiscordRichPresence', 'discordRichPresence', true], @@ -313,6 +314,24 @@ describe('lyrics source setters', () => { }); }); +describe('tray startup settings coupling', () => { + it('setStartMinimizedToTray enables showTrayIcon when it was off', () => { + useAuthStore.getState().setShowTrayIcon(false); + useAuthStore.getState().setStartMinimizedToTray(true); + const s = useAuthStore.getState(); + expect(s.startMinimizedToTray).toBe(true); + expect(s.showTrayIcon).toBe(true); + }); + + it('setShowTrayIcon(false) clears startMinimizedToTray', () => { + useAuthStore.getState().setStartMinimizedToTray(true); + useAuthStore.getState().setShowTrayIcon(false); + const s = useAuthStore.getState(); + expect(s.showTrayIcon).toBe(false); + expect(s.startMinimizedToTray).toBe(false); + }); +}); + describe('mix filter setters — clamp to allowed range', () => { it('setMixMinRatingSong / Album / Artist clamp out-of-range stars', () => { useAuthStore.getState().setMixMinRatingSong(10); diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 4fbc66b8..d847bc81 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -72,6 +72,7 @@ export const useAuthStore = create()( libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS, showTrayIcon: true, minimizeToTray: false, + startMinimizedToTray: false, clockFormat: 'auto', showOrbitTrigger: true, discordRichPresence: false, diff --git a/src/store/authStoreRehydrate.test.ts b/src/store/authStoreRehydrate.test.ts index 10769b95..6f17f511 100644 --- a/src/store/authStoreRehydrate.test.ts +++ b/src/store/authStoreRehydrate.test.ts @@ -75,4 +75,14 @@ describe('computeAuthStoreRehydration — lyrics', () => { { id: 'netease', enabled: false }, ]); }); + + it('clears startMinimizedToTray when tray icon is off', () => { + const base = useAuthStore.getState(); + const patch = computeAuthStoreRehydration({ + ...base, + startMinimizedToTray: true, + showTrayIcon: false, + }); + expect(patch.startMinimizedToTray).toBe(false); + }); }); diff --git a/src/store/authStoreRehydrate.ts b/src/store/authStoreRehydrate.ts index ce674e4b..fc22a0ba 100644 --- a/src/store/authStoreRehydrate.ts +++ b/src/store/authStoreRehydrate.ts @@ -252,6 +252,9 @@ export function computeAuthStoreRehydration(state: AuthState): Partial