mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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
@@ -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();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'Что нового',
|
||||||
|
|||||||
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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' }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user