feat(settings): start minimized to tray on cold launch (#1271)

This commit is contained in:
cucadmuh
2026-07-11 01:41:44 +03:00
committed by GitHub
parent 51140c613a
commit 1625f4a8be
33 changed files with 315 additions and 18 deletions
+7
View File
@@ -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
+18
View File
@@ -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;
})();
+1
View File
@@ -13,6 +13,7 @@
}
function reveal(attempt) {
if (window.__psyStartMinimizedToTray) return;
if (tryShowMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
+8 -6
View File
@@ -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);
}
}
}
})
+48 -10
View File
@@ -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(())
}
+14
View File
@@ -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();
+2
View File
@@ -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(() => {});
}
+1
View File
@@ -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)',
@@ -161,6 +161,18 @@ export function SystemTab() {
checked={auth.minimizeToTray}
onChange={auth.setMinimizeToTray}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.startMinimizedToTray')}
desc={
auth.showTrayIcon
? t('settings.startMinimizedToTrayDesc')
: t('settings.startMinimizedToTrayRequiresTray')
}
checked={auth.startMinimizedToTray}
disabled={!auth.showTrayIcon}
onChange={auth.setStartMinimizedToTray}
/>
</SettingsGroup>
{IS_LINUX && (
@@ -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' },
@@ -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);
});
});
@@ -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.
}
}
+3
View File
@@ -255,6 +255,9 @@ export const settings = {
showTrayIconDesc: 'Показва иконата на Psysonic в областта за системни известия / менюто.',
minimizeToTray: 'Минимизирай в системната лента',
minimizeToTrayDesc: 'При затваряне на прозореца, запази Psysonic да работи в системната лента, вместо да излиза.',
startMinimizedToTray: 'Стартирай минимизиран в трея',
startMinimizedToTrayDesc: 'Стартирай Psysonic със скрит главен прозорец; покажи го чрез иконата в трея.',
startMinimizedToTrayRequiresTray: 'Първо включи „Показвай икона в системната лента“ — без трей не можеш да отвориш прозореца след скрит старт.',
clockFormat: 'Формат на часовника',
clockFormatDesc: 'Показване на часа, използвано от прогнозното време на опашката и предварителния преглед на таймера за заспиване.',
clockFormatAuto: 'Автоматично (системен регион)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -255,6 +255,9 @@ export const settings = {
showTrayIconDesc: 'システム通知領域 / メニューバーに Psysonic アイコンを表示します。',
minimizeToTray: 'トレイへ最小化',
minimizeToTrayDesc: 'ウィンドウを閉じたとき、終了せずにシステムトレイで Psysonic を実行し続けます。',
startMinimizedToTray: 'トレイで起動',
startMinimizedToTrayDesc: 'メインウィンドウを非表示のまま起動します。表示するにはトレイアイコンを使います。',
startMinimizedToTrayRequiresTray: '先に「トレイアイコンを表示」を有効にしてください。非表示起動後はトレイから開けます。',
clockFormat: '時計形式',
clockFormatDesc: 'キューの終了予定時刻とスリープタイマーのプレビューに使う時計表示です。',
clockFormatAuto: '自動 (システムロケール)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -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)',
+3
View File
@@ -256,6 +256,9 @@ export const settings = {
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
minimizeToTray: 'Сворачивать в трей',
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
startMinimizedToTray: 'Запускать свёрнутым в трей',
startMinimizedToTrayDesc: 'При запуске скрывать главное окно; открыть приложение можно через иконку в трее.',
startMinimizedToTrayRequiresTray: 'Сначала включите «Иконка в трее» — без неё окно не открыть после скрытого запуска.',
clockFormat: 'Формат времени',
clockFormatDesc: 'Формат времени для ETA очереди и предпросмотра таймера сна.',
clockFormatAuto: 'Авто (системный)',
+3
View File
@@ -251,6 +251,9 @@ export const settings = {
showTrayIconDesc: '在系统通知区域 / 菜单栏显示 Psysonic 图标。',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
startMinimizedToTray: '启动时最小化到托盘',
startMinimizedToTrayDesc: '启动时隐藏主窗口;通过托盘图标显示窗口。',
startMinimizedToTrayRequiresTray: '请先启用“显示托盘图标”——隐藏启动后需要通过托盘打开窗口。',
clockFormat: '时间格式',
clockFormatDesc: '队列预计结束时间和睡眠定时器预览使用的时间格式。',
clockFormatAuto: '自动(跟随系统)',
+19
View File
@@ -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);
+1
View File
@@ -72,6 +72,7 @@ export const useAuthStore = create<AuthState>()(
libraryGridMaxColumns: DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
showTrayIcon: true,
minimizeToTray: false,
startMinimizedToTray: false,
clockFormat: 'auto',
showOrbitTrigger: true,
discordRichPresence: false,
+10
View File
@@ -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);
});
});
+3
View File
@@ -252,6 +252,9 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
return {
...mediaDirMigrated,
...musicNetworkMigrated,
...(state.startMinimizedToTray && state.showTrayIcon === false
? { startMinimizedToTray: false as const }
: {}),
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
+3
View File
@@ -188,6 +188,8 @@ export interface AuthState {
libraryGridMaxColumns: number;
showTrayIcon: boolean;
minimizeToTray: boolean;
/** Cold start: keep the main window hidden and run in the system tray. */
startMinimizedToTray: boolean;
clockFormat: ClockFormat;
/** Whether the "Orbit" topbar trigger is rendered. Users who never
* touch Orbit can hide it so the header stays uncluttered. */
@@ -416,6 +418,7 @@ export interface AuthState {
setLibraryGridMaxColumns: (v: number) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setStartMinimizedToTray: (v: boolean) => void;
setClockFormat: (v: ClockFormat) => void;
setShowOrbitTrigger: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
+9 -1
View File
@@ -17,6 +17,7 @@ export function createUiAppearanceActions(set: SetState): Pick<
| 'setLibraryGridMaxColumns'
| 'setShowTrayIcon'
| 'setMinimizeToTray'
| 'setStartMinimizedToTray'
| 'setClockFormat'
| 'setShowOrbitTrigger'
| 'setUseCustomTitlebar'
@@ -45,8 +46,15 @@ export function createUiAppearanceActions(set: SetState): Pick<
setShowArtistImages: (v) => set({ showArtistImages: v }),
setArtistBrowseCreditMode: (v) => set({ artistBrowseCreditMode: v === 'track' ? 'track' : 'album' }),
setLibraryGridMaxColumns: (v) => set({ libraryGridMaxColumns: clampLibraryGridMaxColumns(v) }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setShowTrayIcon: (v) => set({
showTrayIcon: v,
...(v ? {} : { startMinimizedToTray: false }),
}),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setStartMinimizedToTray: (v) => set((state) => ({
startMinimizedToTray: v,
...(v && !state.showTrayIcon ? { showTrayIcon: true } : {}),
})),
setClockFormat: (v) => set({ clockFormat: v }),
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
+1
View File
@@ -4,6 +4,7 @@ declare global {
interface Window {
__psyHidden?: boolean;
__psyBlurred?: boolean;
__psyStartMinimizedToTray?: boolean;
}
}