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 } from 'lucide-react'; import { open as openUrl } from '@tauri-apps/plugin-shell'; 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 { 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' | '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')}

); } export default function Settings() { const auth = useAuthStore(); const theme = useThemeStore(); const navigate = useNavigate(); const { state: routeState } = useLocation(); const { t, i18n } = useTranslation(); const [activeTab, setActiveTab] = useState((routeState as { tab?: Tab } | null)?.tab ?? 'playback'); 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); useEffect(() => { if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {}); }, [auth.lastfmSessionKey, auth.lastfmUsername]); 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: 'playback', label: t('settings.tabPlayback'), icon: }, { id: 'library', label: t('settings.tabLibrary'), icon: }, { id: 'appearance', label: t('settings.tabAppearance'), icon: }, { id: 'server', label: t('settings.tabServer'), 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')} {t('settings.experimental')}
{t('settings.crossfadeDesc')}
{auth.crossfadeEnabled && (
auth.setCrossfadeSecs(Number(e.target.value))} style={{ width: 120 }} id="crossfade-secs-slider" /> {t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
)}
{/* Gapless */}
{t('settings.gapless')} {t('settings.experimental')}
{t('settings.gaplessDesc')}
)} {/* ── Library ──────────────────────────────────────────────────────────── */} {activeTab === 'library' && ( <> {/* Cache */}

{t('settings.behavior')}

{t('settings.cacheTitle')}
{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)
auth.setMaxCacheMb(Number(e.target.value))} style={{ width: 120 }} id="cache-size-slider" />
{/* 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.theme')}

theme.setTheme(v as any)} />

{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') }, ]} />
)} {/* ── 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.trayTitle')}
{t('settings.trayDesc')}
{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')}
)}
); } // ─── 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)}
; })}
))}
); }