feat(settings): theme scheduler, UI scale, CustomSelect scroll fix

- Time-based theme scheduler: auto-switches between day/night theme
  based on configurable times; setInterval re-checks every minute
- UI scale slider (80–150%) via CSS zoom on document root; preset
  buttons aligned to actual slider positions
- CustomSelect: scroll listener keeps dropdown anchored on scroll
- All features i18n in 7 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-08 12:23:14 +02:00
parent f0b423ddc4
commit e64d151079
16 changed files with 285 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de> # Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic pkgname=psysonic
pkgver=1.34.3 pkgver=1.34.4
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+10 -3
View File
@@ -61,6 +61,7 @@ import { useOfflineStore } from './store/offlineStore';
import { initHotCachePrefetch } from './hotCachePrefetch'; import { initHotCachePrefetch } from './hotCachePrefetch';
import { usePlayerStore, initAudioListeners } from './store/playerStore'; import { usePlayerStore, initAudioListeners } from './store/playerStore';
import { useThemeStore } from './store/themeStore'; import { useThemeStore } from './store/themeStore';
import { useThemeScheduler } from './hooks/useThemeScheduler';
import { useFontStore } from './store/fontStore'; import { useFontStore } from './store/fontStore';
import { useEqStore } from './store/eqStore'; import { useEqStore } from './store/eqStore';
import { useKeybindingsStore } from './store/keybindingsStore'; import { useKeybindingsStore } from './store/keybindingsStore';
@@ -504,18 +505,24 @@ function TauriEventBridge() {
} }
export default function App() { export default function App() {
const theme = useThemeStore(s => s.theme); useThemeStore(s => s.theme); // keep subscription so re-render on manual change
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font); const font = useFontStore(s => s.font);
const uiScale = useFontStore(s => s.uiScale);
const [exportPickerOpen, setExportPickerOpen] = useState(false); const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', effectiveTheme);
}, [theme]); }, [effectiveTheme]);
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-font', font); document.documentElement.setAttribute('data-font', font);
}, [font]); }, [font]);
useEffect(() => {
document.documentElement.style.zoom = String(uiScale);
}, [uiScale]);
useEffect(() => { useEffect(() => {
return initAudioListeners(); return initAudioListeners();
}, []); }, []);
+13 -3
View File
@@ -25,15 +25,14 @@ export default function CustomSelect({ value, options, onChange, className = '',
const selected = options.find(o => o.value === value); const selected = options.find(o => o.value === value);
useLayoutEffect(() => { const updateDropStyle = () => {
if (!open || !triggerRef.current) return; if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect(); const rect = triggerRef.current.getBoundingClientRect();
const MARGIN = 6; const MARGIN = 6;
const maxH = 240; const maxH = 240;
const spaceBelow = window.innerHeight - rect.bottom - MARGIN; const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
const spaceAbove = rect.top - MARGIN; const spaceAbove = rect.top - MARGIN;
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow; const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
setDropStyle({ setDropStyle({
position: 'fixed', position: 'fixed',
left: rect.left, left: rect.left,
@@ -44,6 +43,17 @@ export default function CustomSelect({ value, options, onChange, className = '',
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow), maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
zIndex: 99998, zIndex: 99998,
}); });
};
useLayoutEffect(() => {
if (!open) return;
updateDropStyle();
}, [open]);
useEffect(() => {
if (!open) return;
window.addEventListener('scroll', updateDropStyle, true);
return () => window.removeEventListener('scroll', updateDropStyle, true);
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -9,7 +9,7 @@ interface ThemeDef {
accent: string; accent: string;
} }
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [ export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ {
group: 'Games', group: 'Games',
themes: [ themes: [
+18
View File
@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { useThemeStore, getScheduledTheme } from '../store/themeStore';
export function useThemeScheduler(): string {
const state = useThemeStore();
const [effectiveTheme, setEffectiveTheme] = useState(() => getScheduledTheme(state));
useEffect(() => {
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
if (!state.enableThemeScheduler) return;
const id = setInterval(() => {
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
}, 60_000);
return () => clearInterval(id);
}, [state.enableThemeScheduler, state.theme, state.themeDay, state.themeNight, state.timeDayStart, state.timeNightStart]);
return effectiveTheme;
}
+9
View File
@@ -574,6 +574,15 @@ export const deTranslation = {
seekbarParticletrail: 'Partikel-Spur', seekbarParticletrail: 'Partikel-Spur',
seekbarLiquidfill: 'Flüssigkeit', seekbarLiquidfill: 'Flüssigkeit',
seekbarRetrotape: 'Retro-Band', seekbarRetrotape: 'Retro-Band',
themeSchedulerTitle: 'Theme-Zeitplan',
themeSchedulerEnable: 'Theme-Zeitplan aktivieren',
themeSchedulerEnableSub: 'Wechselt automatisch zwischen zwei Themes basierend auf der Uhrzeit',
themeSchedulerDayTheme: 'Tages-Theme',
themeSchedulerDayStart: 'Tag beginnt um',
themeSchedulerNightTheme: 'Nacht-Theme',
themeSchedulerNightStart: 'Nacht beginnt um',
uiScaleTitle: 'Interface-Skalierung',
uiScaleLabel: 'Zoom',
}, },
changelog: { changelog: {
modalTitle: 'Was ist neu', modalTitle: 'Was ist neu',
+9
View File
@@ -575,6 +575,15 @@ export const enTranslation = {
seekbarParticletrail: 'Particle Trail', seekbarParticletrail: 'Particle Trail',
seekbarLiquidfill: 'Liquid Fill', seekbarLiquidfill: 'Liquid Fill',
seekbarRetrotape: 'Retro Tape', seekbarRetrotape: 'Retro Tape',
themeSchedulerTitle: 'Auto-Switch Theme',
themeSchedulerEnable: 'Enable Theme Scheduler',
themeSchedulerEnableSub: 'Automatically switch between two themes based on the time of day',
themeSchedulerDayTheme: 'Day Theme',
themeSchedulerDayStart: 'Day Starts At',
themeSchedulerNightTheme: 'Night Theme',
themeSchedulerNightStart: 'Night Starts At',
uiScaleTitle: 'Interface Scale',
uiScaleLabel: 'Zoom',
}, },
changelog: { changelog: {
modalTitle: "What's New", modalTitle: "What's New",
+9
View File
@@ -572,6 +572,15 @@ export const frTranslation = {
seekbarParticletrail: 'Traînée de particules', seekbarParticletrail: 'Traînée de particules',
seekbarLiquidfill: 'Tube liquide', seekbarLiquidfill: 'Tube liquide',
seekbarRetrotape: 'Bande rétro', 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',
themeSchedulerDayTheme: 'Thème de jour',
themeSchedulerDayStart: 'Début du jour',
themeSchedulerNightTheme: 'Thème de nuit',
themeSchedulerNightStart: 'Début de la nuit',
uiScaleTitle: "Mise à l'échelle de l'interface",
uiScaleLabel: 'Zoom',
}, },
changelog: { changelog: {
modalTitle: 'Quoi de neuf', modalTitle: 'Quoi de neuf',
+9
View File
@@ -571,6 +571,15 @@ export const nbTranslation = {
seekbarParticletrail: 'Partikkelspor', seekbarParticletrail: 'Partikkelspor',
seekbarLiquidfill: 'Væskerør', seekbarLiquidfill: 'Væskerør',
seekbarRetrotape: 'Retrotape', seekbarRetrotape: 'Retrotape',
themeSchedulerTitle: 'Tidsplanlagt tema',
themeSchedulerEnable: 'Aktiver temaplanlegger',
themeSchedulerEnableSub: 'Bytter automatisk mellom to temaer basert på tidspunkt',
themeSchedulerDayTheme: 'Dagtema',
themeSchedulerDayStart: 'Dag starter kl.',
themeSchedulerNightTheme: 'Natttema',
themeSchedulerNightStart: 'Natt starter kl.',
uiScaleTitle: 'Grensesnittskala',
uiScaleLabel: 'Zoom',
}, },
changelog: { changelog: {
modalTitle: "Nyheter", modalTitle: "Nyheter",
+9
View File
@@ -572,6 +572,15 @@ export const nlTranslation = {
seekbarParticletrail: 'Deeltjesspoor', seekbarParticletrail: 'Deeltjesspoor',
seekbarLiquidfill: 'Vloeistofbuis', seekbarLiquidfill: 'Vloeistofbuis',
seekbarRetrotape: 'Retrotape', seekbarRetrotape: 'Retrotape',
themeSchedulerTitle: 'Thema-planner',
themeSchedulerEnable: 'Thema-planner inschakelen',
themeSchedulerEnableSub: 'Schakelt automatisch tussen twee thema\'s op basis van de tijd',
themeSchedulerDayTheme: 'Dagthema',
themeSchedulerDayStart: 'Dag begint om',
themeSchedulerNightTheme: 'Nachtthema',
themeSchedulerNightStart: 'Nacht begint om',
uiScaleTitle: 'Interface schaal',
uiScaleLabel: 'Zoom',
}, },
changelog: { changelog: {
modalTitle: 'Wat is nieuw', modalTitle: 'Wat is nieuw',
+9
View File
@@ -598,6 +598,15 @@ export const ruTranslation = {
seekbarParticletrail: 'Частицы', seekbarParticletrail: 'Частицы',
seekbarLiquidfill: 'Жидкость', seekbarLiquidfill: 'Жидкость',
seekbarRetrotape: 'Ретро-лента', seekbarRetrotape: 'Ретро-лента',
themeSchedulerTitle: 'Расписание тем',
themeSchedulerEnable: 'Включить расписание тем',
themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток',
themeSchedulerDayTheme: 'Дневная тема',
themeSchedulerDayStart: 'День начинается в',
themeSchedulerNightTheme: 'Ночная тема',
themeSchedulerNightStart: 'Ночь начинается в',
uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб',
}, },
changelog: { changelog: {
modalTitle: 'Что нового', modalTitle: 'Что нового',
+9
View File
@@ -568,6 +568,15 @@ export const zhTranslation = {
seekbarParticletrail: '粒子轨迹', seekbarParticletrail: '粒子轨迹',
seekbarLiquidfill: '液体填充', seekbarLiquidfill: '液体填充',
seekbarRetrotape: '复古磁带', seekbarRetrotape: '复古磁带',
themeSchedulerTitle: '主题定时切换',
themeSchedulerEnable: '启用主题定时器',
themeSchedulerEnableSub: '根据一天中的时间自动在两个主题之间切换',
themeSchedulerDayTheme: '白天主题',
themeSchedulerDayStart: '白天开始时间',
themeSchedulerNightTheme: '夜晚主题',
themeSchedulerNightStart: '夜晚开始时间',
uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放',
}, },
changelog: { changelog: {
modalTitle: '新功能', modalTitle: '新功能',
+113 -2
View File
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn
} from 'lucide-react'; } from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup'; import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
@@ -17,7 +17,7 @@ import { useHotCacheStore } from '../store/hotCacheStore';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon'; import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import ThemePicker from '../components/ThemePicker'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore'; import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore';
import { SeekbarPreview } from '../components/WaveformSeek'; import { SeekbarPreview } from '../components/WaveformSeek';
import { IS_LINUX } from '../utils/platform'; import { IS_LINUX } from '../utils/platform';
@@ -1220,6 +1220,117 @@ export default function Settings() {
</div> </div>
</section> </section>
<section className="settings-section">
<div className="settings-section-header">
<Clock size={18} />
<h2>{t('settings.themeSchedulerTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeSchedulerEnableSub')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.themeSchedulerEnable')}>
<input type="checkbox" checked={theme.enableThemeScheduler} onChange={e => theme.setEnableThemeScheduler(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
{theme.enableThemeScheduler && (() => {
const themeOptions = THEME_GROUPS.flatMap(g =>
g.themes.map(th => ({ value: th.id, label: th.label, group: g.group }))
);
const hourOptions = Array.from({ length: 24 }, (_, i) => ({
value: String(i).padStart(2, '0'),
label: String(i).padStart(2, '0'),
}));
const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m }));
const dayH = theme.timeDayStart.split(':')[0];
const dayM = theme.timeDayStart.split(':')[1];
const nightH = theme.timeNightStart.split(':')[0];
const nightM = theme.timeNightStart.split(':')[1];
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 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>
</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} />
</div>
</div>
</div>
);
})()}
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<ZoomIn size={18} />
<h2>{t('settings.uiScaleTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
{Math.round(fontStore.uiScale * 100)}%
</span>
</div>
<input
type="range"
min={0.8}
max={1.5}
step={0.05}
value={fontStore.uiScale}
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
className="ui-scale-slider"
/>
<div style={{ position: 'relative', height: 24 }}>
{[80, 90, 100, 110, 125, 150].map(p => {
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100;
const active = Math.round(fontStore.uiScale * 100) === p;
return (
<button
key={p}
className="btn btn-ghost"
style={{
position: 'absolute',
left: `${pct}%`,
transform: 'translateX(-50%)',
fontSize: 11,
padding: '2px 6px',
opacity: active ? 1 : 0.5,
color: active ? 'var(--accent)' : undefined,
}}
onClick={() => fontStore.setUiScale(p / 100)}
>
{p}%
</button>
);
})}
</div>
</div>
</div>
</section>
<section className="settings-section"> <section className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
<Type size={18} /> <Type size={18} />
+4
View File
@@ -6,6 +6,8 @@ export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'spac
interface FontState { interface FontState {
font: FontId; font: FontId;
setFont: (font: FontId) => void; setFont: (font: FontId) => void;
uiScale: number;
setUiScale: (scale: number) => void;
} }
export const useFontStore = create<FontState>()( export const useFontStore = create<FontState>()(
@@ -13,6 +15,8 @@ export const useFontStore = create<FontState>()(
(set) => ({ (set) => ({
font: 'lexend', font: 'lexend',
setFont: (font) => set({ font }), setFont: (font) => set({ font }),
uiScale: 1.0,
setUiScale: (uiScale) => set({ uiScale }),
}), }),
{ name: 'psysonic_font' } { name: 'psysonic_font' }
) )
+34
View File
@@ -6,6 +6,30 @@ type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowsto
interface ThemeState { interface ThemeState {
theme: Theme; theme: Theme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
enableThemeScheduler: boolean;
setEnableThemeScheduler: (v: boolean) => void;
themeDay: string;
setThemeDay: (v: string) => void;
themeNight: string;
setThemeNight: (v: string) => void;
timeDayStart: string;
setTimeDayStart: (v: string) => void;
timeNightStart: string;
setTimeNightStart: (v: string) => void;
}
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
if (!state.enableThemeScheduler) return state.theme;
const now = new Date();
const nowMins = now.getHours() * 60 + now.getMinutes();
const [dh, dm] = state.timeDayStart.split(':').map(Number);
const [nh, nm] = state.timeNightStart.split(':').map(Number);
const dayMins = dh * 60 + dm;
const nightMins = nh * 60 + nm;
const isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
} }
export const useThemeStore = create<ThemeState>()( export const useThemeStore = create<ThemeState>()(
@@ -13,6 +37,16 @@ export const useThemeStore = create<ThemeState>()(
(set) => ({ (set) => ({
theme: 'mocha', theme: 'mocha',
setTheme: (theme) => set({ theme }), setTheme: (theme) => set({ theme }),
enableThemeScheduler: false,
setEnableThemeScheduler: (v) => set({ enableThemeScheduler: v }),
themeDay: 'latte',
setThemeDay: (v) => set({ themeDay: v }),
themeNight: 'mocha',
setThemeNight: (v) => set({ themeNight: v }),
timeDayStart: '07:00',
setTimeDayStart: (v) => set({ timeDayStart: v }),
timeNightStart: '19:00',
setTimeNightStart: (v) => set({ timeNightStart: v }),
}), }),
{ {
name: 'psysonic_theme', name: 'psysonic_theme',
+28
View File
@@ -6739,3 +6739,31 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
/* ─── UI Scale Slider ─── */
.ui-scale-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--border-subtle);
outline: none;
cursor: pointer;
}
.ui-scale-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.ui-scale-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 20%, transparent);
}