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.
This commit is contained in:
Psychotoxical
2026-06-23 12:13:26 +02:00
committed by GitHub
parent 78a177b1bc
commit c7d76af790
20 changed files with 356 additions and 51 deletions
+6
View File
@@ -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. * 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. * 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 ## Changed
+4
View File
@@ -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. - 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 ## Fixed
### Playback and audio ### Playback and audio
+17 -8
View File
@@ -69,14 +69,23 @@ export function applyThemeAtStartup(): void {
const s = parsed.state; const s = parsed.state;
if (!s) return; if (!s) return;
syncInjectedThemes(readInstalledThemes()); syncInjectedThemes(readInstalledThemes());
const effective = getScheduledTheme({ // First-frame best effort for the "follow system" mode: the Web media query
enableThemeScheduler: !!s.enableThemeScheduler, // is sync here (the native Tauri theme resolves only after mount, when the
theme: String(s.theme ?? 'mocha'), // App effect re-applies the effective theme). Unreliable on Linux WebKitGTK,
themeDay: String(s.themeDay ?? 'latte'), // but only affects this initial paint before the effect corrects it.
themeNight: String(s.themeNight ?? 'mocha'), const systemPrefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
timeDayStart: String(s.timeDayStart ?? '07:00'), const effective = getScheduledTheme(
timeNightStart: String(s.timeNightStart ?? '19:00'), {
}); 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); if (effective) document.documentElement.setAttribute('data-theme', effective);
} catch { } catch {
// Non-fatal — App's effects apply the theme after mount. // Non-fatal — App's effects apply the theme after mount.
+56 -21
View File
@@ -92,31 +92,66 @@ export function ThemesTab() {
const dayM = theme.timeDayStart.split(':')[1]; const dayM = theme.timeDayStart.split(':')[1];
const nightH = theme.timeNightStart.split(':')[0]; const nightH = theme.timeNightStart.split(':')[0];
const nightM = theme.timeNightStart.split(':')[1]; const nightM = theme.timeNightStart.split(':')[1];
const isSystem = theme.schedulerMode === 'system';
return ( return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem', marginTop: '1rem' }}> <div style={{ marginTop: '1rem' }}>
<div className="form-group"> <div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayTheme')}</label> <label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerModeLabel')}</label>
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} /> <div className="settings-segmented">
</div> <button
<div className="form-group"> type="button"
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayStart')}</label> className={`btn ${!isSystem ? 'btn-primary' : 'btn-ghost'}`}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}> onClick={() => theme.setSchedulerMode('time')}
<CustomSelect value={dayH} onChange={v => theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} /> >
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span> {t('settings.themeSchedulerModeTime')}
<CustomSelect value={dayM} onChange={v => theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} /> </button>
<button
type="button"
className={`btn ${isSystem ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => theme.setSchedulerMode('system')}
>
{t('settings.themeSchedulerModeSystem')}
</button>
</div> </div>
</div> </div>
<div className="form-group"> {isSystem && (
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightTheme')}</label> <div className="settings-hint settings-hint-info" style={{ marginBottom: '1rem' }}>
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} /> {t('settings.themeSchedulerSystemRestartHint')}
</div>
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightStart')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<CustomSelect value={nightH} onChange={v => theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} />
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
<CustomSelect value={nightM} onChange={v => theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} />
</div> </div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem' }}>
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>
{isSystem ? t('settings.themeSchedulerLightTheme') : t('settings.themeSchedulerDayTheme')}
</label>
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
</div>
{!isSystem && (
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayStart')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<CustomSelect value={dayH} onChange={v => theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} />
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
<CustomSelect value={dayM} onChange={v => theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} />
</div>
</div>
)}
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>
{isSystem ? t('settings.themeSchedulerDarkTheme') : t('settings.themeSchedulerNightTheme')}
</label>
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
</div>
{!isSystem && (
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightStart')}</label>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<CustomSelect value={nightH} onChange={v => theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} />
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
<CustomSelect value={nightM} onChange={v => theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} />
</div>
</div>
)}
</div> </div>
</div> </div>
); );
+1
View File
@@ -383,6 +383,7 @@ const CONTRIBUTOR_ENTRIES = [
'Artist artwork from fanart.tv — opt-in fullscreen background + artist-header banner, off by default (PR #1137)', '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)', 'Per-device equalizer profiles — opt-in, remembers and restores the EQ for each audio output device (PR #1146)',
'Hungarian (Magyar) translation (PR #1149)', '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)',
], ],
}, },
{ {
+77
View File
@@ -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);
}
+22 -10
View File
@@ -1,34 +1,46 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useThemeStore, getScheduledTheme } from '../store/themeStore'; import { useThemeStore, getScheduledTheme } from '../store/themeStore';
import { useSystemPrefersDark } from './useSystemPrefersDark';
/** /**
* Effective theme id for `data-theme` — scheduler-aware when enabled. * Effective theme id for `data-theme` — scheduler-aware when enabled.
* Derived synchronously from the store so `App.tsx` never paints a stale id * 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 * (the previous useState+mirror lag could leave `data-theme` on Mocha for a
* commit cycle in production React). * 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 { export function useThemeScheduler(): string {
const enableScheduler = useThemeStore(s => s.enableThemeScheduler); const enableScheduler = useThemeStore(s => s.enableThemeScheduler);
const schedulerMode = useThemeStore(s => s.schedulerMode);
const theme = useThemeStore(s => s.theme); const theme = useThemeStore(s => s.theme);
const themeDay = useThemeStore(s => s.themeDay); const themeDay = useThemeStore(s => s.themeDay);
const themeNight = useThemeStore(s => s.themeNight); const themeNight = useThemeStore(s => s.themeNight);
const timeDayStart = useThemeStore(s => s.timeDayStart); const timeDayStart = useThemeStore(s => s.timeDayStart);
const timeNightStart = useThemeStore(s => s.timeNightStart); const timeNightStart = useThemeStore(s => s.timeNightStart);
const systemPrefersDark = useSystemPrefersDark();
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
useEffect(() => { 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); const id = setInterval(() => setTick(n => n + 1), 60_000);
return () => clearInterval(id); return () => clearInterval(id);
}, [enableScheduler, themeDay, themeNight, timeDayStart, timeNightStart]); }, [enableScheduler, schedulerMode, themeDay, themeNight, timeDayStart, timeNightStart]);
void tick; void tick;
return getScheduledTheme({ return getScheduledTheme(
enableThemeScheduler: enableScheduler, {
theme, enableThemeScheduler: enableScheduler,
themeDay, schedulerMode,
themeNight, theme,
timeDayStart, themeDay,
timeNightStart, themeNight,
}); timeDayStart,
timeNightStart,
},
systemPrefersDark,
);
} }
+7 -1
View File
@@ -650,12 +650,18 @@ export const settings = {
seekbarRetrotape: 'Retro-Band', seekbarRetrotape: 'Retro-Band',
themeSchedulerTitle: 'Theme-Zeitplan', themeSchedulerTitle: 'Theme-Zeitplan',
themeSchedulerEnable: 'Theme-Zeitplan aktivieren', 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', themeSchedulerDayTheme: 'Tages-Theme',
themeSchedulerDayStart: 'Tag beginnt um', themeSchedulerDayStart: 'Tag beginnt um',
themeSchedulerNightTheme: 'Nacht-Theme', themeSchedulerNightTheme: 'Nacht-Theme',
themeSchedulerNightStart: 'Nacht beginnt um', themeSchedulerNightStart: 'Nacht beginnt um',
themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.', 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', visualOptionsTitle: 'Visuelle Optionen',
coverArtBackground: 'Cover-Hintergrund', coverArtBackground: 'Cover-Hintergrund',
coverArtBackgroundSub: 'Zeigt verschwommenes Cover als Hintergrund in Album/Playlist-Kopfzeilen', coverArtBackgroundSub: 'Zeigt verschwommenes Cover als Hintergrund in Album/Playlist-Kopfzeilen',
+7 -1
View File
@@ -717,12 +717,18 @@ export const settings = {
seekbarRetrotape: 'Retro Tape', seekbarRetrotape: 'Retro Tape',
themeSchedulerTitle: 'Auto-Switch Theme', themeSchedulerTitle: 'Auto-Switch Theme',
themeSchedulerEnable: 'Enable Theme Scheduler', 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', themeSchedulerDayTheme: 'Day Theme',
themeSchedulerDayStart: 'Day Starts At', themeSchedulerDayStart: 'Day Starts At',
themeSchedulerNightTheme: 'Night Theme', themeSchedulerNightTheme: 'Night Theme',
themeSchedulerNightStart: 'Night Starts At', themeSchedulerNightStart: 'Night Starts At',
themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.', 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', visualOptionsTitle: 'Visual Options',
coverArtBackground: 'Cover Art Background', coverArtBackground: 'Cover Art Background',
coverArtBackgroundSub: 'Show blurred cover art as background in album/playlist headers', coverArtBackgroundSub: 'Show blurred cover art as background in album/playlist headers',
+7 -1
View File
@@ -649,12 +649,18 @@ export const settings = {
seekbarRetrotape: 'Cinta Retro', seekbarRetrotape: 'Cinta Retro',
themeSchedulerTitle: 'Cambio Automático de Tema', themeSchedulerTitle: 'Cambio Automático de Tema',
themeSchedulerEnable: 'Habilitar Programador de Temas', 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', themeSchedulerDayTheme: 'Tema de Día',
themeSchedulerDayStart: 'Comienza de Día A las', themeSchedulerDayStart: 'Comienza de Día A las',
themeSchedulerNightTheme: 'Tema de Noche', themeSchedulerNightTheme: 'Tema de Noche',
themeSchedulerNightStart: 'Comienza de Noche A las', themeSchedulerNightStart: 'Comienza de Noche A las',
themeSchedulerActiveHint: 'El Programador de Temas está activo — los cambios de tema se manejan automáticamente.', 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', visualOptionsTitle: 'Opciones Visuales',
coverArtBackground: 'Fondo de Portada', coverArtBackground: 'Fondo de Portada',
coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción', coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción',
+7 -1
View File
@@ -637,12 +637,18 @@ export const settings = {
seekbarRetrotape: 'Bande rétro', seekbarRetrotape: 'Bande rétro',
themeSchedulerTitle: 'Planificateur de thème', themeSchedulerTitle: 'Planificateur de thème',
themeSchedulerEnable: 'Activer le 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', themeSchedulerDayTheme: 'Thème de jour',
themeSchedulerDayStart: 'Début du jour', themeSchedulerDayStart: 'Début du jour',
themeSchedulerNightTheme: 'Thème de nuit', themeSchedulerNightTheme: 'Thème de nuit',
themeSchedulerNightStart: 'Début de la nuit', themeSchedulerNightStart: 'Début de la nuit',
themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.', 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', visualOptionsTitle: 'Options Visuelles',
coverArtBackground: "Fond d'Art de Poche", coverArtBackground: "Fond d'Art de Poche",
coverArtBackgroundSub: "Afficher la pochette floutée comme fond dans les en-têtes d'albums et de playlists", coverArtBackgroundSub: "Afficher la pochette floutée comme fond dans les en-têtes d'albums et de playlists",
+7 -1
View File
@@ -717,12 +717,18 @@ export const settings = {
seekbarRetrotape: 'Retro szalag', seekbarRetrotape: 'Retro szalag',
themeSchedulerTitle: 'Automatikus témaváltás', themeSchedulerTitle: 'Automatikus témaváltás',
themeSchedulerEnable: 'Témaütemező engedélyezése', 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', themeSchedulerDayTheme: 'Nappali téma',
themeSchedulerDayStart: 'A nappal kezdődik', themeSchedulerDayStart: 'A nappal kezdődik',
themeSchedulerNightTheme: 'Éjszakai téma', themeSchedulerNightTheme: 'Éjszakai téma',
themeSchedulerNightStart: 'Az éjszaka kezdődik', themeSchedulerNightStart: 'Az éjszaka kezdődik',
themeSchedulerActiveHint: 'A témaütemező aktív a témaváltások automatikusan kezeltek.', 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', visualOptionsTitle: 'Vizuális beállítások',
coverArtBackground: 'Borító háttér', 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', coverArtBackgroundSub: 'Elmosott borító megjelenítése háttérként az album/lejátszási lista fejlécekben',
+7 -1
View File
@@ -711,12 +711,18 @@ export const settings = {
seekbarRetrotape: 'Retro Tape', seekbarRetrotape: 'Retro Tape',
themeSchedulerTitle: 'テーマ自動切り替え', themeSchedulerTitle: 'テーマ自動切り替え',
themeSchedulerEnable: 'テーマスケジューラーを有効化', themeSchedulerEnable: 'テーマスケジューラーを有効化',
themeSchedulerEnableSub: '時刻に基づいて 2 つのテーマを自動で切り替えます', themeSchedulerEnableSub: '時刻またはシステムテーマに基づいて 2 つのテーマを自動で切り替えます',
themeSchedulerDayTheme: '昼テーマ', themeSchedulerDayTheme: '昼テーマ',
themeSchedulerDayStart: '昼の開始時刻', themeSchedulerDayStart: '昼の開始時刻',
themeSchedulerNightTheme: '夜テーマ', themeSchedulerNightTheme: '夜テーマ',
themeSchedulerNightStart: '夜の開始時刻', themeSchedulerNightStart: '夜の開始時刻',
themeSchedulerActiveHint: 'テーマスケジューラーが有効です。テーマ変更は自動で管理されます。', themeSchedulerActiveHint: 'テーマスケジューラーが有効です。テーマ変更は自動で管理されます。',
themeSchedulerModeLabel: '切り替え基準',
themeSchedulerModeTime: '時刻',
themeSchedulerModeSystem: 'システムテーマ',
themeSchedulerLightTheme: 'ライトテーマ',
themeSchedulerDarkTheme: 'ダークテーマ',
themeSchedulerSystemRestartHint: 'システムのライト/ダーク設定の変更は、アプリの再起動後に反映されます。',
visualOptionsTitle: '表示オプション', visualOptionsTitle: '表示オプション',
coverArtBackground: 'カバーアート背景', coverArtBackground: 'カバーアート背景',
coverArtBackgroundSub: 'アルバム/プレイリストヘッダーの背景にぼかしたカバーアートを表示', coverArtBackgroundSub: 'アルバム/プレイリストヘッダーの背景にぼかしたカバーアートを表示',
+7 -1
View File
@@ -636,12 +636,18 @@ export const settings = {
seekbarRetrotape: 'Retrotape', seekbarRetrotape: 'Retrotape',
themeSchedulerTitle: 'Tidsplanlagt tema', themeSchedulerTitle: 'Tidsplanlagt tema',
themeSchedulerEnable: 'Aktiver temaplanlegger', 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', themeSchedulerDayTheme: 'Dagtema',
themeSchedulerDayStart: 'Dag starter kl.', themeSchedulerDayStart: 'Dag starter kl.',
themeSchedulerNightTheme: 'Natttema', themeSchedulerNightTheme: 'Natttema',
themeSchedulerNightStart: 'Natt starter kl.', themeSchedulerNightStart: 'Natt starter kl.',
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.', 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', visualOptionsTitle: 'Visuelle Alternativer',
coverArtBackground: 'Cover-bakgrunn', coverArtBackground: 'Cover-bakgrunn',
coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter', coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter',
+7 -1
View File
@@ -637,12 +637,18 @@ export const settings = {
seekbarRetrotape: 'Retrotape', seekbarRetrotape: 'Retrotape',
themeSchedulerTitle: 'Thema-planner', themeSchedulerTitle: 'Thema-planner',
themeSchedulerEnable: 'Thema-planner inschakelen', 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', themeSchedulerDayTheme: 'Dagthema',
themeSchedulerDayStart: 'Dag begint om', themeSchedulerDayStart: 'Dag begint om',
themeSchedulerNightTheme: 'Nachtthema', themeSchedulerNightTheme: 'Nachtthema',
themeSchedulerNightStart: 'Nacht begint om', themeSchedulerNightStart: 'Nacht begint om',
themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.", 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', visualOptionsTitle: 'Visuele Opties',
coverArtBackground: 'Cover Achtergrond', coverArtBackground: 'Cover Achtergrond',
coverArtBackgroundSub: 'Toon vervaagde cover als achtergrond in album/playlist headers', coverArtBackgroundSub: 'Toon vervaagde cover als achtergrond in album/playlist headers',
+7 -1
View File
@@ -652,12 +652,18 @@ export const settings = {
seekbarRetrotape: 'Bandă Retro', seekbarRetrotape: 'Bandă Retro',
themeSchedulerTitle: 'Schimbă Tema automat', themeSchedulerTitle: 'Schimbă Tema automat',
themeSchedulerEnable: 'Pornește temele programate', 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', themeSchedulerDayTheme: 'Temă de zi',
themeSchedulerDayStart: 'Ziua începe la', themeSchedulerDayStart: 'Ziua începe la',
themeSchedulerNightTheme: 'Temă de noapte', themeSchedulerNightTheme: 'Temă de noapte',
themeSchedulerNightStart: 'Noaptea începe la', themeSchedulerNightStart: 'Noaptea începe la',
themeSchedulerActiveHint: 'Temele programate sunt active - aceste schimbări de teme sunt gestionate automat.', 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', visualOptionsTitle: 'Opțiuni vizuale',
coverArtBackground: 'Arta de fundal a copertei', coverArtBackground: 'Arta de fundal a copertei',
coverArtBackgroundSub: 'Arată arta de copertă blurată ca fundal în antetele albumelor/playlisturilor', coverArtBackgroundSub: 'Arată arta de copertă blurată ca fundal în antetele albumelor/playlisturilor',
+7 -1
View File
@@ -737,12 +737,18 @@ export const settings = {
seekbarRetrotape: 'Ретро-лента', seekbarRetrotape: 'Ретро-лента',
themeSchedulerTitle: 'Расписание тем', themeSchedulerTitle: 'Расписание тем',
themeSchedulerEnable: 'Включить расписание тем', themeSchedulerEnable: 'Включить расписание тем',
themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток', themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток или системной темы',
themeSchedulerDayTheme: 'Дневная тема', themeSchedulerDayTheme: 'Дневная тема',
themeSchedulerDayStart: 'День начинается в', themeSchedulerDayStart: 'День начинается в',
themeSchedulerNightTheme: 'Ночная тема', themeSchedulerNightTheme: 'Ночная тема',
themeSchedulerNightStart: 'Ночь начинается в', themeSchedulerNightStart: 'Ночь начинается в',
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.', themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
themeSchedulerModeLabel: 'Переключать по',
themeSchedulerModeTime: 'Времени суток',
themeSchedulerModeSystem: 'Системной теме',
themeSchedulerLightTheme: 'Светлая тема',
themeSchedulerDarkTheme: 'Тёмная тема',
themeSchedulerSystemRestartHint: 'Изменение светлой/тёмной настройки системы вступит в силу после перезапуска приложения.',
visualOptionsTitle: 'Визуальные Настройки', visualOptionsTitle: 'Визуальные Настройки',
coverArtBackground: 'Фон Обложки', coverArtBackground: 'Фон Обложки',
coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов', coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов',
+7 -1
View File
@@ -636,12 +636,18 @@ export const settings = {
seekbarRetrotape: '复古磁带', seekbarRetrotape: '复古磁带',
themeSchedulerTitle: '主题定时切换', themeSchedulerTitle: '主题定时切换',
themeSchedulerEnable: '启用主题定时器', themeSchedulerEnable: '启用主题定时器',
themeSchedulerEnableSub: '根据一天中的时间自动在两个主题之间切换', themeSchedulerEnableSub: '根据一天中的时间或系统主题自动在两个主题之间切换',
themeSchedulerDayTheme: '白天主题', themeSchedulerDayTheme: '白天主题',
themeSchedulerDayStart: '白天开始时间', themeSchedulerDayStart: '白天开始时间',
themeSchedulerNightTheme: '夜晚主题', themeSchedulerNightTheme: '夜晚主题',
themeSchedulerNightStart: '夜晚开始时间', themeSchedulerNightStart: '夜晚开始时间',
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。', themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
themeSchedulerModeLabel: '切换依据',
themeSchedulerModeTime: '时间',
themeSchedulerModeSystem: '系统主题',
themeSchedulerLightTheme: '浅色主题',
themeSchedulerDarkTheme: '深色主题',
themeSchedulerSystemRestartHint: '更改系统的浅色/深色设置需重启应用后生效。',
visualOptionsTitle: '视觉选项', visualOptionsTitle: '视觉选项',
coverArtBackground: '封面背景', coverArtBackground: '封面背景',
coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景', coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景',
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getScheduledTheme } from './themeStore';
type SchedState = Parameters<typeof getScheduledTheme>[0];
function state(over: Partial<SchedState> = {}): 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');
});
});
});
+17 -1
View File
@@ -18,11 +18,17 @@ export type BuiltinTheme =
*/ */
export type Theme = BuiltinTheme | (string & {}); export type Theme = BuiltinTheme | (string & {});
/** Trigger for the day/night theme switch. */
export type ThemeSchedulerMode = 'time' | 'system';
interface ThemeState { interface ThemeState {
theme: Theme; theme: Theme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
enableThemeScheduler: boolean; enableThemeScheduler: boolean;
setEnableThemeScheduler: (v: boolean) => void; 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; themeDay: string;
setThemeDay: (v: string) => void; setThemeDay: (v: string) => void;
themeNight: string; themeNight: string;
@@ -51,8 +57,16 @@ interface ThemeState {
setExternalArtworkByok: (v: string) => void; setExternalArtworkByok: (v: string) => void;
} }
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string { export function getScheduledTheme(
state: Pick<
ThemeState,
'enableThemeScheduler' | 'schedulerMode' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'
>,
systemPrefersDark = false,
): string {
if (!state.enableThemeScheduler) return state.theme; 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 now = new Date();
const nowMins = now.getHours() * 60 + now.getMinutes(); const nowMins = now.getHours() * 60 + now.getMinutes();
const [dh, dm] = state.timeDayStart.split(':').map(Number); const [dh, dm] = state.timeDayStart.split(':').map(Number);
@@ -72,6 +86,8 @@ export const useThemeStore = create<ThemeState>()(
setTheme: (theme) => set({ theme }), setTheme: (theme) => set({ theme }),
enableThemeScheduler: false, enableThemeScheduler: false,
setEnableThemeScheduler: (v) => set({ enableThemeScheduler: v }), setEnableThemeScheduler: (v) => set({ enableThemeScheduler: v }),
schedulerMode: 'time',
setSchedulerMode: (v) => set({ schedulerMode: v }),
themeDay: 'latte', themeDay: 'latte',
setThemeDay: (v) => set({ themeDay: v }), setThemeDay: (v) => set({ themeDay: v }),
themeNight: 'mocha', themeNight: 'mocha',