import React, { useState, useMemo, useCallback, useEffect } 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 } from 'lucide-react'; 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 { 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']; type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about'; 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 ?? 'server'); 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); useEffect(() => { if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {}); }, [auth.lastfmSessionKey, auth.lastfmUsername]); useEffect(() => { if (activeTab !== 'library') return; getImageCacheSize().then(setImageCacheBytes); invoke('get_offline_cache_size').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').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 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: 'server', label: t('settings.tabServer'), icon: }, { id: 'appearance', label: t('settings.tabAppearance'), icon: }, { id: 'playback', label: t('settings.tabPlayback'), icon: }, { id: 'library', label: t('settings.tabLibrary'), icon: }, { id: 'shortcuts', label: t('settings.tabShortcuts'), icon: }, { id: 'about', label: t('settings.tabAbout'), icon: }, ]; return (

{t('settings.title')}

{/* Tab navigation */} {/* ── Playback ─────────────────────────────────────────────────────────── */} {activeTab === 'playback' && ( <> {/* 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(Number(e.target.value))} style={{ width: 120 }} id="crossfade-secs-slider" /> {t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
)}
{/* Gapless */}
{t('settings.gapless')}
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
)} {/* ── Library ──────────────────────────────────────────────────────────── */} {activeTab === 'library' && ( <> {/* Cache */}

{t('settings.behavior')}

{t('settings.cacheTitle')}
{t('settings.cacheDesc')} {(imageCacheBytes !== null || offlineCacheBytes !== null) && ( — {t('settings.cacheUsed', { images: imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…', offline: offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…', })} )}
{auth.maxCacheMb} MB auth.setMaxCacheMb(Number(e.target.value))} style={{ width: 120 }} id="cache-size-slider" />
{showClearConfirm ? (
{t('settings.cacheClearWarning')}
) : ( )}
{/* 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} ))}
)} {/* ── 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') }, ]} />

{t('settings.theme')}

theme.setTheme(v as any)} />

{t('settings.font')}

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

{t('settings.tabShortcuts')}

{([ ['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')}

{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}

)}
)}
{/* Downloads + Tray */}

{t('settings.behavior')}

{t('settings.downloadsTitle')}
{auth.downloadFolder || t('settings.downloadsDefault')}
{auth.downloadFolder && ( )}
)} {/* ── About ────────────────────────────────────────────────────────────── */} {activeTab === 'about' && ( <>

{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')}
{t('settings.aboutContributorsLabel')} {t('settings.aboutContributors')}
)}
); } // ─── 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 ChangelogSection() { const { t } = useTranslation(); 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')}

{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)}
; })}
))}
); }