From c7d76af790a1ce0e62b09f1d3c34c5b3abb0dae4 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:13:26 +0200 Subject: [PATCH] feat(themes): add follow-system mode to the theme scheduler (#1163) * feat(themes): add follow-system mode to the theme scheduler The theme scheduler can now switch the day/night theme pair by the OS light/dark preference instead of a clock schedule. A segmented control picks the trigger; in system mode the time inputs are hidden and the two theme pickers read as Light/Dark. The OS theme is read via the native Tauri window theme API (theme() + onThemeChanged) rather than the Web prefers-color-scheme media query, which is unreliable through WebKitGTK on Linux. Live updates land instantly where the platform forwards them; on Linux setups that don't, a hint notes the change applies after an app restart. * docs(changelog): add follow-system theme mode entry (#1163) CHANGELOG Added entry, contributor credit and What's New highlight for the theme scheduler's new system-theme mode. --- CHANGELOG.md | 6 ++ WHATS_NEW.md | 4 ++ src/app/bootstrap.ts | 25 ++++++--- src/components/settings/ThemesTab.tsx | 77 +++++++++++++++++++------- src/config/settingsCredits.ts | 1 + src/hooks/useSystemPrefersDark.ts | 77 ++++++++++++++++++++++++++ src/hooks/useThemeScheduler.ts | 32 +++++++---- src/locales/de/settings.ts | 8 ++- src/locales/en/settings.ts | 8 ++- src/locales/es/settings.ts | 8 ++- src/locales/fr/settings.ts | 8 ++- src/locales/hu/settings.ts | 8 ++- src/locales/ja/settings.ts | 8 ++- src/locales/nb/settings.ts | 8 ++- src/locales/nl/settings.ts | 8 ++- src/locales/ro/settings.ts | 8 ++- src/locales/ru/settings.ts | 8 ++- src/locales/zh/settings.ts | 8 ++- src/store/themeStore.test.ts | 79 +++++++++++++++++++++++++++ src/store/themeStore.ts | 18 +++++- 20 files changed, 356 insertions(+), 51 deletions(-) create mode 100644 src/hooks/useSystemPrefersDark.ts create mode 100644 src/store/themeStore.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea575e95..6a549fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * In an Orbit session the host's track-transition settings — crossfade, gapless or AutoDJ, including the crossfade length and smooth-skip — now apply to everyone, so guests blend between tracks the same way the host does instead of each person using their own. Your own settings are restored when you leave. * While you are a guest in a session, the transition controls in Settings → Audio and the queue toolbar are shown as host-controlled. +### Theme scheduler — follow the system theme + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1163](https://github.com/Psychotoxical/psysonic/pull/1163)**, suggested by [@mokazemi](https://github.com/mokazemi) + +* The theme scheduler can now switch your day/night theme pair based on your operating system's light/dark setting, in addition to the existing time-of-day schedule. Pick the trigger with a new Time of Day / System Theme switch; in system mode the two pickers read as Light and Dark theme. On Linux setups where the OS does not signal the change live, a hint notes it applies after restarting the app. + ## Changed diff --git a/WHATS_NEW.md b/WHATS_NEW.md index e80a8744..20e2ee0e 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -42,6 +42,10 @@ Within each section, order by **user impact** (most noticeable first) — not PR - Turn on **Remember EQ per device** under **Settings → Audio** and Psysonic keeps a separate equalizer setup for each output — speakers, headphones, a USB DAC — and switches to the right one automatically when you change devices. Off by default. +### Themes — follow your system's light and dark mode + +- The theme scheduler can now match your **system's light/dark setting** instead of a fixed clock: pick a light theme and a dark one, and Psysonic switches along with your OS. Choose **Time of Day** or **System Theme** under **Settings → Themes** — the existing time-based schedule is still there. + ## Fixed ### Playback and audio diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index ef439772..3db7cded 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -69,14 +69,23 @@ export function applyThemeAtStartup(): void { const s = parsed.state; if (!s) return; syncInjectedThemes(readInstalledThemes()); - const effective = getScheduledTheme({ - enableThemeScheduler: !!s.enableThemeScheduler, - theme: String(s.theme ?? 'mocha'), - themeDay: String(s.themeDay ?? 'latte'), - themeNight: String(s.themeNight ?? 'mocha'), - timeDayStart: String(s.timeDayStart ?? '07:00'), - timeNightStart: String(s.timeNightStart ?? '19:00'), - }); + // First-frame best effort for the "follow system" mode: the Web media query + // is sync here (the native Tauri theme resolves only after mount, when the + // App effect re-applies the effective theme). Unreliable on Linux WebKitGTK, + // but only affects this initial paint before the effect corrects it. + const systemPrefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; + const effective = getScheduledTheme( + { + enableThemeScheduler: !!s.enableThemeScheduler, + schedulerMode: s.schedulerMode === 'system' ? 'system' : 'time', + theme: String(s.theme ?? 'mocha'), + themeDay: String(s.themeDay ?? 'latte'), + themeNight: String(s.themeNight ?? 'mocha'), + timeDayStart: String(s.timeDayStart ?? '07:00'), + timeNightStart: String(s.timeNightStart ?? '19:00'), + }, + systemPrefersDark, + ); if (effective) document.documentElement.setAttribute('data-theme', effective); } catch { // Non-fatal — App's effects apply the theme after mount. diff --git a/src/components/settings/ThemesTab.tsx b/src/components/settings/ThemesTab.tsx index 83a7deeb..f3303074 100644 --- a/src/components/settings/ThemesTab.tsx +++ b/src/components/settings/ThemesTab.tsx @@ -92,31 +92,66 @@ export function ThemesTab() { const dayM = theme.timeDayStart.split(':')[1]; const nightH = theme.timeNightStart.split(':')[0]; const nightM = theme.timeNightStart.split(':')[1]; + const isSystem = theme.schedulerMode === 'system'; return ( -
-
- - -
-
- -
- theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} /> - : - theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} /> +
+
+ +
+ +
-
- - -
-
- -
- theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} /> - : - theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} /> + {isSystem && ( +
+ {t('settings.themeSchedulerSystemRestartHint')}
+ )} +
+
+ + +
+ {!isSystem && ( +
+ +
+ theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} /> + : + theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} /> +
+
+ )} +
+ + +
+ {!isSystem && ( +
+ +
+ theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} /> + : + theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} /> +
+
+ )}
); diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 7d09e382..c90abb8b 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -383,6 +383,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Artist artwork from fanart.tv — opt-in fullscreen background + artist-header banner, off by default (PR #1137)', 'Per-device equalizer profiles — opt-in, remembers and restores the EQ for each audio output device (PR #1146)', 'Hungarian (Magyar) translation (PR #1149)', + 'Theme scheduler — follow the OS light/dark setting as an alternative to the time-based day/night schedule (PR #1163)', ], }, { diff --git a/src/hooks/useSystemPrefersDark.ts b/src/hooks/useSystemPrefersDark.ts new file mode 100644 index 00000000..ec361d09 --- /dev/null +++ b/src/hooks/useSystemPrefersDark.ts @@ -0,0 +1,77 @@ +import { useSyncExternalStore } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; + +/** + * Tracks whether the OS prefers a dark color scheme, for the "follow system" + * theme scheduler mode. + * + * The native Tauri window theme API is the primary source: on Linux the Web + * `prefers-color-scheme` media query is unreliable through WebKitGTK (Tauri + * does not reliably forward the system preference — it tends to report `light` + * regardless), whereas `theme()` / `onThemeChanged` read the OS theme natively + * (via the XDG portal on Linux). `matchMedia` is only a fallback for the + * frontend-only dev shell where the Tauri IPC is absent. + * + * `theme()` resolves the OS theme correctly at startup; `onThemeChanged` gives + * an instant live update where the platform forwards it (macOS / Windows). On + * some Linux setups (tao does not forward the portal change live) the event + * never fires, so a light/dark flip is only reflected after an app restart — + * the UI surfaces that note in the system scheduler mode. + */ + +let isDark = false; +let started = false; +const listeners = new Set<() => void>(); + +function emit(): void { + for (const l of listeners) l(); +} + +function setDark(next: boolean): void { + if (next === isDark) return; + isDark = next; + emit(); +} + +/** Begin watching once the first subscriber mounts (idempotent). */ +function start(): void { + if (started) return; + started = true; + + void (async () => { + try { + const win = getCurrentWindow(); + setDark((await win.theme()) === 'dark'); + // Instant updates where the platform forwards them (macOS / Windows). + await win.onThemeChanged(({ payload }) => setDark(payload === 'dark')); + } catch { + // No Tauri IPC (frontend-only dev) — best-effort Web fallback. + try { + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + setDark(mql.matches); + mql.addEventListener('change', (e) => setDark(e.matches)); + } catch { + /* default: light */ + } + } + })(); +} + +function subscribe(cb: () => void): () => void { + listeners.add(cb); + start(); + return () => listeners.delete(cb); +} + +function getSnapshot(): boolean { + return isDark; +} + +function getServerSnapshot(): boolean { + return false; +} + +/** `true` when the OS theme is dark. Updates live on OS theme changes. */ +export function useSystemPrefersDark(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/src/hooks/useThemeScheduler.ts b/src/hooks/useThemeScheduler.ts index 0287bd4f..568d6398 100644 --- a/src/hooks/useThemeScheduler.ts +++ b/src/hooks/useThemeScheduler.ts @@ -1,34 +1,46 @@ import { useEffect, useState } from 'react'; import { useThemeStore, getScheduledTheme } from '../store/themeStore'; +import { useSystemPrefersDark } from './useSystemPrefersDark'; /** * Effective theme id for `data-theme` — scheduler-aware when enabled. * Derived synchronously from the store so `App.tsx` never paints a stale id * (the previous useState+mirror lag could leave `data-theme` on Mocha for a * commit cycle in production React). + * + * Two trigger modes: a clock schedule (re-evaluated each minute) or the OS + * theme (re-evaluated whenever the system flips light/dark via + * `useSystemPrefersDark`). */ export function useThemeScheduler(): string { const enableScheduler = useThemeStore(s => s.enableThemeScheduler); + const schedulerMode = useThemeStore(s => s.schedulerMode); const theme = useThemeStore(s => s.theme); const themeDay = useThemeStore(s => s.themeDay); const themeNight = useThemeStore(s => s.themeNight); const timeDayStart = useThemeStore(s => s.timeDayStart); const timeNightStart = useThemeStore(s => s.timeNightStart); + const systemPrefersDark = useSystemPrefersDark(); const [tick, setTick] = useState(0); useEffect(() => { - if (!enableScheduler) return; + // Only the clock mode needs polling; the system mode is event-driven. + if (!enableScheduler || schedulerMode !== 'time') return; const id = setInterval(() => setTick(n => n + 1), 60_000); return () => clearInterval(id); - }, [enableScheduler, themeDay, themeNight, timeDayStart, timeNightStart]); + }, [enableScheduler, schedulerMode, themeDay, themeNight, timeDayStart, timeNightStart]); void tick; - return getScheduledTheme({ - enableThemeScheduler: enableScheduler, - theme, - themeDay, - themeNight, - timeDayStart, - timeNightStart, - }); + return getScheduledTheme( + { + enableThemeScheduler: enableScheduler, + schedulerMode, + theme, + themeDay, + themeNight, + timeDayStart, + timeNightStart, + }, + systemPrefersDark, + ); } diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 6e50e427..e81e3d63 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -650,12 +650,18 @@ export const settings = { seekbarRetrotape: 'Retro-Band', themeSchedulerTitle: 'Theme-Zeitplan', themeSchedulerEnable: 'Theme-Zeitplan aktivieren', - themeSchedulerEnableSub: 'Wechselt automatisch zwischen zwei Themes basierend auf der Uhrzeit', + themeSchedulerEnableSub: 'Wechselt automatisch zwischen zwei Themes – nach Uhrzeit oder System-Theme', themeSchedulerDayTheme: 'Tages-Theme', themeSchedulerDayStart: 'Tag beginnt um', themeSchedulerNightTheme: 'Nacht-Theme', themeSchedulerNightStart: 'Nacht beginnt um', themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.', + themeSchedulerModeLabel: 'Wechseln nach', + themeSchedulerModeTime: 'Uhrzeit', + themeSchedulerModeSystem: 'System-Theme', + themeSchedulerLightTheme: 'Helles Theme', + themeSchedulerDarkTheme: 'Dunkles Theme', + themeSchedulerSystemRestartHint: 'Ein Wechsel des System-Designs (hell/dunkel) wird erst nach einem Neustart der App übernommen.', visualOptionsTitle: 'Visuelle Optionen', coverArtBackground: 'Cover-Hintergrund', coverArtBackgroundSub: 'Zeigt verschwommenes Cover als Hintergrund in Album/Playlist-Kopfzeilen', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index fc03575d..256b6e31 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -717,12 +717,18 @@ export const settings = { seekbarRetrotape: 'Retro Tape', themeSchedulerTitle: 'Auto-Switch Theme', themeSchedulerEnable: 'Enable Theme Scheduler', - themeSchedulerEnableSub: 'Automatically switch between two themes based on the time of day', + themeSchedulerEnableSub: 'Automatically switch between two themes based on the time of day or your system theme', themeSchedulerDayTheme: 'Day Theme', themeSchedulerDayStart: 'Day Starts At', themeSchedulerNightTheme: 'Night Theme', themeSchedulerNightStart: 'Night Starts At', themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.', + themeSchedulerModeLabel: 'Switch Based On', + themeSchedulerModeTime: 'Time of Day', + themeSchedulerModeSystem: 'System Theme', + themeSchedulerLightTheme: 'Light Theme', + themeSchedulerDarkTheme: 'Dark Theme', + themeSchedulerSystemRestartHint: 'Changing your system light/dark setting takes effect after restarting the app.', visualOptionsTitle: 'Visual Options', coverArtBackground: 'Cover Art Background', coverArtBackgroundSub: 'Show blurred cover art as background in album/playlist headers', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 7b2589c1..a5ac29e1 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -649,12 +649,18 @@ export const settings = { seekbarRetrotape: 'Cinta Retro', themeSchedulerTitle: 'Cambio Automático de Tema', themeSchedulerEnable: 'Habilitar Programador de Temas', - themeSchedulerEnableSub: 'Cambia automáticamente entre dos temas según la hora del día', + themeSchedulerEnableSub: 'Cambia automáticamente entre dos temas según la hora del día o el tema del sistema', themeSchedulerDayTheme: 'Tema de Día', themeSchedulerDayStart: 'Comienza de Día A las', themeSchedulerNightTheme: 'Tema de Noche', themeSchedulerNightStart: 'Comienza de Noche A las', themeSchedulerActiveHint: 'El Programador de Temas está activo — los cambios de tema se manejan automáticamente.', + themeSchedulerModeLabel: 'Cambiar según', + themeSchedulerModeTime: 'Hora del día', + themeSchedulerModeSystem: 'Tema del sistema', + themeSchedulerLightTheme: 'Tema claro', + themeSchedulerDarkTheme: 'Tema oscuro', + themeSchedulerSystemRestartHint: 'Cambiar el ajuste claro/oscuro del sistema se aplica tras reiniciar la aplicación.', visualOptionsTitle: 'Opciones Visuales', coverArtBackground: 'Fondo de Portada', coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 6a8d38e8..958482d2 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -637,12 +637,18 @@ export const settings = { seekbarRetrotape: 'Bande rétro', themeSchedulerTitle: 'Planificateur de thème', themeSchedulerEnable: 'Activer le planificateur de thème', - themeSchedulerEnableSub: 'Bascule automatiquement entre deux thèmes selon l\'heure', + themeSchedulerEnableSub: 'Bascule automatiquement entre deux thèmes selon l\'heure ou le thème du système', themeSchedulerDayTheme: 'Thème de jour', themeSchedulerDayStart: 'Début du jour', themeSchedulerNightTheme: 'Thème de nuit', themeSchedulerNightStart: 'Début de la nuit', themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.', + themeSchedulerModeLabel: 'Basculer selon', + themeSchedulerModeTime: 'Heure', + themeSchedulerModeSystem: 'Thème du système', + themeSchedulerLightTheme: 'Thème clair', + themeSchedulerDarkTheme: 'Thème sombre', + themeSchedulerSystemRestartHint: 'Le changement du thème clair/sombre du système prend effet après le redémarrage de l\'application.', visualOptionsTitle: 'Options Visuelles', coverArtBackground: "Fond d'Art de Poche", coverArtBackgroundSub: "Afficher la pochette floutée comme fond dans les en-têtes d'albums et de playlists", diff --git a/src/locales/hu/settings.ts b/src/locales/hu/settings.ts index eb438536..ec15269f 100644 --- a/src/locales/hu/settings.ts +++ b/src/locales/hu/settings.ts @@ -717,12 +717,18 @@ export const settings = { seekbarRetrotape: 'Retro szalag', themeSchedulerTitle: 'Automatikus témaváltás', themeSchedulerEnable: 'Témaütemező engedélyezése', - themeSchedulerEnableSub: 'Automatikus váltás két téma között a napszak alapján', + themeSchedulerEnableSub: 'Automatikus váltás két téma között a napszak vagy a rendszertéma alapján', themeSchedulerDayTheme: 'Nappali téma', themeSchedulerDayStart: 'A nappal kezdődik', themeSchedulerNightTheme: 'Éjszakai téma', themeSchedulerNightStart: 'Az éjszaka kezdődik', themeSchedulerActiveHint: 'A témaütemező aktív – a témaváltások automatikusan kezeltek.', + themeSchedulerModeLabel: 'Váltás alapja', + themeSchedulerModeTime: 'Napszak', + themeSchedulerModeSystem: 'Rendszertéma', + themeSchedulerLightTheme: 'Világos téma', + themeSchedulerDarkTheme: 'Sötét téma', + themeSchedulerSystemRestartHint: 'A rendszer világos/sötét beállításának módosítása az alkalmazás újraindítása után lép életbe.', visualOptionsTitle: 'Vizuális beállítások', coverArtBackground: 'Borító háttér', coverArtBackgroundSub: 'Elmosott borító megjelenítése háttérként az album/lejátszási lista fejlécekben', diff --git a/src/locales/ja/settings.ts b/src/locales/ja/settings.ts index ef679926..9a8a3f7f 100644 --- a/src/locales/ja/settings.ts +++ b/src/locales/ja/settings.ts @@ -711,12 +711,18 @@ export const settings = { seekbarRetrotape: 'Retro Tape', themeSchedulerTitle: 'テーマ自動切り替え', themeSchedulerEnable: 'テーマスケジューラーを有効化', - themeSchedulerEnableSub: '時刻に基づいて 2 つのテーマを自動で切り替えます', + themeSchedulerEnableSub: '時刻またはシステムテーマに基づいて 2 つのテーマを自動で切り替えます', themeSchedulerDayTheme: '昼テーマ', themeSchedulerDayStart: '昼の開始時刻', themeSchedulerNightTheme: '夜テーマ', themeSchedulerNightStart: '夜の開始時刻', themeSchedulerActiveHint: 'テーマスケジューラーが有効です。テーマ変更は自動で管理されます。', + themeSchedulerModeLabel: '切り替え基準', + themeSchedulerModeTime: '時刻', + themeSchedulerModeSystem: 'システムテーマ', + themeSchedulerLightTheme: 'ライトテーマ', + themeSchedulerDarkTheme: 'ダークテーマ', + themeSchedulerSystemRestartHint: 'システムのライト/ダーク設定の変更は、アプリの再起動後に反映されます。', visualOptionsTitle: '表示オプション', coverArtBackground: 'カバーアート背景', coverArtBackgroundSub: 'アルバム/プレイリストヘッダーの背景にぼかしたカバーアートを表示', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 17b0577a..14665fc1 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -636,12 +636,18 @@ export const settings = { seekbarRetrotape: 'Retrotape', themeSchedulerTitle: 'Tidsplanlagt tema', themeSchedulerEnable: 'Aktiver temaplanlegger', - themeSchedulerEnableSub: 'Bytter automatisk mellom to temaer basert på tidspunkt', + themeSchedulerEnableSub: 'Bytter automatisk mellom to temaer basert på tidspunkt eller systemtemaet', themeSchedulerDayTheme: 'Dagtema', themeSchedulerDayStart: 'Dag starter kl.', themeSchedulerNightTheme: 'Natttema', themeSchedulerNightStart: 'Natt starter kl.', themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.', + themeSchedulerModeLabel: 'Bytt basert på', + themeSchedulerModeTime: 'Tidspunkt', + themeSchedulerModeSystem: 'Systemtema', + themeSchedulerLightTheme: 'Lyst tema', + themeSchedulerDarkTheme: 'Mørkt tema', + themeSchedulerSystemRestartHint: 'Endring av systemets lys/mørk-innstilling trer i kraft etter omstart av appen.', visualOptionsTitle: 'Visuelle Alternativer', coverArtBackground: 'Cover-bakgrunn', coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 0206b4d1..6a628f36 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -637,12 +637,18 @@ export const settings = { seekbarRetrotape: 'Retrotape', themeSchedulerTitle: 'Thema-planner', themeSchedulerEnable: 'Thema-planner inschakelen', - themeSchedulerEnableSub: 'Schakelt automatisch tussen twee thema\'s op basis van de tijd', + themeSchedulerEnableSub: 'Schakelt automatisch tussen twee thema\'s op basis van de tijd of je systeemthema', themeSchedulerDayTheme: 'Dagthema', themeSchedulerDayStart: 'Dag begint om', themeSchedulerNightTheme: 'Nachtthema', themeSchedulerNightStart: 'Nacht begint om', themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.", + themeSchedulerModeLabel: 'Wisselen op basis van', + themeSchedulerModeTime: 'Tijd van de dag', + themeSchedulerModeSystem: 'Systeemthema', + themeSchedulerLightTheme: 'Licht thema', + themeSchedulerDarkTheme: 'Donker thema', + themeSchedulerSystemRestartHint: 'Het wijzigen van de licht/donker-instelling van je systeem wordt pas na het herstarten van de app toegepast.', visualOptionsTitle: 'Visuele Opties', coverArtBackground: 'Cover Achtergrond', coverArtBackgroundSub: 'Toon vervaagde cover als achtergrond in album/playlist headers', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index ef6197bc..4a0dac29 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -652,12 +652,18 @@ export const settings = { seekbarRetrotape: 'Bandă Retro', themeSchedulerTitle: 'Schimbă Tema automat', themeSchedulerEnable: 'Pornește temele programate', - themeSchedulerEnableSub: 'Schimbă automat între două teme pe baza orei din zi', + themeSchedulerEnableSub: 'Schimbă automat între două teme în funcție de ora din zi sau tema sistemului', themeSchedulerDayTheme: 'Temă de zi', themeSchedulerDayStart: 'Ziua începe la', themeSchedulerNightTheme: 'Temă de noapte', themeSchedulerNightStart: 'Noaptea începe la', themeSchedulerActiveHint: 'Temele programate sunt active - aceste schimbări de teme sunt gestionate automat.', + themeSchedulerModeLabel: 'Comută în funcție de', + themeSchedulerModeTime: 'Ora din zi', + themeSchedulerModeSystem: 'Tema sistemului', + themeSchedulerLightTheme: 'Temă luminoasă', + themeSchedulerDarkTheme: 'Temă întunecată', + themeSchedulerSystemRestartHint: 'Schimbarea setării luminos/întunecat a sistemului are efect după repornirea aplicației.', visualOptionsTitle: 'Opțiuni vizuale', coverArtBackground: 'Arta de fundal a copertei', coverArtBackgroundSub: 'Arată arta de copertă blurată ca fundal în antetele albumelor/playlisturilor', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 9679dff5..ed36e8a3 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -737,12 +737,18 @@ export const settings = { seekbarRetrotape: 'Ретро-лента', themeSchedulerTitle: 'Расписание тем', themeSchedulerEnable: 'Включить расписание тем', - themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток', + themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток или системной темы', themeSchedulerDayTheme: 'Дневная тема', themeSchedulerDayStart: 'День начинается в', themeSchedulerNightTheme: 'Ночная тема', themeSchedulerNightStart: 'Ночь начинается в', themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.', + themeSchedulerModeLabel: 'Переключать по', + themeSchedulerModeTime: 'Времени суток', + themeSchedulerModeSystem: 'Системной теме', + themeSchedulerLightTheme: 'Светлая тема', + themeSchedulerDarkTheme: 'Тёмная тема', + themeSchedulerSystemRestartHint: 'Изменение светлой/тёмной настройки системы вступит в силу после перезапуска приложения.', visualOptionsTitle: 'Визуальные Настройки', coverArtBackground: 'Фон Обложки', coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index eb65dce6..c190eaf4 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -636,12 +636,18 @@ export const settings = { seekbarRetrotape: '复古磁带', themeSchedulerTitle: '主题定时切换', themeSchedulerEnable: '启用主题定时器', - themeSchedulerEnableSub: '根据一天中的时间自动在两个主题之间切换', + themeSchedulerEnableSub: '根据一天中的时间或系统主题自动在两个主题之间切换', themeSchedulerDayTheme: '白天主题', themeSchedulerDayStart: '白天开始时间', themeSchedulerNightTheme: '夜晚主题', themeSchedulerNightStart: '夜晚开始时间', themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。', + themeSchedulerModeLabel: '切换依据', + themeSchedulerModeTime: '时间', + themeSchedulerModeSystem: '系统主题', + themeSchedulerLightTheme: '浅色主题', + themeSchedulerDarkTheme: '深色主题', + themeSchedulerSystemRestartHint: '更改系统的浅色/深色设置需重启应用后生效。', visualOptionsTitle: '视觉选项', coverArtBackground: '封面背景', coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景', diff --git a/src/store/themeStore.test.ts b/src/store/themeStore.test.ts new file mode 100644 index 00000000..8cf4e376 --- /dev/null +++ b/src/store/themeStore.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getScheduledTheme } from './themeStore'; + +type SchedState = Parameters[0]; + +function state(over: Partial = {}): SchedState { + return { + enableThemeScheduler: true, + schedulerMode: 'time', + theme: 'mocha', + themeDay: 'latte', + themeNight: 'mocha', + timeDayStart: '07:00', + timeNightStart: '19:00', + ...over, + }; +} + +/** Pin the wall clock to a fixed `HH:MM` for the time-mode cases. */ +function atTime(hh: number, mm = 0): void { + vi.useFakeTimers(); + const d = new Date(2026, 5, 23, hh, mm, 0); + vi.setSystemTime(d); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('getScheduledTheme', () => { + it('returns the plain theme when the scheduler is off', () => { + expect(getScheduledTheme(state({ enableThemeScheduler: false }))).toBe('mocha'); + }); + + describe('time mode', () => { + it('picks the day theme during the day window', () => { + atTime(12); + expect(getScheduledTheme(state())).toBe('latte'); + }); + + it('picks the night theme during the night window', () => { + atTime(22); + expect(getScheduledTheme(state())).toBe('mocha'); + }); + + it('treats the day-start edge as day and the night-start edge as night', () => { + atTime(7, 0); + expect(getScheduledTheme(state())).toBe('latte'); + atTime(19, 0); + expect(getScheduledTheme(state())).toBe('mocha'); + }); + + it('handles a window that wraps past midnight (day 22:00 → 06:00)', () => { + const s = state({ timeDayStart: '22:00', timeNightStart: '06:00' }); + atTime(23); + expect(getScheduledTheme(s)).toBe('latte'); // inside the wrapped day window + atTime(12); + expect(getScheduledTheme(s)).toBe('mocha'); // outside → night + }); + }); + + describe('system mode', () => { + it('follows the OS theme, ignoring the clock', () => { + atTime(3); // would be night in time mode — must not matter here + const s = state({ schedulerMode: 'system' }); + expect(getScheduledTheme(s, true)).toBe('mocha'); // dark → night theme + expect(getScheduledTheme(s, false)).toBe('latte'); // light → day theme + }); + + it('defaults to the day theme when the system preference is unknown', () => { + expect(getScheduledTheme(state({ schedulerMode: 'system' }))).toBe('latte'); + }); + + it('still returns the plain theme when the scheduler is off', () => { + expect(getScheduledTheme(state({ enableThemeScheduler: false, schedulerMode: 'system' }), true)).toBe('mocha'); + }); + }); +}); diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 59f797cb..99375b11 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -18,11 +18,17 @@ export type BuiltinTheme = */ export type Theme = BuiltinTheme | (string & {}); +/** Trigger for the day/night theme switch. */ +export type ThemeSchedulerMode = 'time' | 'system'; + interface ThemeState { theme: Theme; setTheme: (theme: Theme) => void; enableThemeScheduler: boolean; setEnableThemeScheduler: (v: boolean) => void; + /** What drives the day/night switch: a clock schedule or the OS theme. */ + schedulerMode: ThemeSchedulerMode; + setSchedulerMode: (v: ThemeSchedulerMode) => void; themeDay: string; setThemeDay: (v: string) => void; themeNight: string; @@ -51,8 +57,16 @@ interface ThemeState { setExternalArtworkByok: (v: string) => void; } -export function getScheduledTheme(state: Pick): string { +export function getScheduledTheme( + state: Pick< + ThemeState, + 'enableThemeScheduler' | 'schedulerMode' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart' + >, + systemPrefersDark = false, +): string { if (!state.enableThemeScheduler) return state.theme; + // Follow the OS theme: dark → night theme, light → day theme. + if (state.schedulerMode === 'system') return systemPrefersDark ? state.themeNight : state.themeDay; const now = new Date(); const nowMins = now.getHours() * 60 + now.getMinutes(); const [dh, dm] = state.timeDayStart.split(':').map(Number); @@ -72,6 +86,8 @@ export const useThemeStore = create()( setTheme: (theme) => set({ theme }), enableThemeScheduler: false, setEnableThemeScheduler: (v) => set({ enableThemeScheduler: v }), + schedulerMode: 'time', + setSchedulerMode: (v) => set({ schedulerMode: v }), themeDay: 'latte', setThemeDay: (v) => set({ themeDay: v }), themeNight: 'mocha',