import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { version as appVersion } from '../../package.json'; import changelogRaw from '../../CHANGELOG.md?raw'; 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 } from 'lucide-react'; import { exportBackup, importBackup } from '../utils/backup'; import { showToast } from '../utils/toast'; import { invoke } from '@tauri-apps/api/core'; import { open as openUrl } from '@tauri-apps/plugin-shell'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { useOfflineStore } from '../store/offlineStore'; 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 { useAuthStore, ServerProfile } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore'; import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore'; import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore'; import { useHomeStore, HomeSectionId } from '../store/homeStore'; import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; import { ALL_NAV_ITEMS } from '../components/Sidebar'; import { pingWithCredentials } from '../api/subsonic'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; const CONTRIBUTORS = [ { github: 'jiezhuo', since: '1.21', contributions: [ 'Chinese (Simplified) translation', ], }, { github: 'nullobject', since: '1.22.0', contributions: [ 'Seek debounce & race condition fix (PR #7)', 'Waveform seekbar stability on position update (PR #8)', ], }, { github: 'trbn1', since: '1.22.0', contributions: [ 'Replay Gain metadata propagation (PR #9)', 'songToTrack() — unified track construction across all sources', ], }, { github: 'nisarg-78', since: '1.29.0', contributions: [ 'Click-to-seek in synced lyrics (PR #38)', 'Volume scroll wheel on volume slider (PR #38)', 'Lyrics line visual states: active / completed / upcoming (PR #38)', ], }, { github: 'JulianNymark', since: '1.29.0', contributions: [ 'OGG/Vorbis container support via symphonia-format-ogg (PR #42)', 'Themed toast notifications for audio playback errors (PR #43)', 'Human-readable audio error messages (PR #44)', ], }, ] as const; const SPECIAL_THANKS = [ { github: 'netherguy4', reason: 'Countless constructive feature ideas and thoughtful feedback', }, ] as const; type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system'; function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit) => void; onCancel: () => void }) { const { t } = useTranslation(); const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); const [showPass, setShowPass] = useState(false); const update = (k: keyof typeof form) => (e: React.ChangeEvent) => setForm(f => ({ ...f, [k]: e.target.value })); return (

{t('settings.addServerTitle')}

); } function formatBytes(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } export default function Settings() { const auth = useAuthStore(); const theme = useThemeStore(); const fontStore = useFontStore(); const kb = useKeybindingsStore(); const gs = useGlobalShortcutsStore(); const serverId = auth.activeServerId ?? ''; const clearAllOffline = useOfflineStore(s => s.clearAll); const [listeningFor, setListeningFor] = useState(null); const [listeningForGlobal, setListeningForGlobal] = useState(null); const navigate = useNavigate(); const { state: routeState } = useLocation(); const { t, i18n } = useTranslation(); const [activeTab, setActiveTab] = useState((routeState as { tab?: Tab } | null)?.tab ?? 'general'); const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(false); const [newGenre, setNewGenre] = useState(''); const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle'); const [lfmPendingToken, setLfmPendingToken] = useState(null); const [lfmError, setLfmError] = useState(null); const [lfmUserInfo, setLfmUserInfo] = useState(null); const [imageCacheBytes, setImageCacheBytes] = useState(null); const [offlineCacheBytes, setOfflineCacheBytes] = useState(null); const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearing, setClearing] = useState(false); const [contributorsOpen, setContributorsOpen] = useState(false); useEffect(() => { if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {}); }, [auth.lastfmSessionKey, auth.lastfmUsername]); useEffect(() => { if (activeTab !== 'storage') return; getImageCacheSize().then(setImageCacheBytes); invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0)); }, [activeTab]); const handleClearCache = useCallback(async () => { setClearing(true); await clearImageCache(); await clearAllOffline(serverId); const [imgBytes, offBytes] = await Promise.all([ getImageCacheSize(), invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0), ]); setImageCacheBytes(imgBytes); setOfflineCacheBytes(offBytes); setShowClearConfirm(false); setClearing(false); }, [clearAllOffline, serverId]); const startLastfmConnect = useCallback(async () => { setLfmError(null); let token: string; try { token = await lastfmGetToken(); setLfmPendingToken(token); setLfmState('waiting'); await openUrl(lastfmAuthUrl(token)); } catch (e: any) { setLfmError(e.message ?? 'Unknown error'); setLfmState('error'); return; } // Poll every 2 s until the user authorises or we time out (2 min) const deadline = Date.now() + 120_000; const poll = async () => { if (Date.now() > deadline) { setLfmState('error'); setLfmError('Timed out — please try again.'); setLfmPendingToken(null); return; } try { const { key, name } = await lastfmGetSession(token); auth.connectLastfm(key, name); setLfmState('idle'); setLfmPendingToken(null); } catch (e: any) { // Error 14 = not yet authorised, keep polling if (e.message?.includes('14')) { setTimeout(poll, 2000); } else { setLfmState('error'); setLfmError(e.message ?? 'Unknown error'); setLfmPendingToken(null); } } }; setTimeout(poll, 2000); }, [auth]); const testConnection = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); try { const ok = await pingWithCredentials(server.url, server.username, server.password); setConnStatus(s => ({ ...s, [server.id]: ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; const switchToServer = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); try { const ok = await pingWithCredentials(server.url, server.username, server.password); if (ok) { auth.setActiveServer(server.id); auth.setLoggedIn(true); navigate('/'); } else { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } } catch { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; const deleteServer = (server: ServerProfile) => { if (confirm(t('settings.confirmDeleteServer', { name: server.name || server.url }))) { auth.removeServer(server.id); } }; const handleAddServer = async (data: Omit) => { setShowAddForm(false); const tempId = '_new'; setConnStatus(s => ({ ...s, [tempId]: 'testing' })); try { const ok = await pingWithCredentials(data.url, data.username, data.password); if (ok) { const id = auth.addServer(data); auth.setActiveServer(id); auth.setLoggedIn(true); setConnStatus(s => ({ ...s, [id]: 'ok' })); } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } } catch { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } }; const handleLogout = () => { auth.logout(); navigate('/login'); }; const pickOfflineDir = async () => { const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') }); if (selected && typeof selected === 'string') { auth.setOfflineDownloadDir(selected); } }; const pickDownloadFolder = async () => { const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') }); if (selected && typeof selected === 'string') { auth.setDownloadFolder(selected); } }; const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: 'general', label: t('settings.tabGeneral'), icon: }, { id: 'server', label: t('settings.tabServer'), icon: }, { id: 'audio', label: t('settings.tabAudio'), icon: }, { id: 'storage', label: t('settings.tabStorage'), icon: }, { id: 'appearance', label: t('settings.tabAppearance'), icon: }, { id: 'input', label: t('settings.tabInput'), icon: }, { id: 'system', label: t('settings.tabSystem'), icon: }, ]; return (

{t('settings.title')}

{/* Tab navigation */} {/* ── Audio ────────────────────────────────────────────────────────────── */} {activeTab === 'audio' && ( <> {/* Equalizer */}

{t('settings.eqTitle')}

{/* Replay Gain + Crossfade + Gapless */}

{t('settings.playbackTitle')}

{/* Replay Gain */}
{t('settings.replayGain')}
{t('settings.replayGainDesc')}
{auth.replayGainEnabled && (
{t('settings.replayGainMode')}:
)}
{/* Crossfade */}
{t('settings.crossfade')}
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
auth.setCrossfadeSecs(parseFloat(e.target.value))} style={{ width: 120 }} id="crossfade-secs-slider" /> {t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
)}
{/* Gapless */}
{t('settings.gapless')}
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
)} {/* ── General ──────────────────────────────────────────────────────────── */} {activeTab === 'general' && ( <> {/* App behaviour */}

{t('settings.behavior')}

{t('settings.minimizeToTray')}
{t('settings.minimizeToTrayDesc')}
{t('settings.showArtistImages')}
{t('settings.showArtistImagesDesc')}
{t('settings.discordRichPresence')}
{t('settings.discordRichPresenceDesc')}
{t('settings.nowPlayingEnabled')}
{t('settings.nowPlayingEnabledDesc')}
{/* Random Mix */}

{t('settings.randomMixTitle')}

{t('settings.randomMixBlacklistDesc')}

{t('settings.randomMixBlacklistTitle')}
{auth.customGenreBlacklist.length === 0 ? ( {t('settings.randomMixBlacklistEmpty')} ) : ( auth.customGenreBlacklist.map(genre => ( {genre} )) )}
setNewGenre(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newGenre.trim()) { const trimmed = newGenre.trim(); if (!auth.customGenreBlacklist.includes(trimmed)) { auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]); } setNewGenre(''); } }} placeholder={t('settings.randomMixBlacklistPlaceholder')} style={{ fontSize: 13 }} />
{t('settings.randomMixHardcodedTitle')}
{AUDIOBOOK_GENRES_DISPLAY.map(genre => ( {genre} ))}
)} {/* ── Storage & Downloads ───────────────────────────────────────────────── */} {activeTab === 'storage' && ( <> {/* Offline Library (In-App) — includes cache settings */}

{t('settings.offlineDirTitle')}

{t('settings.offlineDirDesc')}
{auth.offlineDownloadDir && ( )}
{auth.offlineDownloadDir && (
{t('settings.offlineDirHint')}
)}
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
{t('settings.cacheUsedImages')} {imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
{t('settings.cacheUsedOffline')} {offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
)}
{t('settings.cacheMaxLabel')} { const v = Number(e.target.value); if (v >= 100) auth.setMaxCacheMb(v); }} style={{ width: 80, padding: '4px 8px', fontSize: 13 }} id="cache-size-input" /> MB
{showClearConfirm ? (
{t('settings.cacheClearWarning')}
) : ( )}
{/* ZIP Export & Archiving */}

{t('settings.downloadsTitle')}

{t('settings.downloadsFolderDesc')}
{auth.downloadFolder && ( )}
)} {/* ── Appearance ───────────────────────────────────────────────────────── */} {activeTab === 'appearance' && ( <>

{t('settings.language')}

i18n.changeLanguage(v)} options={[ { value: 'nl', label: t('settings.languageNl') }, { value: 'en', label: t('settings.languageEn') }, { value: 'fr', label: t('settings.languageFr') }, { value: 'de', label: t('settings.languageDe') }, { value: 'zh', label: t('settings.languageZh') }, { value: 'nb', label: t('settings.languageNb') }, ]} />

{t('settings.theme')}

theme.setTheme(v as any)} />

{t('settings.font')}

{( [ { id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" }, { id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" }, { id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" }, { id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" }, { id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" }, { id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" }, { id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" }, { id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" }, { id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" }, { id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" }, ] as { id: FontId; label: string; stack: string }[] ).map(f => ( ))}
)} {/* ── Input ────────────────────────────────────────────────────────────── */} {activeTab === 'input' && ( <>

{t('settings.tabInput')}

{([ ['play-pause', t('settings.shortcutPlayPause')], ['next', t('settings.shortcutNext')], ['prev', t('settings.shortcutPrev')], ['volume-up', t('settings.shortcutVolumeUp')], ['volume-down', t('settings.shortcutVolumeDown')], ['seek-forward', t('settings.shortcutSeekForward')], ['seek-backward', t('settings.shortcutSeekBackward')], ['toggle-queue', t('settings.shortcutToggleQueue')], ['fullscreen-player', t('settings.shortcutFullscreenPlayer')], ['native-fullscreen', t('settings.shortcutNativeFullscreen')], ] as [KeyAction, string][]).map(([action, label]) => { const bound = kb.bindings[action]; const isListening = listeningFor === action; return (
{label}
{bound && !isListening && ( )}
); })}

{t('settings.globalShortcutsTitle')}

{t('settings.globalShortcutsNote')}

{([ ['play-pause', t('settings.shortcutPlayPause')], ['next', t('settings.shortcutNext')], ['prev', t('settings.shortcutPrev')], ['volume-up', t('settings.shortcutVolumeUp')], ['volume-down', t('settings.shortcutVolumeDown')], ] as [GlobalAction, string][]).map(([action, label]) => { const bound = gs.shortcuts[action] ?? null; const isListening = listeningForGlobal === action; return (
{label}
{bound && !isListening && ( )}
); })}
)} {/* ── Server ───────────────────────────────────────────────────────────── */} {activeTab === 'server' && ( <>

{t('settings.servers')}

{t('settings.serverCompatible')}
{auth.servers.length === 0 && !showAddForm ? (
{t('settings.noServers')}
) : (
{auth.servers.map(srv => { const isActive = srv.id === auth.activeServerId; const status = connStatus[srv.id]; return (
{srv.name || srv.url} {isActive && ( {t('settings.serverActive')} )}
{srv.username}@{srv.url}
{status === 'ok' && } {status === 'error' && } {status === 'testing' &&
} {!isActive && ( )}
); })}
)} {showAddForm ? ( setShowAddForm(false)} /> ) : ( )}
{/* Last.fm */}

{t('settings.lfmTitle')}

{auth.lastfmSessionKey ? ( /* ── Connected state ── */
@{auth.lastfmUsername}
{lfmUserInfo && (
{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })} {t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}
)}
{t('settings.scrobbleEnabled')}
{t('settings.scrobbleDesc')}
) : lfmState === 'waiting' ? ( /* ── Waiting for browser auth — auto-polling ── */
{t('settings.lfmConnecting')}
) : ( /* ── Not connected ── */

{t('settings.lfmConnectDesc')}

{lfmState === 'error' && (

{lfmError}

)}
)}
)} {/* ── System ───────────────────────────────────────────────────────────── */} {activeTab === 'system' && ( <>

{t('settings.aboutTitle')}

Psysonic
Psysonic
{t('settings.aboutVersion')} {appVersion}

{t('settings.aboutDesc')}

{t('settings.aboutFeatures')}

{t('settings.aboutLicense')} {t('settings.aboutLicenseText')}
Stack {t('settings.aboutBuiltWith')}
AI {t('settings.aboutAiCredit')}
{contributorsOpen && (
{CONTRIBUTORS.map(c => (
{c.github}
v{c.since}
    {c.contributions.map(item =>
  • {item}
  • )}
))}
)}
{t('settings.aboutSpecialThanksLabel')}
{SPECIAL_THANKS.map(s => (
{s.github} — {s.reason}
))}
)}
); } // ─── Changelog renderer ─────────────────────────────────────────────────────── function renderInline(text: string): React.ReactNode[] { // Splits on **bold**, *italic*, `code` and renders each part. const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); return parts.map((part, i) => { if (part.startsWith('**') && part.endsWith('**')) return {part.slice(2, -2)}; if (part.startsWith('*') && part.endsWith('*')) return {part.slice(1, -1)}; if (part.startsWith('`') && part.endsWith('`')) return {part.slice(1, -1)}; return part; }); } function HomeCustomizer() { const { t } = useTranslation(); const { sections, toggleSection, reset } = useHomeStore(); const SECTION_LABELS: Record = { hero: t('home.hero'), recent: t('home.recent'), discover: t('home.discover'), discoverArtists: t('home.discoverArtists'), recentlyPlayed: t('home.recentlyPlayed'), starred: t('home.starred'), mostPlayed: t('home.mostPlayed'), }; return (

{t('settings.homeCustomizerTitle')}

{sections.map(sec => (
{SECTION_LABELS[sec.id]}
))}
); } function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) { const { t } = useTranslation(); const { onMouseDown } = useDragSource(() => ({ data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }), label, })); return ( ); } type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; function SidebarCustomizer() { const { t } = useTranslation(); const { items, setItems, toggleItem, reset } = useSidebarStore(); const { isDragging: isPsyDragging } = useDragDrop(); const containerRef = useRef(null); const [dropTarget, setDropTarget] = useState(null); const dropTargetRef = useRef(null); const itemsRef = useRef(items); itemsRef.current = items; const libraryItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'library'); const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system'); useEffect(() => { if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } }, [isPsyDragging]); useEffect(() => { const el = containerRef.current; if (!el) return; const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.data) return; let parsed: { type?: string; index?: number; section?: string }; try { parsed = JSON.parse(detail.data); } catch { return; } if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return; const fromIdx = parsed.index; const fromSection = parsed.section as 'library' | 'system'; const target = dropTargetRef.current; dropTargetRef.current = null; setDropTarget(null); if (!target || target.section !== fromSection) return; const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems]; const insertBefore = target.before ? target.idx : target.idx + 1; if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; const [moved] = sectionItems.splice(fromIdx, 1); sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); // Merge reordered section back into flat items array const all = [...itemsRef.current]; const positions = all.map((cfg, i) => ({ cfg, i })) .filter(({ cfg }) => ALL_NAV_ITEMS[cfg.id]?.section === fromSection) .map(({ i }) => i); positions.forEach((pos, i) => { all[pos] = sectionItems[i]; }); setItems(all); }; el.addEventListener('psy-drop', onPsyDrop); return () => el.removeEventListener('psy-drop', onPsyDrop); }, [libraryItems, systemItems, setItems]); const handleMouseMove = (e: React.MouseEvent) => { if (!isPsyDragging || !containerRef.current) return; const rows = containerRef.current.querySelectorAll('[data-sidebar-idx]'); let target: DropTarget = null; for (const row of rows) { const rect = row.getBoundingClientRect(); const idx = Number(row.dataset.sidebarIdx); const section = row.dataset.sidebarSection as 'library' | 'system'; if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; } target = { idx, before: false, section }; } dropTargetRef.current = target; setDropTarget(target); }; const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => { const meta = ALL_NAV_ITEMS[cfg.id]; if (!meta) return null; const Icon = meta.icon; const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before; const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before; return (
{t(meta.labelKey)}
); }; return (

{t('settings.sidebarTitle')}

{/* Library block */}
{t('sidebar.library')}
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
{/* System block */}
{t('sidebar.system')}
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}
); } function BackupSection() { const { t } = useTranslation(); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); const handleExport = async () => { setExporting(true); try { const path = await exportBackup(); if (path) showToast(t('settings.backupSuccess'), 3000, 'info'); } catch (e) { console.error('Export failed', e); showToast(t('settings.backupImportError'), 4000, 'error'); } finally { setExporting(false); } }; const handleImport = async () => { if (!window.confirm(t('settings.backupImportConfirm'))) return; setImporting(true); try { await importBackup(); // importBackup reloads the page — this toast will briefly show before reload showToast(t('settings.backupImportSuccess'), 3000, 'info'); } catch (e) { console.error('Import failed', e); showToast(t('settings.backupImportError'), 4000, 'error'); setImporting(false); } }; return (

{t('settings.backupTitle')}

{/* Export */}
{t('settings.backupExport')}
{t('settings.backupExportDesc')}
{/* Import */}
{t('settings.backupImport')}
{t('settings.backupImportDesc')}
); } function ChangelogSection() { const { t } = useTranslation(); const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate); const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate); const versions = useMemo(() => { const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## [')); return blocks.map(block => { const lines = block.split('\n'); const headerLine = lines[0]; // e.g. "## [1.5.0] - 2026-03-18" const versionMatch = headerLine.match(/## \[([^\]]+)\]/); const dateMatch = headerLine.match(/- (\d{4}-\d{2}-\d{2})/); const version = versionMatch?.[1] ?? ''; const date = dateMatch?.[1] ?? ''; // Parse the rest into rendered lines const body = lines.slice(1).join('\n').trim(); return { version, date, body }; }); }, []); return (

{t('settings.changelog')}

{t('settings.showChangelogOnUpdate')}
{t('settings.showChangelogOnUpdateDesc')}
{versions.map(({ version, date, body }) => (
v{version} {date}
{body.split('\n').map((line, i) => { if (line.startsWith('### ')) { return
{renderInline(line.slice(4))}
; } if (line.startsWith('#### ')) { return
{renderInline(line.slice(5))}
; } if (line.startsWith('- ')) { return
{renderInline(line.slice(2))}
; } if (line.trim() === '') return null; return
{renderInline(line)}
; })}
))}
); }