mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
37799fb861
On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, Openbox, etc.)
the window has no title bar at all — neither native nor custom. On stacking DEs
(GNOME, KDE, XFCE) the custom TitleBar can be toggled in settings as before.
Native decorations are disabled on all Linux at startup. The frontend checks
is_tiling_wm() to decide whether to render the custom TitleBar:
- DE (GNOME/KDE): custom TitleBar shown (user-toggleable in settings)
- Tiling WM: no TitleBar at all, setting hidden from Settings page
Detection is based on environment variables:
- HYPRLAND_INSTANCE_SIGNATURE, SWAYSOCK, I3SOCK (direct compositor signatures)
- XDG_CURRENT_DESKTOP check for known tiling WMs
Changes:
- Added is_tiling_wm_cmd() Tauri command for frontend detection
- Native decorations disabled on all Linux at startup
- TitleBar not rendered on tiling WMs
- Custom TitleBar setting hidden in Settings for tiling WMs
2229 lines
106 KiB
TypeScript
2229 lines
106 KiB
TypeScript
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
|
||
} 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 { 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 { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } 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, 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';
|
||
import StarRating from '../components/StarRating';
|
||
|
||
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)',
|
||
],
|
||
},
|
||
{
|
||
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)',
|
||
'Gapless manual skip: honor user-initiated play over pre-chained track (PR #119)',
|
||
'Per-server music folder filter and sidebar library picker (PR #125)',
|
||
],
|
||
},
|
||
{
|
||
github: 'kilyabin',
|
||
since: '1.34.0',
|
||
contributions: [
|
||
'Russian locale improvements (PR #107, PR #120)',
|
||
'Auto-install script for Debian / RHEL (PR #121)',
|
||
],
|
||
},
|
||
{
|
||
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)',
|
||
],
|
||
},
|
||
] 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<ServerProfile, 'id'>) => 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<HTMLInputElement>) =>
|
||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||
|
||
return (
|
||
<div className="settings-card" style={{ marginTop: '1rem' }}>
|
||
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>{t('settings.addServerTitle')}</h3>
|
||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||
<label style={{ fontSize: 13 }}>{t('settings.serverName')}</label>
|
||
<input className="input" type="text" value={form.name} onChange={update('name')} placeholder="My Navidrome" autoComplete="off" />
|
||
</div>
|
||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||
<label style={{ fontSize: 13 }}>{t('settings.serverUrl')}</label>
|
||
<input className="input" type="text" value={form.url} onChange={update('url')} placeholder="192.168.1.100:4533" autoComplete="off" />
|
||
</div>
|
||
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
||
<div className="form-group">
|
||
<label style={{ fontSize: 13 }}>{t('settings.serverUsername')}</label>
|
||
<input className="input" type="text" value={form.username} onChange={update('username')} placeholder="admin" autoComplete="off" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label>
|
||
<div style={{ position: 'relative' }}>
|
||
<input
|
||
className="input"
|
||
type={showPass ? 'text' : 'password'}
|
||
value={form.password}
|
||
onChange={update('password')}
|
||
placeholder="••••••••"
|
||
style={{ paddingRight: '2.5rem' }}
|
||
/>
|
||
<button
|
||
type="button"
|
||
style={{ position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }}
|
||
onClick={() => setShowPass(v => !v)}
|
||
>
|
||
{showPass ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||
<button className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => form.url.trim() && onSave({ name: form.name.trim() || form.url.trim(), url: form.url.trim(), username: form.username.trim(), password: form.password })}
|
||
>
|
||
{t('common.add')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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<boolean>('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<KeyAction | null>(null);
|
||
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
|
||
const navigate = useNavigate();
|
||
const { state: routeState } = useLocation();
|
||
const { t, i18n } = useTranslation();
|
||
|
||
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'general');
|
||
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
|
||
const [showAddForm, setShowAddForm] = useState(false);
|
||
const [newGenre, setNewGenre] = useState('');
|
||
const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle');
|
||
const [lfmPendingToken, setLfmPendingToken] = useState<string | null>(null);
|
||
const [lfmError, setLfmError] = useState<string | null>(null);
|
||
const [lfmUserInfo, setLfmUserInfo] = useState<LastfmUserInfo | null>(null);
|
||
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
|
||
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
||
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(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<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
||
|
||
const handleClearCache = useCallback(async () => {
|
||
setClearing(true);
|
||
await clearImageCache();
|
||
await clearAllOffline(serverId);
|
||
const [imgBytes, offBytes] = await Promise.all([
|
||
getImageCacheSize(),
|
||
invoke<number>('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<ServerProfile, 'id'>) => {
|
||
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 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<number>('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: <AppWindow size={15} /> },
|
||
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
|
||
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
|
||
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
|
||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
|
||
{ id: 'system', label: t('settings.tabSystem'), icon: <Info size={15} /> },
|
||
];
|
||
|
||
return (
|
||
<div className="content-body animate-fade-in">
|
||
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('settings.title')}</h1>
|
||
|
||
{/* Tab navigation */}
|
||
<nav className="settings-tabs" aria-label="Settings navigation">
|
||
{tabs.map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
>
|
||
{tab.icon}
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
|
||
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'audio' && (
|
||
<>
|
||
{/* Equalizer */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Sliders size={18} />
|
||
<h2>{t('settings.eqTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<Equalizer />
|
||
</div>
|
||
</section>
|
||
|
||
{/* Replay Gain + Crossfade + Gapless */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Music2 size={18} />
|
||
<h2>{t('settings.playbackTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
{/* Replay Gain */}
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.replayGain')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
|
||
<input type="checkbox" checked={auth.replayGainEnabled} onChange={e => auth.setReplayGainEnabled(e.target.checked)} id="replay-gain-toggle" />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
{auth.replayGainEnabled && (
|
||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
||
<button
|
||
className={`btn ${auth.replayGainMode === 'track' ? 'btn-primary' : 'btn-ghost'}`}
|
||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||
onClick={() => auth.setReplayGainMode('track')}
|
||
>
|
||
{t('settings.replayGainTrack')}
|
||
</button>
|
||
<button
|
||
className={`btn ${auth.replayGainMode === 'album' ? 'btn-primary' : 'btn-ghost'}`}
|
||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||
onClick={() => auth.setReplayGainMode('album')}
|
||
>
|
||
{t('settings.replayGainAlbum')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="divider" />
|
||
|
||
{/* Crossfade */}
|
||
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>
|
||
{t('settings.crossfade')}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
|
||
</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
|
||
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
|
||
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
|
||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<input
|
||
type="range"
|
||
min={0.1}
|
||
max={10}
|
||
step={0.1}
|
||
value={auth.crossfadeSecs}
|
||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||
style={{ width: 120 }}
|
||
id="crossfade-secs-slider"
|
||
/>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="divider" />
|
||
|
||
{/* Gapless */}
|
||
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>
|
||
{t('settings.gapless')}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
|
||
</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.gapless')}>
|
||
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
|
||
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="divider" />
|
||
|
||
{/* Preload mode */}
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
{(['balanced', 'early', 'custom'] as const).map(mode => (
|
||
<button
|
||
key={mode}
|
||
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
|
||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||
onClick={() => auth.setPreloadMode(mode)}
|
||
>
|
||
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{auth.preloadMode === 'custom' && (
|
||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<input
|
||
type="range"
|
||
min={5} max={120} step={5}
|
||
value={auth.preloadCustomSeconds}
|
||
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
|
||
style={{ width: 120 }}
|
||
/>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</section>
|
||
|
||
{/* Native Hi-Res Playback */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Waves size={18} />
|
||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{t('settings.hiResTitle')}
|
||
<span style={{
|
||
fontSize: 10, fontWeight: 600, textTransform: 'uppercase',
|
||
letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4,
|
||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||
color: 'var(--text-primary)',
|
||
}}>
|
||
{t('settings.hotCacheAlphaBadge')}
|
||
</span>
|
||
</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={auth.enableHiRes}
|
||
onChange={e => auth.setEnableHiRes(e.target.checked)}
|
||
id="hires-enabled-toggle"
|
||
/>
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</>
|
||
)}
|
||
|
||
{/* ── General ──────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'general' && (
|
||
<>
|
||
{/* App behaviour */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<AppWindow size={18} />
|
||
<h2>{t('settings.behavior')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
|
||
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
<div className="settings-section-divider" />
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
|
||
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
{IS_LINUX && !isTilingWm && (
|
||
<>
|
||
<div className="settings-section-divider" />
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.useCustomTitlebar')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.useCustomTitlebarDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.useCustomTitlebar')}>
|
||
<input type="checkbox" checked={auth.useCustomTitlebar} onChange={e => auth.setUseCustomTitlebar(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="settings-section-divider" />
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
|
||
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
<div className="settings-section-divider" />
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
|
||
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
{auth.discordRichPresence && (
|
||
<>
|
||
<div className="settings-section-divider" />
|
||
<div className="settings-toggle-row" style={{ paddingLeft: 16 }}>
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.discordAppleCovers')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordAppleCoversDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.discordAppleCovers')}>
|
||
<input type="checkbox" checked={auth.enableAppleMusicCoversDiscord} onChange={e => auth.setEnableAppleMusicCoversDiscord(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="settings-section-divider" />
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
|
||
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
<div className="settings-section-divider" />
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.lyricsServerFirst')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsServerFirstDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.lyricsServerFirst')}>
|
||
<input type="checkbox" checked={auth.lyricsServerFirst} onChange={e => auth.setLyricsServerFirst(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Random Mix */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Shuffle size={18} />
|
||
<h2>{t('settings.randomMixTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||
{t('settings.randomMixBlacklistDesc')}
|
||
</p>
|
||
|
||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||
{auth.customGenreBlacklist.length === 0 ? (
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||
) : (
|
||
auth.customGenreBlacklist.map(genre => (
|
||
<span key={genre} style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||
}}>
|
||
{genre}
|
||
<button
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||
aria-label={`Remove ${genre}`}
|
||
>×</button>
|
||
</span>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||
<input
|
||
className="input"
|
||
type="text"
|
||
value={newGenre}
|
||
onChange={e => 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 }}
|
||
/>
|
||
<button
|
||
className="btn btn-ghost"
|
||
onClick={() => {
|
||
const trimmed = newGenre.trim();
|
||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||
}
|
||
setNewGenre('');
|
||
}}
|
||
disabled={!newGenre.trim()}
|
||
>
|
||
{t('settings.randomMixBlacklistAdd')}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||
|
||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||
<span key={genre} className="genre-keyword-badge" style={{
|
||
display: 'inline-flex', alignItems: 'center',
|
||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||
}}>
|
||
{genre}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* Ratings (single block under Random Mix) */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Star size={18} />
|
||
<h2>{t('settings.ratingsSectionTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsSkipStarDesc')}</div>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||
{auth.skipStarOnManualSkipsEnabled && (
|
||
<>
|
||
<label htmlFor="settings-skip-star-threshold" style={{ fontSize: 13, color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>
|
||
{t('settings.ratingsSkipStarThresholdLabel')}
|
||
</label>
|
||
<input
|
||
id="settings-skip-star-threshold"
|
||
className="input"
|
||
type="number"
|
||
min={1}
|
||
max={99}
|
||
value={auth.skipStarManualSkipThreshold}
|
||
onChange={e => auth.setSkipStarManualSkipThreshold(Number(e.target.value))}
|
||
style={{ width: 72, padding: '6px 10px', fontSize: 13 }}
|
||
aria-label={t('settings.ratingsSkipStarThresholdLabel')}
|
||
/>
|
||
</>
|
||
)}
|
||
<label className="toggle-switch" aria-label={t('settings.ratingsSkipStarTitle')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={auth.skipStarOnManualSkipsEnabled}
|
||
onChange={e => auth.setSkipStarOnManualSkipsEnabled(e.target.checked)}
|
||
/>
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="settings-section-divider" />
|
||
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||
{t('settings.ratingsMixFilterDesc', {
|
||
mix: t('sidebar.randomMix'),
|
||
albums: t('sidebar.randomAlbums'),
|
||
})}
|
||
</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={auth.mixMinRatingFilterEnabled}
|
||
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
|
||
/>
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
{auth.mixMinRatingFilterEnabled && (
|
||
<>
|
||
<div className="settings-section-divider" />
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
||
gap: '1rem 0.75rem',
|
||
alignItems: 'start',
|
||
}}
|
||
>
|
||
{([
|
||
{ 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 => (
|
||
<div
|
||
key={row.key}
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
minWidth: 0,
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-secondary)' }}>{row.label}</span>
|
||
<StarRating
|
||
maxSelectable={MIX_MIN_RATING_FILTER_MAX_STARS}
|
||
value={row.value}
|
||
onChange={row.set}
|
||
ariaLabel={t('settings.ratingsMixMinThresholdAria', { label: row.label })}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<HomeCustomizer />
|
||
</>
|
||
)}
|
||
|
||
{/* ── Storage & Downloads ───────────────────────────────────────────────── */}
|
||
{activeTab === 'storage' && (
|
||
<>
|
||
{/* Offline Library (In-App) — includes cache settings */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Download size={18} />
|
||
<h2>{t('settings.offlineDirTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||
{t('settings.offlineDirDesc')}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<input
|
||
className="input"
|
||
type="text"
|
||
readOnly
|
||
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
|
||
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||
/>
|
||
{auth.offlineDownloadDir && (
|
||
<button
|
||
className="btn btn-ghost"
|
||
onClick={() => auth.setOfflineDownloadDir('')}
|
||
data-tooltip={t('settings.offlineDirClear')}
|
||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
)}
|
||
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
|
||
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
|
||
</button>
|
||
</div>
|
||
{auth.offlineDownloadDir && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||
{t('settings.offlineDirHint')}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||
|
||
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
|
||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
<div style={{ color: 'var(--text-secondary)' }}>
|
||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
|
||
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
|
||
</div>
|
||
<div style={{ color: 'var(--text-secondary)' }}>
|
||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
|
||
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
min={100}
|
||
max={50000}
|
||
step={100}
|
||
value={auth.maxCacheMb}
|
||
onChange={e => {
|
||
const v = Number(e.target.value);
|
||
if (v >= 100) auth.setMaxCacheMb(v);
|
||
}}
|
||
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
|
||
id="cache-size-input"
|
||
/>
|
||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
|
||
</div>
|
||
{showClearConfirm ? (
|
||
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
|
||
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
|
||
onClick={handleClearCache}
|
||
disabled={clearing}
|
||
>
|
||
{t('settings.cacheClearConfirm')}
|
||
</button>
|
||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
|
||
{t('settings.cacheClearCancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
|
||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<HardDrive size={18} />
|
||
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
{t('settings.hotCacheTitle')}
|
||
<span
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.04em',
|
||
padding: '2px 6px',
|
||
borderRadius: 4,
|
||
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
|
||
color: 'var(--text-primary)',
|
||
}}
|
||
>
|
||
{t('settings.hotCacheAlphaBadge')}
|
||
</span>
|
||
</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||
{t('settings.hotCacheDisclaimer')}
|
||
</div>
|
||
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheEnabled')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
|
||
<input
|
||
type="checkbox"
|
||
checked={auth.hotCacheEnabled}
|
||
onChange={async e => {
|
||
const enabled = e.target.checked;
|
||
if (!enabled) {
|
||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||
setHotCacheBytes(0);
|
||
auth.setHotCacheEnabled(false);
|
||
} else {
|
||
auth.setHotCacheEnabled(true);
|
||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null })
|
||
.then(setHotCacheBytes)
|
||
.catch(() => setHotCacheBytes(0));
|
||
}
|
||
}}
|
||
id="hot-cache-enabled-toggle"
|
||
/>
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
|
||
{auth.hotCacheEnabled && (
|
||
<div style={{ marginTop: '1.25rem' }}>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<input
|
||
className="input"
|
||
type="text"
|
||
readOnly
|
||
value={auth.hotCacheDownloadDir || t('settings.hotCacheDirDefault')}
|
||
style={{ flex: 1, fontSize: 13, color: auth.hotCacheDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||
/>
|
||
{auth.hotCacheDownloadDir && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
onClick={() => {
|
||
auth.setHotCacheDownloadDir('');
|
||
useHotCacheStore.setState({ entries: {} });
|
||
invoke<number>('get_hot_cache_size', { customDir: null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||
}}
|
||
data-tooltip={t('settings.hotCacheDirClear')}
|
||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
)}
|
||
<button type="button" className="btn btn-surface" onClick={pickHotCacheDir} style={{ flexShrink: 0 }}>
|
||
<FolderOpen size={16} /> {t('settings.hotCacheDirChange')}
|
||
</button>
|
||
</div>
|
||
{auth.hotCacheDownloadDir && (
|
||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||
{t('settings.hotCacheDirHint')}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||
|
||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
<div style={{ color: 'var(--text-secondary)' }}>
|
||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||
</div>
|
||
<div style={{ color: 'var(--text-secondary)' }}>
|
||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||
{hotCacheTrackCount}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<input
|
||
type="range"
|
||
min={32}
|
||
max={20000}
|
||
step={32}
|
||
value={snapHotCacheMb(auth.hotCacheMaxMb)}
|
||
onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))}
|
||
style={{ width: 140 }}
|
||
id="hot-cache-max-mb-slider"
|
||
/>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 72 }}>
|
||
{snapHotCacheMb(auth.hotCacheMaxMb)} MB
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: '0.75rem' }}>
|
||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={600}
|
||
step={1}
|
||
value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))}
|
||
onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))}
|
||
style={{ width: 140 }}
|
||
id="hot-cache-debounce-slider"
|
||
/>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||
? t('settings.hotCacheDebounceImmediate')
|
||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
style={{ fontSize: 13 }}
|
||
onClick={async () => {
|
||
await clearHotCacheDisk(auth.hotCacheDownloadDir || null);
|
||
const b = await invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).catch(() => 0);
|
||
setHotCacheBytes(b);
|
||
}}
|
||
>
|
||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
{/* ZIP Export & Archiving */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<FolderOpen size={18} />
|
||
<h2>{t('settings.downloadsTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
|
||
{t('settings.downloadsFolderDesc')}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<input
|
||
className="input"
|
||
type="text"
|
||
readOnly
|
||
value={auth.downloadFolder || t('settings.downloadsDefault')}
|
||
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
|
||
/>
|
||
{auth.downloadFolder && (
|
||
<button
|
||
className="btn btn-ghost"
|
||
onClick={() => auth.setDownloadFolder('')}
|
||
aria-label={t('settings.clearFolder')}
|
||
data-tooltip={t('settings.clearFolder')}
|
||
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
)}
|
||
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
|
||
<FolderOpen size={16} /> {t('settings.pickFolder')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||
{activeTab === 'appearance' && (
|
||
<>
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Globe size={18} />
|
||
<h2>{t('settings.language')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||
<CustomSelect
|
||
value={i18n.language}
|
||
onChange={v => i18n.changeLanguage(v)}
|
||
options={[
|
||
{ value: 'en', label: t('settings.languageEn') },
|
||
{ value: 'de', label: t('settings.languageDe') },
|
||
{ 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') },
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Palette size={18} />
|
||
<h2>{t('settings.theme')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
{theme.enableThemeScheduler && (
|
||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||
{t('settings.themeSchedulerActiveHint')}
|
||
</div>
|
||
)}
|
||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Clock size={18} />
|
||
<h2>{t('settings.themeSchedulerTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeSchedulerEnableSub')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.themeSchedulerEnable')}>
|
||
<input type="checkbox" checked={theme.enableThemeScheduler} onChange={e => theme.setEnableThemeScheduler(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
{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 (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}>
|
||
<div className="form-group">
|
||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayTheme')}</label>
|
||
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayStart')}</label>
|
||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||
<CustomSelect value={dayH} onChange={v => theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} />
|
||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||
<CustomSelect value={dayM} onChange={v => theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} />
|
||
</div>
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightTheme')}</label>
|
||
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightStart')}</label>
|
||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||
<CustomSelect value={nightH} onChange={v => theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} />
|
||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||
<CustomSelect value={nightM} onChange={v => theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<ZoomIn size={18} />
|
||
<h2>{t('settings.uiScaleTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||
{Math.round(fontStore.uiScale * 100)}%
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={0.8}
|
||
max={1.5}
|
||
step={0.05}
|
||
value={fontStore.uiScale}
|
||
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
|
||
className="ui-scale-slider"
|
||
/>
|
||
<div style={{ position: 'relative', height: 24 }}>
|
||
{[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 (
|
||
<button
|
||
key={p}
|
||
className="btn btn-ghost"
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${pct}%`,
|
||
transform: 'translateX(-50%)',
|
||
fontSize: 11,
|
||
padding: '2px 6px',
|
||
opacity: active ? 1 : 0.5,
|
||
color: active ? 'var(--accent)' : undefined,
|
||
}}
|
||
onClick={() => fontStore.setUiScale(p / 100)}
|
||
>
|
||
{p}%
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Type size={18} />
|
||
<h2>{t('settings.font')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
{(
|
||
[
|
||
{ 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 => (
|
||
<button
|
||
key={f.id}
|
||
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
|
||
style={{ justifyContent: 'flex-start', fontFamily: f.stack }}
|
||
onClick={() => fontStore.setFont(f.id)}
|
||
>
|
||
{f.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Sliders size={18} />
|
||
<h2>{t('settings.seekbarStyle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||
{t('settings.seekbarStyleDesc')}
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||
{(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
|
||
<SeekbarPreview
|
||
key={style}
|
||
style={style}
|
||
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}` as any)}
|
||
selected={auth.seekbarStyle === style}
|
||
onClick={() => auth.setSeekbarStyle(style)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<SidebarCustomizer />
|
||
</>
|
||
)}
|
||
|
||
{/* ── Input ────────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'input' && (
|
||
<>
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Keyboard size={18} />
|
||
<h2>{t('settings.tabInput')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}>
|
||
{t('settings.shortcutsReset')}
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{([
|
||
['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 (
|
||
<div key={action} style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||
transition: 'background 0.15s',
|
||
}}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
<button
|
||
onClick={() => {
|
||
if (isListening) { setListeningFor(null); return; }
|
||
setListeningFor(action);
|
||
const handler = (e: KeyboardEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.code !== 'Escape') {
|
||
// unbind any existing action with this key first
|
||
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
|
||
.find(([, c]) => c === e.code)?.[0];
|
||
if (existing && existing !== action) kb.setBinding(existing, null);
|
||
kb.setBinding(action, e.code);
|
||
}
|
||
setListeningFor(null);
|
||
window.removeEventListener('keydown', handler, true);
|
||
};
|
||
window.addEventListener('keydown', handler, true);
|
||
}}
|
||
className="keybind-badge"
|
||
style={{
|
||
minWidth: 72, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{isListening ? t('settings.shortcutListening') : bound ? formatKeyCode(bound) : t('settings.shortcutUnbound')}
|
||
</button>
|
||
{bound && !isListening && (
|
||
<button
|
||
onClick={() => kb.setBinding(action, null)}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||
data-tooltip={t('settings.shortcutClear')}
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Keyboard size={18} />
|
||
<h2>{t('settings.globalShortcutsTitle')}</h2>
|
||
</div>
|
||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '12px', lineHeight: 1.5 }}>
|
||
{t('settings.globalShortcutsNote')}
|
||
</p>
|
||
<div className="settings-card">
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
|
||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}>
|
||
{t('settings.shortcutsReset')}
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
{([
|
||
['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 (
|
||
<div key={action} style={{
|
||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||
padding: '8px 10px', borderRadius: 'var(--radius-sm)',
|
||
background: isListening ? 'var(--accent-dim)' : 'transparent',
|
||
transition: 'background 0.15s',
|
||
}}>
|
||
<span style={{ fontSize: 13, color: 'var(--text-primary)' }}>{label}</span>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
<button
|
||
onClick={() => {
|
||
if (isListening) { setListeningForGlobal(null); return; }
|
||
setListeningForGlobal(action);
|
||
const handler = (e: KeyboardEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.code === 'Escape') {
|
||
setListeningForGlobal(null);
|
||
window.removeEventListener('keydown', handler, true);
|
||
return;
|
||
}
|
||
const shortcut = buildGlobalShortcut(e);
|
||
if (shortcut) {
|
||
gs.setShortcut(action, shortcut);
|
||
setListeningForGlobal(null);
|
||
window.removeEventListener('keydown', handler, true);
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handler, true);
|
||
}}
|
||
className="keybind-badge"
|
||
style={{
|
||
minWidth: 120, padding: '3px 10px', borderRadius: 'var(--radius-sm)',
|
||
fontSize: 12, fontWeight: 600, fontFamily: 'monospace',
|
||
background: isListening ? 'var(--accent)' : bound ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||
color: isListening ? 'var(--ctp-base)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
|
||
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{isListening ? t('settings.shortcutListening') : bound ? formatGlobalShortcut(bound) : t('settings.shortcutUnbound')}
|
||
</button>
|
||
{bound && !isListening && (
|
||
<button
|
||
onClick={() => gs.setShortcut(action, null)}
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '2px 4px', lineHeight: 1 }}
|
||
data-tooltip={t('settings.shortcutClear')}
|
||
>
|
||
<X size={12} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Server ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'server' && (
|
||
<>
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Server size={18} />
|
||
<h2>{t('settings.servers')}</h2>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||
{t('settings.serverCompatible')}
|
||
</div>
|
||
|
||
{auth.servers.length === 0 && !showAddForm ? (
|
||
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
|
||
{t('settings.noServers')}
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
{auth.servers.map(srv => {
|
||
const isActive = srv.id === auth.activeServerId;
|
||
const status = connStatus[srv.id];
|
||
return (
|
||
<div key={srv.id} className="settings-card" style={{ border: isActive ? '1px solid var(--accent)' : undefined }}>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px' }}>
|
||
<span style={{ fontWeight: 600 }}>{srv.name || srv.url}</span>
|
||
{isActive && (
|
||
<span style={{ fontSize: 11, background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '1px 6px', borderRadius: '10px', fontWeight: 600 }}>
|
||
{t('settings.serverActive')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{srv.username}@{srv.url}</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center' }}>
|
||
{status === 'ok' && <CheckCircle2 size={16} style={{ color: 'var(--positive)' }} />}
|
||
{status === 'error' && <WifiOff size={16} style={{ color: 'var(--danger)' }} />}
|
||
{status === 'testing' && <div className="spinner" style={{ width: 16, height: 16 }} />}
|
||
<button
|
||
className="btn btn-surface"
|
||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||
onClick={() => testConnection(srv)}
|
||
disabled={status === 'testing'}
|
||
>
|
||
<Wifi size={13} />
|
||
{t('settings.testBtn')}
|
||
</button>
|
||
{!isActive && (
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ fontSize: 12, padding: '4px 10px' }}
|
||
onClick={() => switchToServer(srv)}
|
||
disabled={status === 'testing'}
|
||
id={`settings-use-server-${srv.id}`}
|
||
>
|
||
{t('settings.useServer')}
|
||
</button>
|
||
)}
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ color: 'var(--danger)', padding: '4px 8px' }}
|
||
onClick={() => deleteServer(srv)}
|
||
data-tooltip={t('settings.deleteServer')}
|
||
id={`settings-delete-server-${srv.id}`}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{showAddForm ? (
|
||
<AddServerForm onSave={handleAddServer} onCancel={() => setShowAddForm(false)} />
|
||
) : (
|
||
<button className="btn btn-surface" style={{ marginTop: '0.75rem' }} onClick={() => setShowAddForm(true)} id="settings-add-server-btn">
|
||
<Plus size={16} /> {t('settings.addServer')}
|
||
</button>
|
||
)}
|
||
</section>
|
||
|
||
{/* Last.fm */}
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<LastfmIcon size={18} />
|
||
<h2>{t('settings.lfmTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card">
|
||
{auth.lastfmSessionKey ? (
|
||
/* ── Connected state ── */
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
|
||
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
|
||
{lfmUserInfo && (
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
|
||
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
|
||
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
|
||
onClick={() => auth.disconnectLastfm()}
|
||
>
|
||
{t('settings.lfmDisconnect')}
|
||
</button>
|
||
</div>
|
||
<div className="settings-toggle-row">
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
|
||
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
) : lfmState === 'waiting' ? (
|
||
/* ── Waiting for browser auth — auto-polling ── */
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
|
||
{t('settings.lfmConnecting')}
|
||
</div>
|
||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
|
||
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
|
||
{t('common.cancel')}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
/* ── Not connected ── */
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||
{t('settings.lfmConnectDesc')}
|
||
</p>
|
||
{lfmState === 'error' && (
|
||
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
|
||
)}
|
||
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
|
||
{t('settings.lfmConnect')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="settings-section">
|
||
<button className="btn btn-danger" onClick={handleLogout} id="settings-logout-btn">
|
||
<LogOut size={16} /> {t('settings.logout')}
|
||
</button>
|
||
</section>
|
||
|
||
</>
|
||
)}
|
||
|
||
{/* ── System ───────────────────────────────────────────────────────────── */}
|
||
{activeTab === 'system' && (
|
||
<>
|
||
<BackupSection />
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Info size={18} />
|
||
<h2>{t('settings.aboutTitle')}</h2>
|
||
</div>
|
||
<div className="settings-card settings-about">
|
||
<div className="settings-about-header">
|
||
<img src="/logo-psysonic.png" width={52} height={52} alt="Psysonic" style={{ borderRadius: 14 }} />
|
||
<div>
|
||
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||
Psysonic
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||
{t('settings.aboutVersion')} {appVersion}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
|
||
{t('settings.aboutDesc')}
|
||
</p>
|
||
<p style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.6, margin: '0.5rem 0' }}>
|
||
{t('settings.aboutFeatures')}
|
||
</p>
|
||
|
||
<div className="divider" style={{ margin: '1rem 0' }} />
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', fontSize: 13 }}>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutLicense')}</span>
|
||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutLicenseText')}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>Stack</span>
|
||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutBuiltWith')}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
|
||
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
|
||
</div>
|
||
<div>
|
||
<button
|
||
style={{ display: 'flex', width: '100%', alignItems: 'center', gap: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textAlign: 'left' }}
|
||
onClick={() => setContributorsOpen(o => !o)}
|
||
>
|
||
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0 }}>{t('settings.aboutContributorsLabel')}</span>
|
||
<span style={{ color: 'var(--text-secondary)', flex: 1 }}>{CONTRIBUTORS.length}</span>
|
||
<ChevronDown size={13} style={{ color: 'var(--text-muted)', transform: contributorsOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
|
||
</button>
|
||
|
||
{contributorsOpen && (
|
||
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||
{CONTRIBUTORS.map(c => (
|
||
<div key={c.github} style={{
|
||
display: 'flex', gap: '0.75rem', alignItems: 'flex-start',
|
||
background: 'var(--bg-elevated)', borderRadius: 'var(--radius-md)',
|
||
padding: '0.65rem 0.75rem',
|
||
boxShadow: 'inset 0 0 0 1px var(--border-subtle)',
|
||
}}>
|
||
<img
|
||
src={`https://github.com/${c.github}.png?size=48`}
|
||
width={36} height={36}
|
||
style={{ borderRadius: '50%', flexShrink: 0, marginTop: 2 }}
|
||
alt={c.github}
|
||
/>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.3rem' }}>
|
||
<button
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
|
||
onClick={() => openUrl(`https://github.com/${c.github}`)}
|
||
>
|
||
@{c.github}
|
||
</button>
|
||
<span style={{ fontSize: 10, background: 'var(--accent-dim)', color: 'var(--accent)', padding: '1px 6px', borderRadius: 99, fontWeight: 600 }}>
|
||
v{c.since}
|
||
</span>
|
||
</div>
|
||
<ul style={{ margin: 0, padding: '0 0 0 1rem', fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||
{c.contributions.map(item => <li key={item}>{item}</li>)}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0, paddingTop: 2, fontSize: 13 }}>{t('settings.aboutSpecialThanksLabel')}</span>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', flex: 1 }}>
|
||
{SPECIAL_THANKS.map(s => (
|
||
<div key={s.github} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<img
|
||
src={`https://github.com/${s.github}.png?size=32`}
|
||
width={22} height={22}
|
||
style={{ borderRadius: '50%', flexShrink: 0 }}
|
||
alt={s.github}
|
||
/>
|
||
<button
|
||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
|
||
onClick={() => openUrl(`https://github.com/${s.github}`)}
|
||
>
|
||
@{s.github}
|
||
</button>
|
||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>— {s.reason}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ marginTop: '1.25rem', alignSelf: 'flex-start' }}
|
||
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
|
||
>
|
||
<ExternalLink size={14} />
|
||
{t('settings.aboutRepo')}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<ChangelogSection />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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 <strong key={i}>{part.slice(2, -2)}</strong>;
|
||
if (part.startsWith('*') && part.endsWith('*'))
|
||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||
if (part.startsWith('`') && part.endsWith('`'))
|
||
return <code key={i} className="changelog-code">{part.slice(1, -1)}</code>;
|
||
return part;
|
||
});
|
||
}
|
||
|
||
function HomeCustomizer() {
|
||
const { t } = useTranslation();
|
||
const { sections, toggleSection, reset } = useHomeStore();
|
||
|
||
const SECTION_LABELS: Record<HomeSectionId, string> = {
|
||
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 (
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<LayoutGrid size={18} />
|
||
<h2>{t('settings.homeCustomizerTitle')}</h2>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||
onClick={reset}
|
||
data-tooltip={t('settings.sidebarReset')}
|
||
>
|
||
<RotateCcw size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||
{sections.map(sec => (
|
||
<div key={sec.id} className="settings-toggle-row" style={{ padding: '8px 16px' }}>
|
||
<span style={{ fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
|
||
<label className="toggle-switch" aria-label={SECTION_LABELS[sec.id]}>
|
||
<input type="checkbox" checked={sec.visible} onChange={() => toggleSection(sec.id)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<span
|
||
className="sidebar-customizer-grip"
|
||
data-tooltip={t('settings.sidebarDrag')}
|
||
data-tooltip-pos="right"
|
||
onMouseDown={onMouseDown}
|
||
>
|
||
<GripVertical size={16} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement>(null);
|
||
const [dropTarget, setDropTarget] = useState<DropTarget>(null);
|
||
const dropTargetRef = useRef<DropTarget>(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<HTMLElement>('[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 (
|
||
<div
|
||
key={cfg.id}
|
||
data-sidebar-idx={localIdx}
|
||
data-sidebar-section={section}
|
||
className="sidebar-customizer-row"
|
||
style={{
|
||
borderTop: isBefore ? '2px solid var(--accent)' : undefined,
|
||
borderBottom: isAfter ? '2px solid var(--accent)' : undefined,
|
||
}}
|
||
>
|
||
<SidebarGripHandle idx={localIdx} section={section} label={t(meta.labelKey)} />
|
||
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||
<span style={{ flex: 1, fontSize: 14 }}>{t(meta.labelKey)}</span>
|
||
<label className="toggle-switch" aria-label={t(meta.labelKey)}>
|
||
<input type="checkbox" checked={cfg.visible} onChange={() => toggleItem(cfg.id)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<PanelLeft size={18} />
|
||
<h2>{t('settings.sidebarTitle')}</h2>
|
||
<button
|
||
className="btn btn-ghost"
|
||
style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}
|
||
onClick={reset}
|
||
data-tooltip={t('settings.sidebarReset')}
|
||
>
|
||
<RotateCcw size={14} />
|
||
</button>
|
||
</div>
|
||
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
{/* Library block */}
|
||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
|
||
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
|
||
</div>
|
||
{/* System block */}
|
||
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
|
||
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
|
||
<div className="sidebar-customizer-fixed-hint">
|
||
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<HardDrive size={18} />
|
||
<h2>{t('settings.backupTitle')}</h2>
|
||
</div>
|
||
|
||
{/* Export */}
|
||
<div className="settings-card" style={{ marginBottom: '1rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
|
||
</div>
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={handleExport}
|
||
disabled={exporting}
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<Upload size={14} />
|
||
{exporting ? '…' : t('settings.backupExport')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Import */}
|
||
<div className="settings-card">
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
|
||
</div>
|
||
<button
|
||
className="btn btn-surface"
|
||
onClick={handleImport}
|
||
disabled={importing}
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
<Download size={14} />
|
||
{importing ? '…' : t('settings.backupImport')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<section className="settings-section">
|
||
<div className="settings-section-header">
|
||
<Info size={18} />
|
||
<h2>{t('settings.changelog')}</h2>
|
||
</div>
|
||
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showChangelogOnUpdateDesc')}</div>
|
||
</div>
|
||
<label className="toggle-switch" aria-label={t('settings.showChangelogOnUpdate')}>
|
||
<input type="checkbox" checked={showChangelogOnUpdate} onChange={e => setShowChangelogOnUpdate(e.target.checked)} />
|
||
<span className="toggle-track" />
|
||
</label>
|
||
</div>
|
||
<div className="changelog-list">
|
||
{versions.map(({ version, date, body }) => (
|
||
<details key={version} className="changelog-entry" open={version === appVersion}>
|
||
<summary className="changelog-summary">
|
||
<span className="changelog-version">v{version}</span>
|
||
<span className="changelog-date">{date}</span>
|
||
</summary>
|
||
<div className="changelog-body">
|
||
{body.split('\n').map((line, i) => {
|
||
if (line.startsWith('### ')) {
|
||
return <div key={i} className="changelog-h3">{renderInline(line.slice(4))}</div>;
|
||
}
|
||
if (line.startsWith('#### ')) {
|
||
return <div key={i} className="changelog-h4">{renderInline(line.slice(5))}</div>;
|
||
}
|
||
if (line.startsWith('- ')) {
|
||
return <div key={i} className="changelog-item">{renderInline(line.slice(2))}</div>;
|
||
}
|
||
if (line.trim() === '') return null;
|
||
return <div key={i} className="changelog-text">{renderInline(line)}</div>;
|
||
})}
|
||
</div>
|
||
</details>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|