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, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock } from 'lucide-react'; import i18n from '../i18n'; import { exportBackup, importBackup } from '../utils/backup'; import { showToast } from '../utils/toast'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open as openUrl } from '@tauri-apps/plugin-shell'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { useOfflineStore } from '../store/offlineStore'; 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, { THEME_GROUPS } from '../components/ThemePicker'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig } from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } 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 '../config/navItems'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { Trans, useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; import StarRating from '../components/StarRating'; import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity'; 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 AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; 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)', 'Queue auto-scroll to keep upcoming tracks in view; fixed re-renders from currentTime in QueuePanel (PR #115)', ], }, { 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)', ], }, { github: 'zz5zz', since: '1.32.0', contributions: [ 'Norwegian (Bokmål) translation (PR #101)', ], }, { github: 'cucadmuh', since: '1.33.0', contributions: [ 'Russian translation & i18n locale split (PR #106)', 'Russian locale refinements using phrasing from ru2 (PR #113)', 'Gapless manual skip: honor user-initiated play over pre-chained track (PR #119)', 'Hot playback cache — queue prefetch (PR #123)', 'Per-server music folder filter and sidebar library picker (PR #124, PR #125)', 'Richer star ratings, skip threshold, and library filtering (PR #130)', 'Statistics: scope album and song totals to selected music library (PR #138)', 'AudioMuse-AI discovery integration for Navidrome (PR #147)', 'Hot playback cache — eviction budgeting, grace period, and live Audio settings readout (PR #153)', 'Folder Browser: keyboard navigation, context menus, now-playing path emphasis, and adaptive column layout (PR #158)', 'Infinite queue: artist-driven candidates via Top Songs + Similar Songs with random fallback (PR #163)', 'Folder Browser: per-column filter with keyboard flow and Shift+Enter queue append (PR #165)', 'Keybindings: modifier + key chords for in-app shortcuts; fixed seek ±10s units (PR #167)', 'Statistics: genre insights scoped to music library, cached Subsonic fetches, localized duration formatting (PR #144)', 'Audio output device picker: clearer ALSA labels, duplicate disambiguation, system-default mark, live refresh (PR #173)', 'Folder Browser: arrow navigation blocked when modifier keys are held (PR #174)', 'Linux audio output device picker: stable watcher (disable false enumeration-miss resets), canonicalize ALSA name drift, ghost entry for unlisted device (PR #176)', 'Opus audio playback via symphonia-adapter-libopus with bundled libopus (PR #183)', 'CLI player controls with D-Bus forwarding, shell completions for bash/zsh, library/audio-device/instant-mix CLI commands, active server switcher in header (PR #187)', 'Streaming playback stability: stream-first start, seek recovery, crossfade/gapless backup preload, hot-cache promotion (PR #200)', 'ReplayGain values in Queue tech strip (PR #196)', 'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)', ], }, { github: 'kilyabin', since: '1.34.0', contributions: [ 'Russian locale improvements (PR #107, PR #120)', 'Auto-install script for Debian / RHEL (PR #121)', 'Album cover art in Discord Rich Presence via iTunes API (PR #111)', 'Tiling WM detection: hide custom TitleBar on Hyprland/Sway/i3/etc. (PR #134)', 'Russian translation: lyricsServerFirst settings strings (PR #140)', 'Russian translation refinements (PR #148)', 'Merge Random Mix & Albums into a single Build a Mix hub (PR #155)', 'Fullscreen player: software-rendering performance fixes + portrait toggle & dimming setting (PR #156)', 'Fullscreen player: stop mesh blob and portrait animations in no-compositing mode; remove seekbar box-shadow repaint (PR #175)', ], }, { github: 'kveld9', since: '1.34.4', contributions: [ 'Spanish (es) translation — 964 strings (PR #159)', 'Column-header sorting for albums & playlists (PR #160)', 'Multi-select for albums, artists & playlists with bulk "Add to Playlist"; collapsible sidebar playlist section; infinite scroll on Artists page; "Remove from Playlist" in context menu (PR #168)', '3 visual toggles: cover art background, playlist cover photo, show bitrate badge (PR #181)', '8 community themes (AMOLED Black, Monochrome Dark, Amber Night, Phosphor Green, Midnight Blue, Rose Dark, Sepia Dark, Ice Blue) + waveform live theme update (PR #182)', 'Favorites redesign: sortable columns, genre filter, age range filter, new columns (PR #184)', 'Albums and playlist headers redesign with improved layout and theme integration (PR #186)', 'Tracklist column picker overflow fix in AlbumTrackList (PR #188)', 'Spotify CSV playlist import (PR #190)', 'Context menu for songs in AdvancedSearch and SearchResults (PR #191)', 'Tracklist column picker alignment and toggle fix across Favorites and PlaylistDetail (PR #192)', 'CSV import: dynamic match threshold, cleaned title search, score display in report (PR #199)', 'Discord Rich Presence: configurable text templates for details, state and album tooltip (PR #198)', ], }, { github: 'nisrael', since: '1.34.0', contributions: [ 'Nightfox.nvim theme group in Open Source Classics (PR #114)', 'Switch reqwest to rustls-tls for cross-platform TLS (PR #112)', 'ICY stream metadata & AzuraCast Now Playing support (PR #146)', ], }, ] 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`; } /** Align hot-cache size slider (step 32 MB) to valid values. */ function snapHotCacheMb(v: number): number { const x = Math.min(20000, Math.max(32, Math.round(v))); return Math.round((x - 32) / 32) * 32 + 32; } /** Makes raw ALSA device names more readable on Linux. * Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned. * e.g. "sysdefault:CARD=U192k" → "U192k" * "hw:CARD=U192k,DEV=0" → "U192k (hw · PCM 0)" * "hdmi:CARD=NVidia,DEV=1" → "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string) * "iec958:CARD=PCH,DEV=0" → "PCH (S/PDIF)" * Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */ function formatAudioDeviceLabel(name: string): string { const cardMatch = name.match(/CARD=([^,]+)/); if (!cardMatch) return name; const card = cardMatch[1]; const devM = name.match(/DEV=(\d+)/); const devNum = devM ? parseInt(devM[1], 10) : null; const subM = name.match(/SUBDEV=(\d+)/); const subNum = subM ? parseInt(subM[1], 10) : null; if (name.startsWith('iec958:')) return `${card} (S/PDIF)`; if (name.startsWith('hdmi:')) { const d = devNum !== null ? devNum : 0; return `${card} (HDMI · DEV ${d})`; } if (name.startsWith('sysdefault:')) { if (devNum !== null && devNum > 0) return `${card} (default · PCM ${devNum})`; return card; } if (name.startsWith('plughw:')) { if (devNum !== null) { const sub = subNum !== null ? ` · sub ${subNum}` : ''; return `${card} (plug · PCM ${devNum}${sub})`; } return card; } if (name.startsWith('hw:')) { if (devNum !== null) { const sub = subNum !== null ? ` · sub ${subNum}` : ''; return `${card} (hw · PCM ${devNum}${sub})`; } return `${card} (hw)`; } if (name.startsWith('front:')) return `${card} (Front)`; if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`; // Other ALSA iface:card,dev — show plugin + PCM so identical cards differ const iface = name.split(':')[0]; if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) { if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`; return `${card} (${iface})`; } return card; } /** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */ function audioDeviceDuplicateHint(raw: string): string { const cardM = raw.match(/CARD=([^,]+)/); const devM = raw.match(/DEV=(\d+)/); const subM = raw.match(/SUBDEV=(\d+)/); const iface = raw.split(':')[0] || ''; const parts: string[] = []; if (iface) parts.push(iface); if (cardM) parts.push(cardM[1]); if (devM) parts.push(`PCM ${devM[1]}`); if (subM) parts.push(`sub ${subM[1]}`); if (parts.length > 1) return parts.join(' · '); return raw.length > 56 ? `…${raw.slice(-53)}` : raw; } /** When several devices share the same display label, append a disambiguator. */ function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string { if (!duplicateBase) return baseLabel; return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`; } /** cpal order is arbitrary; sort by readable label, current OS default first. */ function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] { return [...devices].sort((a, b) => { const aDef = osDefaultDeviceId && a === osDefaultDeviceId; const bDef = osDefaultDeviceId && b === osDefaultDeviceId; if (aDef !== bDef) return aDef ? -1 : 1; const la = formatAudioDeviceLabel(a); const lb = formatAudioDeviceLabel(b); const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' }); if (byLabel !== 0) return byLabel; return a.localeCompare(b); }); } function buildAudioDeviceSelectOptions( devices: string[], defaultLabel: string, osDefaultDeviceId: string | null, osDefaultMark: string, pinnedDevice: string | null, notInListSuffix: string, ): { value: string; label: string }[] { const baseLabels = devices.map(formatAudioDeviceLabel); const countByBase = new Map(); for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1); const pinned = pinnedDevice?.trim() || null; const pinnedNotListed = !!(pinned && !devices.includes(pinned)); const ghost: { value: string; label: string }[] = pinnedNotListed ? (() => { const base = formatAudioDeviceLabel(pinned); let label = `${base} · ${notInListSuffix}`; if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`; return [{ value: pinned, label }]; })() : []; return [ { value: '', label: defaultLabel }, ...ghost, ...devices.map((d, i) => { const base = baseLabels[i]; const dup = (countByBase.get(base) ?? 0) > 1; let label = disambiguatedAudioDeviceLabel(d, base, dup); if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`; return { value: d, label }; }), ]; } 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 clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk); const hotCacheEntries = useHotCacheStore(s => s.entries); const [isTilingWm, setIsTilingWm] = useState(false); useEffect(() => { if (!IS_LINUX) return; invoke('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); }, []); const hotCacheTrackCount = useMemo(() => { if (!serverId) return 0; const prefix = `${serverId}:`; return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length; }, [hotCacheEntries, serverId]); 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 [hotCacheBytes, setHotCacheBytes] = useState(null); const [audioDevices, setAudioDevices] = useState([]); const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState(null); const [deviceSwitching, setDeviceSwitching] = useState(false); const [devicesLoading, setDevicesLoading] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearing, setClearing] = useState(false); const [contributorsOpen, setContributorsOpen] = useState(false); const [fontPickerOpen, setFontPickerOpen] = 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)); invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => { const silent = !!opts?.silent; if (!silent) setDevicesLoading(true); const listP = invoke('audio_list_devices').catch((e) => { console.error(e); showToast(t('settings.audioOutputDeviceListError'), 5000, 'error'); return [] as string[]; }); const defP = invoke('audio_default_output_device_name').catch(() => null); Promise.all([listP, defP]) .then(async ([devices, osDefault]) => { let canon: string | null = null; try { canon = await invoke('audio_canonicalize_selected_device'); if (canon) useAuthStore.getState().setAudioOutputDevice(canon); } catch { /* ignore */ } const finalList = canon ? await invoke('audio_list_devices').catch(() => devices) : devices; const defId = osDefault ?? null; setAudioDevices(sortAudioDeviceIds(finalList, defId)); setOsDefaultAudioDeviceId(defId); }) .finally(() => { if (!silent) setDevicesLoading(false); }); }, [t]); // Load available audio output devices when Audio tab opens. useEffect(() => { if (activeTab !== 'audio') return; refreshAudioDevices(); }, [activeTab, refreshAudioDevices]); // Keep device list + "current system output" mark in sync when the backend reopens the stream. useEffect(() => { if (activeTab !== 'audio') return; let cancelled = false; const unlisteners: Array<() => void> = []; (async () => { for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) { const u = await listen(ev, () => { if (!cancelled) refreshAudioDevices({ silent: true }); }); if (cancelled) { u(); return; } unlisteners.push(u); } })(); return () => { cancelled = true; for (const u of unlisteners) u(); }; }, [activeTab, refreshAudioDevices]); /** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */ useEffect(() => { if (activeTab !== 'audio') return; const customDir = auth.hotCacheDownloadDir || null; const refresh = () => { invoke('get_hot_cache_size', { customDir }) .then(setHotCacheBytes) .catch(() => setHotCacheBytes(0)); }; refresh(); if (!auth.hotCacheEnabled) return; const interval = window.setInterval(refresh, 2000); return () => window.clearInterval(interval); }, [activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); useEffect(() => { if (activeTab !== 'audio' || !auth.hotCacheEnabled) return; const t = window.setTimeout(() => { invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }) .then(setHotCacheBytes) .catch(() => setHotCacheBytes(0)); }, 400); return () => window.clearTimeout(t); }, [hotCacheEntries, activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); 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 ping = await pingWithCredentials(server.url, server.username, server.password); if (ping.ok) { const identity = { type: ping.type, serverVersion: ping.serverVersion, openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(server.id, identity); scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); } setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; const switchToServer = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); const ok = await switchActiveServer(server); if (ok) { setConnStatus(s => ({ ...s, [server.id]: 'ok' })); navigate('/'); } else { 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 ping = await pingWithCredentials(data.url, data.username, data.password); if (ping.ok) { const id = auth.addServer(data); const identity = { type: ping.type, serverVersion: ping.serverVersion, openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(id, identity); scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); 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 pickHotCacheDir = async () => { const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') }); if (selected && typeof selected === 'string') { auth.setHotCacheDownloadDir(selected); useHotCacheStore.setState({ entries: {} }); invoke('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); } }; 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' && ( <> {/* Audio Output Device */}

{t('settings.audioOutputDevice')}

{t('settings.audioOutputDeviceDesc')}
{ const device = val || null; setDeviceSwitching(true); try { await invoke('audio_set_device', { deviceName: device }); auth.setAudioOutputDevice(device); } catch { /* device open failed — don't persist */ } setDeviceSwitching(false); }} options={buildAudioDeviceSelectOptions( audioDevices, t('settings.audioOutputDeviceDefault'), osDefaultAudioDeviceId, t('settings.audioOutputDeviceOsDefaultNow'), auth.audioOutputDevice, t('settings.audioOutputDeviceNotInCurrentList'), )} />
{/* Native Hi-Res Playback */}

{t('settings.hiResTitle')}

{t('settings.hiResEnabled')}
{t('settings.hiResDesc')}
{/* Equalizer */}

{t('settings.eqTitle')}

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

{t('settings.playbackTitle')}

{/* Replay Gain */}
{t('settings.replayGain')}
{t('settings.replayGainDesc')}
{auth.replayGainEnabled && (
{t('settings.replayGainMode')}:
)} {auth.replayGainEnabled && (
{t('settings.replayGainPreGain')} auth.setReplayGainPreGainDb(Number(e.target.value))} style={{ flex: 1, maxWidth: 160 }} /> {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
{t('settings.replayGainFallback')} auth.setReplayGainFallbackDb(Number(e.target.value))} style={{ flex: 1, maxWidth: 160 }} /> {auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
)}
{/* 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')}
{/* Next Track Buffering */}

{t('settings.nextTrackBufferingTitle')}

{t('settings.preloadHotCacheMutualExclusive')}
{/* Preload mode */}
{t('settings.preloadMode')}
{t('settings.preloadModeDesc')}
{auth.preloadMode !== 'off' && ( <>
{(['balanced', 'early', 'custom'] as const).map(mode => ( ))}
{auth.preloadMode === 'custom' && (
auth.setPreloadCustomSeconds(parseInt(e.target.value))} style={{ width: 120 }} /> {t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
)} )}
{/* Hot Cache */}
{t('settings.hotCacheTitle')}
{t('settings.hotCacheDisclaimer')}
{auth.hotCacheEnabled && (
{auth.hotCacheDownloadDir && ( )}
{auth.hotCacheDownloadDir && (
{t('settings.hotCacheDirHint')}
)}
{t('settings.cacheUsedHot')} {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
{t('settings.hotCacheTrackCount')} {hotCacheTrackCount}
{t('settings.hotCacheMaxMb')}
auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-max-mb-slider" /> {snapHotCacheMb(auth.hotCacheMaxMb)} MB
{t('settings.hotCacheDebounce')}
auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-debounce-slider" /> {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 ? t('settings.hotCacheDebounceImmediate') : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
)}
)} {/* ── General ──────────────────────────────────────────────────────────── */} {activeTab === 'general' && ( <> {/* App behaviour */}

{t('settings.behavior')}

{t('settings.showTrayIcon')}
{t('settings.showTrayIconDesc')}
{t('settings.minimizeToTray')}
{t('settings.minimizeToTrayDesc')}
{IS_LINUX && !isTilingWm && ( <>
{t('settings.useCustomTitlebar')}
{t('settings.useCustomTitlebarDesc')}
)}
{t('settings.showArtistImages')}
{t('settings.showArtistImagesDesc')}
{t('settings.discordRichPresence')}
{t('settings.discordRichPresenceDesc')}
{auth.discordRichPresence && ( <>
{t('settings.discordAppleCovers')}
{t('settings.discordAppleCoversDesc')}
{t('settings.discordTemplates')}
{t('settings.discordTemplatesDesc')}
auth.setDiscordTemplateDetails(e.target.value)} placeholder="{artist} - {title}" />
auth.setDiscordTemplateState(e.target.value)} placeholder="{album}" />
auth.setDiscordTemplateLargeText(e.target.value)} placeholder="{album}" />
)}
{t('settings.nowPlayingEnabled')}
{t('settings.nowPlayingEnabledDesc')}
{/* Lyrics Sources */} {/* 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} ))}
{/* Ratings (single block under Random Mix) */}

{t('settings.ratingsSectionTitle')}

{t('settings.ratingsSkipStarTitle')}
{t('settings.ratingsSkipStarDesc')}
{auth.skipStarOnManualSkipsEnabled && ( <> auth.setSkipStarManualSkipThreshold(Number(e.target.value))} style={{ width: 72, padding: '6px 10px', fontSize: 13 }} aria-label={t('settings.ratingsSkipStarThresholdLabel')} /> )}
{t('settings.ratingsMixFilterTitle')}
{t('settings.ratingsMixFilterDesc', { mix: t('sidebar.randomMix'), albums: t('sidebar.randomAlbums'), })}
{auth.mixMinRatingFilterEnabled && ( <>
{([ { key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong }, { key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum }, { key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist }, ] as const).map(row => (
{row.label}
))}
)}
)} {/* ── 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: 'en', label: t('settings.languageEn') }, { value: 'de', label: t('settings.languageDe') }, { value: 'es', label: t('settings.languageEs') }, { value: 'fr', label: t('settings.languageFr') }, { value: 'nl', label: t('settings.languageNl') }, { value: 'nb', label: t('settings.languageNb') }, { value: 'ru', label: t('settings.languageRu') }, { value: 'zh', label: t('settings.languageZh') }, ]} />

{t('settings.theme')}

{theme.enableThemeScheduler && (
{t('settings.themeSchedulerActiveHint')}
)} theme.setTheme(v as any)} />

{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 use12h = i18n.language === 'en'; const hourOptions = Array.from({ length: 24 }, (_, i) => { const value = String(i).padStart(2, '0'); const label = use12h ? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}` : value; return { value, label }; }); 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.visualOptionsTitle')}

{t('settings.coverArtBackground')}
{t('settings.coverArtBackgroundSub')}
{t('settings.playlistCoverPhoto')}
{t('settings.playlistCoverPhotoSub')}
{t('settings.showBitrate')}
{t('settings.showBitrateSub')}

{t('settings.uiScaleTitle')}

{/* TODO: UI scaling is being reworked — disabled until fixed */}

Interface scaling is currently being reworked and will be available in a future update.

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

{t('settings.font')}

{fontPickerOpen && (
{( [ { id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" }, { id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" }, { id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" }, { id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" }, { id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" }, { id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" }, { id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" }, { id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" }, { id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" }, { id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" }, { id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" }, { id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" }, ] as { id: FontId; label: string; stack: string }[] ).map(f => ( ))}
)}

{t('settings.fsPlayerSection')}

{t('settings.fsShowArtistPortrait')}
{t('settings.fsShowArtistPortraitDesc')}
{auth.showFsArtistPortrait && (
{t('settings.fsPortraitDim')} {auth.fsPortraitDim}%
auth.setFsPortraitDim(parseInt(e.target.value, 10))} className="ui-scale-slider" />
)}

{t('settings.seekbarStyle')}

{t('settings.seekbarStyleDesc')}
{(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => ( auth.setSeekbarStyle(style)} /> ))}
)} {/* ── 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')], ['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })], ['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.url.startsWith('https://') && ( )} {srv.url.replace(/^https?:\/\//, '')}
{srv.username}
{status === 'ok' && } {status === 'error' && } {status === 'testing' &&
} {!isActive && ( )}
{showAudiomuseNavidromeServerSetting( auth.subsonicServerIdentityByServer[srv.id], auth.instantMixProbeByServer[srv.id], ) && (
{t('settings.audiomuseTitle')} {t('settings.hotCacheAlphaBadge')} {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( )}
{ e.preventDefault(); void openUrl(AUDIOMUSE_NV_PLUGIN_URL); }} style={{ color: 'var(--accent)', textDecoration: 'underline' }} /> ), }} />
)}
); })}
)} {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.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 ( ); } // ── Lyrics Sources Customizer ────────────────────────────────────────────── const LYRICS_SOURCE_LABEL_KEYS: Record = { server: 'settings.lyricsSourceServer', lrclib: 'settings.lyricsSourceLrclib', netease: 'settings.lyricsSourceNetease', }; type LyricsDropTarget = { idx: number; before: boolean } | null; function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) { const { t } = useTranslation(); const { onMouseDown } = useDragSource(() => ({ data: JSON.stringify({ type: 'lyrics_source_reorder', index: idx }), label, })); return ( ); } function LyricsSourcesCustomizer() { const { t } = useTranslation(); const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources)); const setLyricsSources = useAuthStore(s => s.setLyricsSources); const { isDragging: isPsyDragging } = useDragDrop(); const containerRef = useRef(null); const [dropTarget, setDropTarget] = useState(null); const dropTargetRef = useRef(null); const sourcesRef = useRef(lyricsSources); sourcesRef.current = lyricsSources; 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 }; try { parsed = JSON.parse(detail.data as string); } catch { return; } if (parsed.type !== 'lyrics_source_reorder' || parsed.index == null) return; const fromIdx = parsed.index; const target = dropTargetRef.current; dropTargetRef.current = null; setDropTarget(null); if (!target) return; const insertBefore = target.before ? target.idx : target.idx + 1; if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; const next = [...sourcesRef.current]; const [moved] = next.splice(fromIdx, 1); next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); setLyricsSources(next); }; el.addEventListener('psy-drop', onPsyDrop); return () => el.removeEventListener('psy-drop', onPsyDrop); }, [setLyricsSources]); const handleMouseMove = (e: React.MouseEvent) => { if (!isPsyDragging || !containerRef.current) return; const rows = containerRef.current.querySelectorAll('[data-lyrics-idx]'); let target: LyricsDropTarget = null; for (const row of rows) { const rect = row.getBoundingClientRect(); const idx = Number(row.dataset.lyricsIdx); if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } target = { idx, before: false }; } dropTargetRef.current = target; setDropTarget(target); }; const toggleSource = (id: LyricsSourceId) => { setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s)); }; return (

{t('settings.lyricsSourcesTitle')}

{t('settings.lyricsSourcesDesc')}

{lyricsSources.map((src, i) => { const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]); const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before; const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before; return (
{label}
); })}
); } // ── Sidebar Customizer ────────────────────────────────────────────────────── 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 randomNavMode = useAuthStore(s => s.randomNavMode); const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); const libraryItems = items.filter(cfg => { if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false; if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; return true; }); 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. // Only update positions of the *visible* items (same filter as libraryItems/systemItems) // so hidden entries like randomMix/randomAlbums are never overwritten with undefined. const all = [...itemsRef.current]; const visibleIds = new Set(sectionItems.map(c => c.id)); const positions = all.map((cfg, i) => ({ cfg, i })) .filter(({ cfg }) => visibleIds.has(cfg.id)) .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')}

{t('settings.randomNavSplitTitle')}
{t('settings.randomNavSplitDesc')}
{/* 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.slice(0, 3).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)}
; })}
))}
); }