diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 4ff10eaa..7b57df69 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.34.3 +pkgver=1.34.4 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src/App.tsx b/src/App.tsx index 487922f9..d053d9c4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import { useOfflineStore } from './store/offlineStore'; import { initHotCachePrefetch } from './hotCachePrefetch'; import { usePlayerStore, initAudioListeners } from './store/playerStore'; import { useThemeStore } from './store/themeStore'; +import { useThemeScheduler } from './hooks/useThemeScheduler'; import { useFontStore } from './store/fontStore'; import { useEqStore } from './store/eqStore'; import { useKeybindingsStore } from './store/keybindingsStore'; @@ -504,18 +505,24 @@ function TauriEventBridge() { } 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 uiScale = useFontStore(s => s.uiScale); const [exportPickerOpen, setExportPickerOpen] = useState(false); useEffect(() => { - document.documentElement.setAttribute('data-theme', theme); - }, [theme]); + document.documentElement.setAttribute('data-theme', effectiveTheme); + }, [effectiveTheme]); useEffect(() => { document.documentElement.setAttribute('data-font', font); }, [font]); + useEffect(() => { + document.documentElement.style.zoom = String(uiScale); + }, [uiScale]); + useEffect(() => { return initAudioListeners(); }, []); diff --git a/src/components/CustomSelect.tsx b/src/components/CustomSelect.tsx index 6c21a608..4fc9209e 100644 --- a/src/components/CustomSelect.tsx +++ b/src/components/CustomSelect.tsx @@ -25,15 +25,14 @@ export default function CustomSelect({ value, options, onChange, className = '', const selected = options.find(o => o.value === value); - useLayoutEffect(() => { - if (!open || !triggerRef.current) return; + const updateDropStyle = () => { + if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); const MARGIN = 6; const maxH = 240; const spaceBelow = window.innerHeight - rect.bottom - MARGIN; const spaceAbove = rect.top - MARGIN; const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow; - setDropStyle({ position: 'fixed', left: rect.left, @@ -44,6 +43,17 @@ export default function CustomSelect({ value, options, onChange, className = '', maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow), zIndex: 99998, }); + }; + + useLayoutEffect(() => { + if (!open) return; + updateDropStyle(); + }, [open]); + + useEffect(() => { + if (!open) return; + window.addEventListener('scroll', updateDropStyle, true); + return () => window.removeEventListener('scroll', updateDropStyle, true); }, [open]); useEffect(() => { diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index 64b8b722..fabde5da 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -9,7 +9,7 @@ interface ThemeDef { accent: string; } -const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [ +export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [ { group: 'Games', themes: [ diff --git a/src/hooks/useThemeScheduler.ts b/src/hooks/useThemeScheduler.ts new file mode 100644 index 00000000..b93374e4 --- /dev/null +++ b/src/hooks/useThemeScheduler.ts @@ -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; +} diff --git a/src/locales/de.ts b/src/locales/de.ts index 948e1aa9..b24fba70 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -574,6 +574,15 @@ export const deTranslation = { seekbarParticletrail: 'Partikel-Spur', seekbarLiquidfill: 'Flüssigkeit', 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: { modalTitle: 'Was ist neu', diff --git a/src/locales/en.ts b/src/locales/en.ts index df40da3c..654cc814 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -575,6 +575,15 @@ export const enTranslation = { seekbarParticletrail: 'Particle Trail', seekbarLiquidfill: 'Liquid Fill', 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: { modalTitle: "What's New", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 3c1a5f14..e8c31c15 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -572,6 +572,15 @@ export const frTranslation = { seekbarParticletrail: 'Traînée de particules', seekbarLiquidfill: 'Tube liquide', 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: { modalTitle: 'Quoi de neuf', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 2fb620f9..2ee8069e 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -571,6 +571,15 @@ export const nbTranslation = { seekbarParticletrail: 'Partikkelspor', seekbarLiquidfill: 'Væskerør', 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: { modalTitle: "Nyheter", diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 740a8ca1..c0806b00 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -572,6 +572,15 @@ export const nlTranslation = { seekbarParticletrail: 'Deeltjesspoor', seekbarLiquidfill: 'Vloeistofbuis', 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: { modalTitle: 'Wat is nieuw', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 4440cf6a..501b943b 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -598,6 +598,15 @@ export const ruTranslation = { seekbarParticletrail: 'Частицы', seekbarLiquidfill: 'Жидкость', seekbarRetrotape: 'Ретро-лента', + themeSchedulerTitle: 'Расписание тем', + themeSchedulerEnable: 'Включить расписание тем', + themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток', + themeSchedulerDayTheme: 'Дневная тема', + themeSchedulerDayStart: 'День начинается в', + themeSchedulerNightTheme: 'Ночная тема', + themeSchedulerNightStart: 'Ночь начинается в', + uiScaleTitle: 'Масштаб интерфейса', + uiScaleLabel: 'Масштаб', }, changelog: { modalTitle: 'Что нового', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 74a09df8..47aafe45 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -568,6 +568,15 @@ export const zhTranslation = { seekbarParticletrail: '粒子轨迹', seekbarLiquidfill: '液体填充', seekbarRetrotape: '复古磁带', + themeSchedulerTitle: '主题定时切换', + themeSchedulerEnable: '启用主题定时器', + themeSchedulerEnableSub: '根据一天中的时间自动在两个主题之间切换', + themeSchedulerDayTheme: '白天主题', + themeSchedulerDayStart: '白天开始时间', + themeSchedulerNightTheme: '夜晚主题', + themeSchedulerNightStart: '夜晚开始时间', + uiScaleTitle: '界面缩放', + uiScaleLabel: '缩放', }, changelog: { modalTitle: '新功能', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index b1e432ca..002b6850 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, 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'; import { exportBackup, importBackup } from '../utils/backup'; import { showToast } from '../utils/toast'; @@ -17,7 +17,7 @@ import { useHotCacheStore } from '../store/hotCacheStore'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; 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 { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX } from '../utils/platform'; @@ -1220,6 +1220,117 @@ export default function Settings() { +
+
+ +

{t('settings.themeSchedulerTitle')}

+
+
+
+
+
{t('settings.themeSchedulerEnable')}
+
{t('settings.themeSchedulerEnableSub')}
+
+ +
+ {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 ( +
+
+ + +
+
+ +
+ theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} /> + : + theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} /> +
+
+
+ + +
+
+ +
+ theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} /> + : + theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} /> +
+
+
+ ); + })()} +
+
+ +
+
+ +

{t('settings.uiScaleTitle')}

+
+
+
+
+ {t('settings.uiScaleLabel')} + + {Math.round(fontStore.uiScale * 100)}% + +
+ fontStore.setUiScale(parseFloat(e.target.value))} + className="ui-scale-slider" + /> +
+ {[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 ( + + ); + })} +
+
+
+
+
diff --git a/src/store/fontStore.ts b/src/store/fontStore.ts index c3937ed3..381a07ed 100644 --- a/src/store/fontStore.ts +++ b/src/store/fontStore.ts @@ -6,6 +6,8 @@ export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'spac interface FontState { font: FontId; setFont: (font: FontId) => void; + uiScale: number; + setUiScale: (scale: number) => void; } export const useFontStore = create()( @@ -13,6 +15,8 @@ export const useFontStore = create()( (set) => ({ font: 'lexend', setFont: (font) => set({ font }), + uiScale: 1.0, + setUiScale: (uiScale) => set({ uiScale }), }), { name: 'psysonic_font' } ) diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 0a72c653..883ca85c 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -6,6 +6,30 @@ type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowsto interface ThemeState { theme: Theme; 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): 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()( @@ -13,6 +37,16 @@ export const useThemeStore = create()( (set) => ({ theme: 'mocha', 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', diff --git a/src/styles/components.css b/src/styles/components.css index c1da5a99..20486f85 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6739,3 +6739,31 @@ align-items: center; 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); +}