mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+17
-8
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem', marginTop: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayTheme')}</label>
|
||||
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
|
||||
</div>
|
||||
<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 style={{ marginTop: '1rem' }}>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerModeLabel')}</label>
|
||||
<div className="settings-segmented">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${!isSystem ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => theme.setSchedulerMode('time')}
|
||||
>
|
||||
{t('settings.themeSchedulerModeTime')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${isSystem ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => theme.setSchedulerMode('system')}
|
||||
>
|
||||
{t('settings.themeSchedulerModeSystem')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightTheme')}</label>
|
||||
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
|
||||
</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} />
|
||||
{isSystem && (
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '1rem' }}>
|
||||
{t('settings.themeSchedulerSystemRestartHint')}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'アルバム/プレイリストヘッダーの背景にぼかしたカバーアートを表示',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов',
|
||||
|
||||
@@ -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: '在专辑/播放列表标题中显示模糊的封面作为背景',
|
||||
|
||||
@@ -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
@@ -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<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;
|
||||
// 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<ThemeState>()(
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user