refactor(settings): co-locate settings feature into features/settings

Theme infra (fixedThemes, theme utils), music-network presetIcon, audio-device
probe, library-index sync, and the auth*SettingsActions slices stay in the
core/global layer (consumed via @/ alias) to avoid inverting core->feature.
This commit is contained in:
Psychotoxical
2026-06-29 23:48:19 +02:00
parent fbc37db64e
commit e945da693a
69 changed files with 361 additions and 345 deletions
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { AddServerForm } from '@/features/settings/components/AddServerForm';
import { encodeServerMagicString } from '@/utils/server/serverMagicString';
// resolve_host_addresses Tauri command — hint-only, must not block save.
vi.mock('@/api/network', () => ({
resolveHostAddresses: vi.fn(async () => [] as string[]),
}));
// showToast mocked so we can assert two-LAN validation surfaced the error.
vi.mock('@/utils/ui/toast', () => ({
showToast: vi.fn(),
}));
import { showToast } from '@/utils/ui/toast';
describe('AddServerForm — dual-address behaviour', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('saves a single-address profile without alternateUrl / shareUsesLocalUrl', async () => {
const onSave = vi.fn();
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
const user = userEvent.setup();
const inputs = screen.getAllByRole('textbox');
// [0] name, [1] primary url, [2] alternate url, [3] username, [4] magic string
await user.type(inputs[1]!, 'https://music.example.com');
await user.type(inputs[3]!, 'tester');
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
await user.click(screen.getByRole('button', { name: /add/i }));
expect(onSave).toHaveBeenCalledTimes(1);
const arg = onSave.mock.calls[0]![0];
expect(arg.url).toBe('https://music.example.com');
expect(arg.username).toBe('tester');
expect(arg.password).toBe('pw');
expect(arg).not.toHaveProperty('alternateUrl');
expect(arg).not.toHaveProperty('shareUsesLocalUrl');
});
it('saves both addresses when the user fills the second field', async () => {
const onSave = vi.fn();
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
const user = userEvent.setup();
const inputs = screen.getAllByRole('textbox');
// [0] name, [1] primary url, [2] alternate url, [3] username
await user.type(inputs[1]!, 'https://music.example.com');
await user.type(inputs[2]!, 'http://192.168.0.10:4533');
await user.type(inputs[3]!, 'tester');
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
await user.click(screen.getByRole('button', { name: /add/i }));
expect(onSave).toHaveBeenCalledTimes(1);
const arg = onSave.mock.calls[0]![0];
expect(arg.url).toBe('https://music.example.com');
expect(arg.alternateUrl).toBe('http://192.168.0.10:4533');
expect(arg.shareUsesLocalUrl).toBe(false);
});
it('blocks save with a toast when both addresses classify as LAN', async () => {
const onSave = vi.fn();
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
const user = userEvent.setup();
const inputs = screen.getAllByRole('textbox');
await user.type(inputs[1]!, 'http://10.0.0.5');
await user.type(inputs[2]!, 'http://192.168.0.10');
await user.type(inputs[3]!, 'tester');
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
await user.click(screen.getByRole('button', { name: /add/i }));
// Save is blocked, error toast surfaced with the two-LAN string.
expect(onSave).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith(
expect.stringMatching(/both addresses are local/i),
expect.any(Number),
'error',
);
});
it('decodes a v2 magic string and forwards alternateUrl + shareUsesLocalUrl on save', async () => {
const onSave = vi.fn();
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
const user = userEvent.setup();
const magicString = encodeServerMagicString({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10:4533',
shareUsesLocalUrl: true,
username: 'tester',
password: 'pw',
});
// The magic-string input is the last textbox shown for new-profile mode.
const inputs = screen.getAllByRole('textbox');
const magicInput = inputs[inputs.length - 1]!;
await user.type(magicInput, magicString);
await user.click(screen.getByRole('button', { name: /add/i }));
expect(onSave).toHaveBeenCalledTimes(1);
const arg = onSave.mock.calls[0]![0];
expect(arg.url).toBe('https://music.example.com');
expect(arg.alternateUrl).toBe('http://192.168.0.10:4533');
expect(arg.shareUsesLocalUrl).toBe(true);
expect(arg.username).toBe('tester');
expect(arg.password).toBe('pw');
});
it('strips alternateUrl + share flag when the user empties the second field', async () => {
const onSave = vi.fn();
renderWithProviders(
<AddServerForm
onSave={onSave}
onCancel={vi.fn()}
editingServer={{
id: 'srv-1',
name: 'Home',
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
shareUsesLocalUrl: true,
username: 'tester',
password: 'pw',
}}
/>,
);
const user = userEvent.setup();
// Locate the alternate-url field (second URL-shaped input, prefilled).
const altInput = screen.getByDisplayValue('http://192.168.0.10');
await user.clear(altInput);
await user.click(screen.getByRole('button', { name: /save/i }));
expect(onSave).toHaveBeenCalledTimes(1);
const arg = onSave.mock.calls[0]![0];
expect(arg.url).toBe('https://music.example.com');
expect(arg).not.toHaveProperty('alternateUrl');
expect(arg).not.toHaveProperty('shareUsesLocalUrl');
});
});
describe('AddServerForm — custom HTTP headers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('includes configured custom headers on save', async () => {
const onSave = vi.fn();
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
const user = userEvent.setup();
const inputs = screen.getAllByRole('textbox');
await user.type(inputs[1]!, 'https://music.example.com');
await user.type(inputs[3]!, 'tester');
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
await user.click(screen.getByRole('button', { name: /custom http headers/i }));
const headerNameInputs = screen.getAllByPlaceholderText(/header name/i);
const headerValueInputs = screen.getAllByPlaceholderText(/header value/i);
await user.type(headerNameInputs[0]!, 'CF-Access-Client-Secret');
await user.type(headerValueInputs[0]!, 'gate-secret');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(onSave).toHaveBeenCalledTimes(1);
const arg = onSave.mock.calls[0]![0];
expect(arg.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]);
expect(arg.customHeadersApplyTo).toBe('public');
});
});
@@ -0,0 +1,414 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '@/store/authStoreTypes';
import { showToast } from '@/utils/ui/toast';
import {
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
serverCustomHeadersFromForm,
validateCustomHeaders,
} from '@/utils/server/serverHttpHeaders';
import { CustomHttpHeadersEditor } from '@/features/settings/components/CustomHttpHeadersEditor';
import {
decodeServerMagicString,
encodeServerMagicString,
DECODED_PASSWORD_VISUAL_MASK,
type ServerMagicPayload,
} from '@/utils/server/serverMagicString';
import { shortHostFromServerUrl } from '@/utils/server/serverDisplayName';
import { isLanUrl } from '@/utils/server/serverEndpoint';
import { resolveHostAddresses } from '@/api/network';
type FormState = {
name: string;
url: string;
alternateUrl: string;
shareUsesLocalUrl: boolean;
username: string;
password: string;
customHeaders: CustomHeaderEntry[];
customHeadersApplyTo: CustomHeadersApplyTo;
customHeadersOpen: boolean;
};
/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */
function hostnameForDnsHint(rawUrl: string): string | null {
const trimmed = rawUrl.trim();
if (!trimmed) return null;
try {
const u = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`);
const host = u.hostname.replace(/^\[|\]$/g, '');
// Literal IPv4 / IPv6 → no DNS lookup; isLanUrl alone classifies it.
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return null;
if (host.includes(':')) return null;
if (host === 'localhost' || host.endsWith('.local')) return null;
return host;
} catch {
return null;
}
}
export function AddServerForm({
onSave,
onCancel,
onDelete,
initialInvite = null,
editingServer = null,
}: {
onSave: (data: Omit<ServerProfile, 'id'>) => void | Promise<void>;
onCancel: () => void;
onDelete?: () => void | Promise<void>;
initialInvite?: ServerMagicPayload | null;
editingServer?: ServerProfile | null;
}) {
const { t } = useTranslation();
const isEdit = editingServer != null;
const [form, setForm] = useState<FormState>(
editingServer
? {
name: editingServer.name,
url: editingServer.url,
alternateUrl: editingServer.alternateUrl ?? '',
shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
username: editingServer.username,
password: editingServer.password,
customHeaders: editingServer.customHeaders?.length
? editingServer.customHeaders.map(h => ({ ...h }))
: [{ name: '', value: '' }],
customHeadersApplyTo:
editingServer.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
customHeadersOpen: Boolean(editingServer.customHeaders?.length),
}
: {
name: '',
url: '',
alternateUrl: '',
shareUsesLocalUrl: false,
username: '',
password: '',
customHeaders: [{ name: '', value: '' }],
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO,
customHeadersOpen: false,
},
);
const [magicString, setMagicString] = useState('');
const [blockPasswordReveal, setBlockPasswordReveal] = useState(false);
// DNS-classified hint: 'lan' / 'public' / null (no hint, no lookup yet, or
// literal IP — isLanUrl already classifies those without DNS).
const [primaryDnsClass, setPrimaryDnsClass] = useState<'lan' | 'public' | null>(null);
useEffect(() => {
if (!initialInvite) return;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setBlockPasswordReveal(true);
setForm(f => ({
...f,
name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url),
url: initialInvite.url,
// v2 invites carry the host's dual-address fields. Pre-populate so the
// receiver sees both addresses + the share preference rather than
// re-typing them.
alternateUrl: initialInvite.alternateUrl ?? '',
shareUsesLocalUrl: initialInvite.shareUsesLocalUrl ?? false,
username: initialInvite.username,
password: initialInvite.password,
}));
setMagicString(encodeServerMagicString(initialInvite));
}, [initialInvite]);
const update = <K extends keyof FormState>(k: K) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const value =
e.target.type === 'checkbox'
? (e.target as HTMLInputElement).checked
: e.target.value;
setForm(f => ({ ...f, [k]: value }) as FormState);
};
const handleMagicStringChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setMagicString(v);
const trimmed = v.trim();
const decoded = decodeServerMagicString(trimmed);
if (decoded) {
setBlockPasswordReveal(true);
setForm(f => ({
...f,
name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
alternateUrl: decoded.alternateUrl ?? '',
shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false,
username: decoded.username,
password: decoded.password,
}));
}
};
// Literal-IP classification — instant, no DNS needed.
const primaryUrlIsLanLiteral = useMemo(() => {
const trimmed = form.url.trim();
if (!trimmed) return null as boolean | null;
if (hostnameForDnsHint(trimmed) !== null) return null; // hostname — defer to DNS
return isLanUrl(trimmed);
}, [form.url]);
// Effective LAN classification: literal IP shortcut OR DNS result.
const primaryUrlIsLan = useMemo(() => {
if (primaryUrlIsLanLiteral !== null) return primaryUrlIsLanLiteral;
if (primaryDnsClass === null) return null;
return primaryDnsClass === 'lan';
}, [primaryUrlIsLanLiteral, primaryDnsClass]);
const runDnsHint = async () => {
const hostname = hostnameForDnsHint(form.url);
if (!hostname) {
setPrimaryDnsClass(null);
return;
}
const addresses = await resolveHostAddresses(hostname);
if (addresses.length === 0) {
// DNS failed (no network, NXDOMAIN, …) → no hint, don't block.
setPrimaryDnsClass(null);
return;
}
const anyPublic = addresses.some(ip => !isLanUrl(`http://${ip}`));
setPrimaryDnsClass(anyPublic ? 'public' : 'lan');
};
// Two-LAN client-side check before submit. Returns true on validation pass.
const validateAddresses = (): boolean => {
const url = form.url.trim();
const alt = form.alternateUrl.trim();
if (!url) return false;
if (!alt) return true; // single-address — always fine.
// For the LAN-LAN check we accept both the synchronous isLanUrl
// classification (literal IPs + .local + localhost) and the DNS-resolved
// class for the primary. The alternate goes through isLanUrl directly —
// we don't run a second DNS lookup for it here; the verify step on save
// will catch any deeper inconsistency.
const primaryLan = primaryUrlIsLan ?? isLanUrl(url);
const altLan = isLanUrl(alt);
if (primaryLan && altLan) {
showToast(t('settings.serverBothLanError'), 4500, 'error');
return false;
}
return true;
};
const customHeadersPayload = () =>
serverCustomHeadersFromForm(form.customHeaders, form.customHeadersApplyTo);
const submit = async () => {
const ms = magicString.trim();
if (ms) {
const decoded = decodeServerMagicString(ms);
if (!decoded) {
showToast(t('login.magicStringInvalid'), 4000, 'error');
return;
}
// v2 invites carry alternateUrl + shareUsesLocalUrl — must survive the
// magic-string submit path (handleMagicStringChange already prefills
// them into form state, but the magic-string branch forwards the
// decoded payload directly so we have to pick them off here too).
const altDecoded = decoded.alternateUrl?.trim() ?? '';
await onSave({
name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url),
url: decoded.url,
username: decoded.username,
password: decoded.password,
...customHeadersPayload(),
...(altDecoded
? {
alternateUrl: altDecoded,
shareUsesLocalUrl: decoded.shareUsesLocalUrl ?? false,
}
: {}),
});
return;
}
if (!form.url.trim()) return;
if (!validateAddresses()) return;
const headerValidation = validateCustomHeaders(
form.customHeaders.filter(h => h.name.trim() || h.value),
);
if (!headerValidation.ok) {
const first = headerValidation.fieldErrors[0];
showToast(t(first.messageKey, { defaultValue: first.messageKey }), 5000, 'error');
return;
}
const altTrimmed = form.alternateUrl.trim();
// If the user clears the second address, strip the share-flag as well so
// we don't leave a dangling preference (spec §5.3 last row).
const data: Omit<ServerProfile, 'id'> = {
name: form.name.trim() || form.url.trim(),
url: form.url.trim(),
username: form.username.trim(),
password: form.password,
...customHeadersPayload(),
...(altTrimmed
? {
alternateUrl: altTrimmed,
shareUsesLocalUrl: form.shareUsesLocalUrl,
}
: {}),
};
await onSave(data);
};
// Hint to show under the second-address field: only when the primary is
// classified one way or the other and the second is still empty.
const alternateUrlHint =
!form.alternateUrl.trim() && primaryUrlIsLan !== null
? primaryUrlIsLan
? t('settings.serverAlternateUrlHintAddPublic')
: t('settings.serverAlternateUrlHintAddLocal')
: null;
// Share checkbox visibility (spec §5.3):
// - hidden when there is no second address
// - shown the moment a user fills the second field (or both already exist
// on an edit). Default off; persists across save/load.
const showShareCheckbox = form.alternateUrl.trim().length > 0;
return (
<form
className="settings-card"
style={{ marginTop: '1rem' }}
onSubmit={e => { e.preventDefault(); void submit(); }}
>
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
{isEdit ? t('settings.editServerTitle') : 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')}
onBlur={() => { void runDnsHint(); }}
placeholder={t('settings.serverUrlPlaceholder')}
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.serverAlternateUrl')}</label>
<input
className="input"
type="text"
value={form.alternateUrl}
onChange={update('alternateUrl')}
placeholder={
primaryUrlIsLan === true
? t('settings.serverAlternateUrlPlaceholderPublic')
: t('settings.serverAlternateUrlPlaceholderLocal')
}
autoComplete="off"
/>
{alternateUrlHint && (
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
{alternateUrlHint}
</div>
)}
</div>
{showShareCheckbox && (
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13, display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={form.shareUsesLocalUrl}
onChange={update('shareUsesLocalUrl')}
style={{ marginTop: 2 }}
/>
<span style={{ display: 'flex', flexDirection: 'column' }}>
<span>{t('settings.shareUsesLocalUrl')}</span>
<span style={{ fontSize: 11, opacity: 0.7 }}>
{t('settings.shareUsesLocalUrlDesc')}
</span>
</span>
</label>
</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"
readOnly={blockPasswordReveal}
style={blockPasswordReveal ? { cursor: 'default' } : undefined}
/>
</div>
<div className="form-group">
<label style={{ fontSize: 13 }}>{t('settings.serverPassword')}</label>
{blockPasswordReveal ? (
<input
className="input"
type="text"
readOnly
value={DECODED_PASSWORD_VISUAL_MASK}
autoComplete="off"
aria-label={t('settings.serverPassword')}
style={{ letterSpacing: '0.12em', cursor: 'default' }}
/>
) : (
<input
className="input"
type="password"
value={form.password}
onChange={update('password')}
placeholder="••••••••"
/>
)}
</div>
</div>
<CustomHttpHeadersEditor
headers={form.customHeaders}
applyTo={form.customHeadersApplyTo}
open={form.customHeadersOpen}
onOpenChange={open => setForm(f => ({ ...f, customHeadersOpen: open }))}
onHeadersChange={customHeaders => setForm(f => ({ ...f, customHeaders }))}
onApplyToChange={customHeadersApplyTo => setForm(f => ({ ...f, customHeadersApplyTo }))}
radioGroupName="addServerCustomHeadersApplyTo"
/>
{!isEdit && (
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
<input
className="input"
type="text"
value={magicString}
onChange={handleMagicStringChange}
placeholder={t('login.magicStringPlaceholder')}
autoComplete="off"
/>
</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'space-between', alignItems: 'center' }}>
{isEdit && onDelete ? (
<button type="button" className="btn btn-danger" onClick={() => void onDelete()}>
{t('settings.deleteServer')}
</button>
) : (
<span />
)}
<div style={{ display: 'flex', gap: '8px' }}>
<button type="button" className="btn btn-ghost" onClick={onCancel}>{t('common.cancel')}</button>
<button type="submit" className="btn btn-primary">
{isEdit ? t('common.save') : t('common.add')}
</button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,618 @@
import { AlertTriangle, BarChart3, FileDown, RefreshCcw, TriangleAlert, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsSubCard } from '@/features/settings/components/SettingsSubCard';
import { useAnalysisStrategyStore } from '@/store/analysisStrategyStore';
import { useAuthStore } from '@/store/authStore';
import {
analysisClearFailedTracks,
analysisDeleteAllForServer,
analysisEnqueueSeedFromUrl,
analysisGetFailedTrackCount,
analysisListFailedTracks,
type AnalysisFailedTrackDto,
libraryAnalysisProgress,
type LibraryAnalysisProgressDto,
} from '@/api/analysis';
import { libraryGetTracksBatch, type LibraryTrackDto, type TrackRefDto } from '@/api/library';
import { buildStreamUrlForServer } from '@/api/subsonicStreamUrl';
import { serverListDisplayLabel } from '@/utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
import { showToast } from '@/utils/ui/toast';
import {
ANALYTICS_STRATEGIES,
ADVANCED_PARALLELISM_MAX,
ADVANCED_PARALLELISM_MIN,
type AnalyticsStrategy,
} from '@/utils/library/analysisStrategy';
type ClearTarget = {
serverId: string;
label: string;
};
type FailedModalTarget = {
serverId: string;
label: string;
indexKey: string;
};
type FailedTrackView = AnalysisFailedTrackDto & {
title?: string | null;
serverPath?: string | null;
};
export default function AnalyticsStrategySection() {
const { t } = useTranslation();
const servers = useAuthStore(s => s.servers);
const {
strategyByServer,
advancedParallelismByServer,
setServerStrategy,
setServerAdvancedParallelism,
clearServerOverrides,
getStrategyForServer,
getAdvancedParallelismForServer,
} = useAnalysisStrategyStore();
const [progressByServer, setProgressByServer] = useState<Record<string, LibraryAnalysisProgressDto | null>>({});
const [failedCountByServer, setFailedCountByServer] = useState<Record<string, number>>({});
const [failedTracksByServer, setFailedTracksByServer] = useState<Record<string, FailedTrackView[]>>({});
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
const [clearingServerId, setClearingServerId] = useState<string | null>(null);
const [failedModalTarget, setFailedModalTarget] = useState<FailedModalTarget | null>(null);
const [failedModalLoading, setFailedModalLoading] = useState(false);
const [failedActionBusy, setFailedActionBusy] = useState(false);
const activeServerIds = useMemo(
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
[servers],
);
const removedServerIds = useMemo(() => {
const known = new Set([
...Object.keys(strategyByServer),
...Object.keys(advancedParallelismByServer),
]);
return Array.from(known).filter(id => !activeServerIds.has(id));
}, [strategyByServer, advancedParallelismByServer, activeServerIds]);
useEffect(() => {
if (servers.length === 0) return;
let cancelled = false;
const refresh = () => {
void Promise.all(
servers.map(server => {
const key = serverIndexKeyForProfile(server);
return Promise.all([
libraryAnalysisProgress(server.id).catch(() => null),
analysisGetFailedTrackCount(server.id).catch(() => 0),
])
.then(([progress, failedCount]) => ({ key, progress, failedCount }))
.catch(() => ({ key, progress: null, failedCount: 0 }));
}),
).then(results => {
if (cancelled) return;
setProgressByServer(prev => {
const next = { ...prev };
results.forEach(({ key, progress }) => {
next[key] = progress;
});
return next;
});
setFailedCountByServer(prev => {
const next = { ...prev };
results.forEach(({ key, failedCount }) => {
next[key] = Number.isFinite(failedCount) ? failedCount : 0;
});
return next;
});
});
};
refresh();
const id = window.setInterval(refresh, 5000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [servers]);
const progressLabel = (progress: LibraryAnalysisProgressDto | null, failedCount: number) => {
if (!progress || progress.totalTracks <= 0) return null;
const blocked = Math.max(0, failedCount);
const total = Math.max(0, progress.totalTracks - blocked);
if (total <= 0) {
return t('settings.analyticsStrategyProgressEmptyAfterFailed');
}
const done = Math.max(0, total - progress.pendingTracks);
const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100)));
return t('settings.analyticsStrategyProgressValue', {
percent,
done: done.toLocaleString(),
total: total.toLocaleString(),
});
};
const strategyLabel = (s: AnalyticsStrategy) => {
switch (s) {
case 'lazy':
return t('settings.analyticsStrategyLazy');
case 'advanced':
return t('settings.analyticsStrategyAdvanced');
}
};
const handleClearAnalysis = async () => {
if (!clearTarget) return;
setClearingServerId(clearTarget.serverId);
try {
await analysisDeleteAllForServer(clearTarget.serverId);
clearServerOverrides(clearTarget.serverId);
showToast(t('settings.analyticsStrategyClearSuccess'), 4000, 'success');
} catch {
showToast(t('settings.analyticsStrategyClearError'), 5000, 'error');
} finally {
setClearingServerId(null);
setClearTarget(null);
}
};
const openFailedTracksModal = async (target: FailedModalTarget) => {
setFailedModalTarget(target);
setFailedModalLoading(true);
try {
const tracks = await analysisListFailedTracks(target.serverId, 2000);
const refs: TrackRefDto[] = tracks.map(track => ({
serverId: target.serverId,
trackId: track.trackId,
}));
const dtoById = new Map<string, LibraryTrackDto>();
if (refs.length > 0) {
const batch = await libraryGetTracksBatch(refs).catch(() => []);
batch.forEach(track => {
if (!dtoById.has(track.id)) dtoById.set(track.id, track);
});
}
const merged: FailedTrackView[] = tracks.map(track => {
const dto = dtoById.get(track.trackId);
return {
...track,
title: dto?.title ?? null,
serverPath: dto?.serverPath ?? null,
};
});
setFailedTracksByServer(prev => ({ ...prev, [target.indexKey]: merged }));
setFailedCountByServer(prev => ({ ...prev, [target.indexKey]: merged.length }));
} catch {
showToast(t('settings.analyticsFailedTracksLoadError'), 4500, 'error');
} finally {
setFailedModalLoading(false);
}
};
const handleExportFailedTracks = async () => {
if (!failedModalTarget) return;
const tracks = failedTracksByServer[failedModalTarget.indexKey] ?? [];
if (tracks.length === 0) return;
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
const suggestedName = `psysonic-failed-tracks-${stamp}.json`;
const selected = await saveDialog({
defaultPath: suggestedName,
filters: [{ name: 'JSON', extensions: ['json'] }],
title: t('settings.analyticsFailedTracksExport'),
});
if (!selected || Array.isArray(selected)) return;
try {
const payload = {
serverId: failedModalTarget.serverId,
exportedAt: new Date().toISOString(),
count: tracks.length,
tracks,
};
const bytes = new TextEncoder().encode(JSON.stringify(payload, null, 2));
await writeFile(selected, bytes);
showToast(t('settings.analyticsFailedTracksExportSuccess', { count: tracks.length }), 3500, 'success');
} catch {
showToast(t('settings.analyticsFailedTracksExportError'), 4500, 'error');
}
};
const handleRescanFailedTracks = async () => {
if (!failedModalTarget) return;
const tracks = failedTracksByServer[failedModalTarget.indexKey] ?? [];
if (tracks.length === 0) return;
setFailedActionBusy(true);
try {
const ids = tracks.map(t => t.trackId);
await analysisClearFailedTracks(failedModalTarget.serverId, ids);
await Promise.allSettled(
ids.slice(0, 200).map(trackId =>
analysisEnqueueSeedFromUrl(
trackId,
buildStreamUrlForServer(failedModalTarget.serverId, trackId),
failedModalTarget.serverId,
'low',
),
),
);
setFailedTracksByServer(prev => ({ ...prev, [failedModalTarget.indexKey]: [] }));
setFailedCountByServer(prev => ({ ...prev, [failedModalTarget.indexKey]: 0 }));
showToast(t('settings.analyticsFailedTracksRescanSuccess', { count: ids.length }), 4500, 'success');
setFailedModalTarget(null);
} catch {
showToast(t('settings.analyticsFailedTracksRescanError'), 5000, 'error');
} finally {
setFailedActionBusy(false);
}
};
return (
<SettingsSubSection
title={t('settings.analyticsStrategyTitle')}
icon={<BarChart3 size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px 10px', paddingLeft: 0, fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyServerLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyParallelismLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyProgressLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyActionsLabel')}
</th>
</tr>
</thead>
<tbody>
{servers.map(server => {
const strategy = getStrategyForServer(server.id);
const advancedParallelism = getAdvancedParallelismForServer(server.id);
const key = serverIndexKeyForProfile(server);
const progress = progressByServer[key] ?? null;
const failedCount = failedCountByServer[key] ?? 0;
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
{label}
</td>
<td style={{ padding: '10px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{ANALYTICS_STRATEGIES.map(s => (
<button
key={s}
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setServerStrategy(server.id, s)}
>
{strategyLabel(s)}
</button>
))}
</div>
</td>
<td style={{ padding: '10px', minWidth: 160 }}>
{strategy === 'advanced' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<input
type="range"
min={ADVANCED_PARALLELISM_MIN}
max={ADVANCED_PARALLELISM_MAX}
step={1}
value={advancedParallelism}
onChange={e => {
const value = parseInt(e.target.value, 10);
setServerAdvancedParallelism(server.id, value);
}}
style={{ flex: 1, minWidth: 80, maxWidth: 160 }}
aria-valuemin={ADVANCED_PARALLELISM_MIN}
aria-valuemax={ADVANCED_PARALLELISM_MAX}
aria-valuenow={advancedParallelism}
/>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', minWidth: 64 }}>
{t('settings.analyticsStrategyParallelismValue', { n: advancedParallelism })}
</span>
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
)}
</td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{progressLabel(progress, failedCount) ?? '—'}</span>
{failedCount > 0 && (
<button
type="button"
className="btn btn-sm btn-surface"
onClick={() => void openFailedTracksModal({ serverId: server.id, label, indexKey: key })}
title={t('settings.analyticsFailedTracksOpenTitle', { count: failedCount })}
style={{ padding: '2px 8px', minHeight: 24 }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<TriangleAlert size={13} />
{failedCount}
</span>
</button>
)}
</div>
</td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-sm btn-surface"
onClick={() => setClearTarget({ serverId: server.id, label })}
disabled={clearingServerId === server.id}
>
{t('settings.analyticsStrategyClearAction')}
</button>
</td>
</tr>
);
})}
{removedServerIds.map(serverId => (
<tr
key={serverId}
style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}
>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-secondary)' }}>
<div>{serverId}</div>
<div style={{ fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 2 }}>
{t('settings.analyticsStrategyServerRemoved')}
</div>
</td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-muted)' }}></td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-muted)' }}></td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-muted)' }}></td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-sm btn-surface"
onClick={() => setClearTarget({ serverId, label: serverId })}
disabled={clearingServerId === serverId}
>
{t('settings.analyticsStrategyClearAction')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
{t('settings.analyticsStrategyDesc')}
</p>
<div
style={{
marginTop: '0.85rem',
padding: '0.65rem 0.75rem',
borderRadius: 8,
background: 'var(--surface-elevated, rgba(255,255,255,0.03))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
fontSize: 12,
color: 'var(--text-muted)',
lineHeight: 1.55,
}}
>
<div style={{ marginBottom: '0.4rem' }}>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>
{t('settings.analyticsStrategyLazy')}
</span>
{' '}
{t('settings.analyticsStrategyLazyDesc')}
</div>
<div>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>
{t('settings.analyticsStrategyAdvanced')}
</span>
{' '}
{t('settings.analyticsStrategyAdvancedDesc')}
</div>
</div>
<div
className="settings-hint settings-hint-info"
role="note"
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
>
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--warning, #f59e0b)' }} />
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
{t('settings.analyticsStrategyAdvancedWarning')}
</span>
</div>
</SettingsSubCard>
</SettingsGroup>
</div>
{clearTarget &&
createPortal(
<div
className="modal-overlay"
onClick={() => setClearTarget(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '420px' }}
>
<button
className="modal-close"
onClick={() => setClearTarget(null)}
aria-label={t('settings.analyticsStrategyClearCancel')}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.analyticsStrategyClearTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
{t('settings.analyticsStrategyClearDesc', { server: clearTarget.label })}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-primary"
onClick={() => setClearTarget(null)}
autoFocus
disabled={clearingServerId === clearTarget.serverId}
>
{t('settings.analyticsStrategyClearCancel')}
</button>
<button
className="btn btn-surface"
onClick={handleClearAnalysis}
disabled={clearingServerId === clearTarget.serverId}
style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<AlertTriangle size={14} />
{t('settings.analyticsStrategyClearConfirm')}
</span>
</button>
</div>
</div>
</div>,
document.body,
)}
{failedModalTarget &&
createPortal(
<div
className="modal-overlay"
onClick={() => !failedActionBusy && setFailedModalTarget(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '680px', width: 'min(680px, 92vw)' }}
>
<button
className="modal-close"
onClick={() => setFailedModalTarget(null)}
aria-label={t('settings.analyticsFailedTracksClose')}
disabled={failedActionBusy}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.analyticsFailedTracksTitle', { server: failedModalTarget.label })}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.analyticsFailedTracksDesc')}
</p>
{failedModalLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksLoading')}
</div>
) : (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksEmpty')}
</div>
) : (
<div
style={{
border: '1px solid var(--border-subtle, rgba(255,255,255,0.08))',
borderRadius: 10,
maxHeight: 280,
overflowY: 'auto',
marginBottom: 12,
}}
>
{(failedTracksByServer[failedModalTarget.indexKey] ?? []).map(track => (
<div
key={`${track.trackId}:${track.md5_16kb}:${track.updatedAt}`}
style={{
padding: '8px 10px',
borderBottom: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
fontSize: 12,
color: 'var(--text-secondary)',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 10,
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
wordBreak: 'break-word',
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
fontSize: 12,
}}
>
{track.title?.trim() || track.trackId}
</div>
<div style={{ wordBreak: 'break-all', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
{track.serverPath?.trim() || track.trackId}
</div>
</div>
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
{new Date(track.updatedAt * 1000).toLocaleString()}
</span>
</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-surface"
onClick={handleExportFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<FileDown size={14} />
{t('settings.analyticsFailedTracksExport')}
</span>
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
onClick={() => setFailedModalTarget(null)}
disabled={failedActionBusy}
>
{t('settings.analyticsFailedTracksClose')}
</button>
<button
className="btn btn-surface"
onClick={handleRescanFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
style={{ borderColor: 'var(--warning, #f59e0b)', color: 'var(--warning, #f59e0b)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<RefreshCcw size={14} />
{t('settings.analyticsFailedTracksRescan')}
</span>
</button>
</div>
</div>
</div>
</div>,
document.body,
)}
</SettingsSubSection>
);
}
@@ -0,0 +1,44 @@
import { Activity } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type Variant = 'inline' | 'overlay';
/**
* Small amber "animated theme" chip, shown only on animation-risk setups
* (Nvidia/Linux or compositing off). A motion glyph rather than a hazard
* triangle — the CPU-usage caveat lives in the tooltip.
*
* - `inline` — sits after a theme name (Theme Store rows).
* - `overlay` — pinned top-centre on an installed theme's preview swatch
* (top-right is the active indicator, top-left the uninstall X).
*/
export function AnimatedThemeBadge({ variant }: { variant: Variant }) {
const { t } = useTranslation();
const overlay = variant === 'overlay';
return (
<span
role="img"
aria-label={t('settings.themeAnimationWarning')}
data-tooltip={t('settings.themeAnimationWarning')}
data-tooltip-pos="top"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
width: overlay ? 18 : undefined,
height: overlay ? 18 : undefined,
padding: overlay ? 0 : '1px 5px',
borderRadius: 999,
background: 'color-mix(in srgb, var(--warning) 20%, var(--bg-elevated, var(--bg-card)))',
border: '1px solid color-mix(in srgb, var(--warning) 45%, transparent)',
color: 'var(--warning)',
...(overlay
? { position: 'absolute', top: 3, left: '50%', transform: 'translateX(-50%)' }
: { verticalAlign: 'middle' }),
}}
>
<Activity size={overlay ? 11 : 12} strokeWidth={2.5} />
</span>
);
}
@@ -0,0 +1,326 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import {
LIBRARY_GRID_MAX_COLUMNS_MAX,
LIBRARY_GRID_MAX_COLUMNS_MIN,
} from '@/store/authStoreDefaults';
import type { SeekbarStyle, WindowButtonStyle } from '@/store/authStoreTypes';
import { useFontStore, FontId } from '@/store/fontStore';
import { useThemeStore } from '@/store/themeStore';
import { IS_LINUX, IS_WINDOWS } from '@/utils/platform';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsValue } from '@/features/settings/components/SettingsSubCard';
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
import { SeekbarPreview } from '@/features/waveform';
import WindowButtonPreview from '@/components/WindowButtonPreview';
export function AppearanceTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const theme = useThemeStore();
const fontStore = useFontStore();
const [isTilingWm, setIsTilingWm] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
}, []);
return (
<>
<SettingsSubSection
title={t('settings.libraryGridMaxColumnsTitle')}
icon={<LayoutGrid size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.libraryGridMaxColumnsPerfHint')}
</div>
<SettingsSubCard>
<SettingsField
label={t('settings.libraryGridMaxColumnsRangeLabel', {
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
})}
desc={t('settings.libraryGridMaxColumnsDesc')}
row
>
<input
id="library-grid-max-cols"
type="range"
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
step={1}
value={auth.libraryGridMaxColumns}
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
aria-valuenow={auth.libraryGridMaxColumns}
/>
<SettingsValue>{auth.libraryGridMaxColumns}</SettingsValue>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.visualOptionsTitle')}
icon={<Palette size={16} />}
>
<div className="settings-card">
<SettingsGroup title={t('settings.groupDisplay')}>
<SettingsToggle
label={t('settings.coverArtBackground')}
desc={t('settings.coverArtBackgroundSub')}
checked={theme.enableCoverArtBackground}
onChange={theme.setEnableCoverArtBackground}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.playlistCoverPhoto')}
desc={t('settings.playlistCoverPhotoSub')}
checked={theme.enablePlaylistCoverPhoto}
onChange={theme.setEnablePlaylistCoverPhoto}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showBitrate')}
desc={t('settings.showBitrateSub')}
checked={theme.showBitrate}
onChange={theme.setShowBitrate}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.floatingPlayerBar')}
desc={t('settings.floatingPlayerBarSub')}
checked={theme.floatingPlayerBar}
onChange={theme.setFloatingPlayerBar}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.squareCorners')}
desc={t('settings.squareCornersSub')}
checked={theme.squareCorners}
onChange={theme.setSquareCorners}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showArtistImages')}
desc={t('settings.showArtistImagesDesc')}
checked={auth.showArtistImages}
onChange={auth.setShowArtistImages}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showOrbitTrigger')}
desc={t('settings.showOrbitTriggerDesc')}
checked={auth.showOrbitTrigger}
onChange={auth.setShowOrbitTrigger}
/>
{!IS_WINDOWS && (
<>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.preloadMiniPlayer')}
desc={t('settings.preloadMiniPlayerDesc')}
checked={auth.preloadMiniPlayer}
onChange={auth.setPreloadMiniPlayer}
/>
</>
)}
</SettingsGroup>
{IS_LINUX && !isTilingWm && (
<SettingsGroup title={t('settings.groupWindow')}>
<SettingsToggle
label={t('settings.useCustomTitlebar')}
desc={t('settings.useCustomTitlebarDesc')}
checked={auth.useCustomTitlebar}
onChange={auth.setUseCustomTitlebar}
/>
{auth.useCustomTitlebar && (
<>
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField
label={t('settings.windowButtonStyle')}
desc={t('settings.windowButtonStyleDesc')}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{(['dots', 'dotsGlyph', 'flat', 'pill', 'outline', 'glyph'] as WindowButtonStyle[]).map(style => (
<WindowButtonPreview
key={style}
style={style}
label={t(`settings.windowButtons${style.charAt(0).toUpperCase() + style.slice(1)}`)}
selected={auth.windowButtonStyle === style}
onClick={() => auth.setWindowButtonStyle(style)}
/>
))}
</div>
</SettingsField>
</SettingsSubCard>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showMinimizeButton')}
desc={t('settings.showMinimizeButtonDesc')}
checked={auth.showMinimizeButton}
onChange={auth.setShowMinimizeButton}
/>
</>
)}
</SettingsGroup>
)}
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.uiScaleTitle')}
icon={<ZoomIn size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField label={t('settings.uiScaleLabel')}>
{(() => {
const presets = [80, 90, 100, 110, 125, 150];
const currentPct = Math.round(fontStore.uiScale * 100);
// Snap a legacy off-preset value to the closest preset so one
// button is always marked active.
const activePct = presets.includes(currentPct)
? currentPct
: presets.reduce(
(best, p) => (Math.abs(p - currentPct) < Math.abs(best - currentPct) ? p : best),
presets[0],
);
const options: SegmentedOption<string>[] = presets.map(p => ({
id: String(p),
label: `${p}%`,
}));
return (
<SettingsSegmented
options={options}
value={String(activePct)}
onChange={id => fontStore.setUiScale(parseInt(id, 10) / 100)}
/>
);
})()}
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.font')}
icon={<Type size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{(
[
// Accessibility-first: OpenDyslexic at the top so dyslexic
// readers don't have to scroll past 14 sans-serifs to find it.
{ id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') },
{ 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" },
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
] as { id: FontId; label: string; stack: string; hint?: string }[]
).map(f => (
<button
key={f.id}
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
style={{
justifyContent: 'flex-start',
fontFamily: f.stack,
...(f.hint ? { flexDirection: 'column', alignItems: 'flex-start', gap: '2px', paddingTop: '8px', paddingBottom: '8px' } : null),
}}
onClick={() => fontStore.setFont(f.id)}
>
<span>{f.label}</span>
{f.hint && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-sans)' }}>
{f.hint}
</span>
)}
</button>
))}
</div>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.seekbarStyle')}
icon={<Sliders size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField desc={t('settings.seekbarStyleDesc')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{(['truewave', 'pseudowave', '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)}`)}
selected={auth.seekbarStyle === style}
onClick={() => auth.setSeekbarStyle(style)}
/>
))}
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.buttonSizeTitle')}
icon={<Maximize2 size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField
label={t('settings.buttonSizeLabel')}
desc={t('settings.buttonSizeDesc')}
>
<div style={{ display: 'flex', gap: 8 }}>
{(['large', 'small'] as const).map(size => (
<button
key={size}
className={`btn ${theme.buttonSize === size ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => theme.setButtonSize(size)}
>
{t(`settings.buttonSize_${size}`)}
</button>
))}
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,66 @@
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useArtistLayoutStore, type ArtistSectionConfig, type ArtistSectionId } from '@/store/artistLayoutStore';
import { useListReorderDnd } from '@/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
bio: 'settings.artistLayoutBio',
topTracks: 'settings.artistLayoutTopTracks',
similar: 'settings.artistLayoutSimilar',
albums: 'settings.artistLayoutAlbums',
featured: 'settings.artistLayoutFeatured',
};
const REORDER_TYPE = 'artist_section_reorder';
export function ArtistLayoutCustomizer() {
const { t } = useTranslation();
const sections = useArtistLayoutStore(s => s.sections);
const setSections = useArtistLayoutStore(s => s.setSections);
const toggleSection = useArtistLayoutStore(s => s.toggleSection);
const sectionsRef = useRef(sections);
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
sectionsRef.current = sections;
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(sectionsRef.current, draggedId, target);
if (next) setSections(next);
}, [setSections]);
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
return (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('settings.artistLayoutDesc')}
</p>
<div style={{ padding: '4px 0' }} ref={setContainer} onMouseMove={onMouseMove}>
{sections.map((section: ArtistSectionConfig) => {
const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]);
const edge = isDragging ? dropEdge(section.id) : null;
return (
<div
key={section.id}
data-reorder-id={section.id}
className="sidebar-customizer-row"
style={{
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<ReorderGripHandle id={section.id} type={REORDER_TYPE} label={label} />
<span style={{ flex: 1, fontSize: 14, opacity: section.visible ? 1 : 0.45 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={section.visible} onChange={() => toggleSection(section.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
</>
);
}
@@ -0,0 +1,128 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Blend, Gauge, Sliders, Volume2, Waves } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import Equalizer from '@/components/Equalizer';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/utils/audio/loudnessPreAnalysisSlider';
import { useAudioDevicesProbe } from '@/hooks/useAudioDevicesProbe';
import { IS_MACOS } from '@/utils/platform';
import { AudioOutputDeviceSection } from '@/features/settings/components/audio/AudioOutputDeviceSection';
import { NormalizationBlock } from '@/features/settings/components/audio/NormalizationBlock';
import { PlaybackRateBlock } from '@/features/settings/components/audio/PlaybackRateBlock';
import { TrackTransitionsBlock } from '@/features/settings/components/audio/TrackTransitionsBlock';
import { TrackPreviewsSection } from '@/features/settings/components/audio/TrackPreviewsSection';
import { HiResCrossfadeResampleBlock } from '@/features/settings/components/audio/HiResCrossfadeResampleBlock';
export function AudioTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const {
audioDevices,
osDefaultAudioDeviceId,
deviceSwitching,
devicesLoading,
setDeviceSwitching,
refreshAudioDevices,
} = useAudioDevicesProbe(t);
const preAnalysisEffectiveDb = useMemo(
() => effectiveLoudnessPreAnalysisAttenuationDb(
auth.loudnessPreAnalysisAttenuationDb,
auth.loudnessTargetLufs,
),
[auth.loudnessPreAnalysisAttenuationDb, auth.loudnessTargetLufs],
);
return (
<>
{/* Output-device picker is hidden on macOS — the stream is pinned to the
system default there, so the whole category is gated out. */}
{!IS_MACOS && (
<AudioOutputDeviceSection
audioDevices={audioDevices}
osDefaultAudioDeviceId={osDefaultAudioDeviceId}
deviceSwitching={deviceSwitching}
devicesLoading={devicesLoading}
setDeviceSwitching={setDeviceSwitching}
refreshAudioDevices={refreshAudioDevices}
t={t}
/>
)}
{/* Normalization — loudness levelling (own category) */}
<SettingsSubSection
title={t('settings.normalization')}
description={t('settings.normalizationDesc')}
icon={<Volume2 size={16} />}
>
<div className="settings-card">
<NormalizationBlock preAnalysisEffectiveDb={preAnalysisEffectiveDb} t={t} />
</div>
</SettingsSubSection>
{/* Track transitions — crossfade / gapless / AutoDJ (own category) */}
<SettingsSubSection
title={t('settings.transitionsTitle')}
description={t('settings.transitionsDesc')}
icon={<Blend size={16} />}
>
<div className="settings-card">
<TrackTransitionsBlock t={t} />
</div>
</SettingsSubSection>
{/* Native Hi-Res Playback */}
<SettingsSubSection
title={t('settings.hiResTitle')}
icon={<Waves size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsToggle
desc={t('settings.hiResDesc')}
ariaLabel={t('settings.hiResEnabled')}
id="hires-enabled-toggle"
checked={auth.enableHiRes}
onChange={auth.setEnableHiRes}
/>
<HiResCrossfadeResampleBlock
enabled={auth.enableHiRes}
resampleHz={auth.hiResCrossfadeResampleHz}
onResampleHzChange={auth.setHiResCrossfadeResampleHz}
t={t}
/>
</SettingsGroup>
</div>
</SettingsSubSection>
{/* Equalizer */}
<SettingsSubSection
title={t('settings.eqTitle')}
icon={<Sliders size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<Equalizer />
</SettingsGroup>
</div>
</SettingsSubSection>
{/* Playback speed */}
<SettingsSubSection
title={t('settings.playbackRateTitle')}
icon={<Gauge size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<PlaybackRateBlock t={t} />
</SettingsGroup>
</div>
</SettingsSubSection>
<TrackPreviewsSection t={t} />
</>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { useDragDrop, useDragSource } from '@/contexts/DragDropContext';
import type { BackdropSource, BackdropSourcePref } from '@/cover/artistBackdrop';
import type { BackdropSurface } from '@/store/themeStore';
import { moveSourceTo, dropSourceBefore } from '@/features/settings/components/backdropReorder';
const DRAG_TYPE = 'backdrop-source';
interface RowPayload {
type: typeof DRAG_TYPE;
surface: BackdropSurface;
index: number;
}
interface Props {
surface: BackdropSurface;
sources: BackdropSourcePref[];
labelFor: (s: BackdropSource) => string;
onChange: (next: BackdropSourcePref[]) => void;
moveUpLabel: string;
moveDownLabel: string;
}
/**
* Ordered, individually-toggleable backdrop source list for one surface. Drag a
* row by its grip to reorder (priority = top-to-bottom), or use the ↑/↓ buttons
* for a keyboard-accessible reorder; the switch on each row drops a source out
* of the resolution chain without losing its place. Uses the shared
* `useDragSource` / `psy-drop` drag infrastructure (text/plain payloads).
*/
export function BackdropSourceList({ surface, sources, labelFor, onChange, moveUpLabel, moveDownLabel }: Props) {
const { isDragging, payload } = useDragDrop();
const [dropIdx, setDropIdx] = useState<number | null>(null);
// Is the in-flight drag a row from *this* list? (Don't react to other drags.)
let draggingHere = false;
if (isDragging && payload) {
try {
const p = JSON.parse(payload.data);
draggingHere = p.type === DRAG_TYPE && p.surface === surface;
} catch { /* not our payload */ }
}
const apply = (next: BackdropSourcePref[] | null) => { if (next) onChange(next); };
const setEnabled = (i: number, enabled: boolean) =>
onChange(sources.map((s, idx) => (idx === i ? { ...s, enabled } : s)));
return (
<ul className="backdrop-source-list" role="list">
{sources.map((pref, i) => (
<BackdropSourceRow
key={pref.source}
surface={surface}
index={i}
count={sources.length}
label={labelFor(pref.source)}
enabled={pref.enabled}
isDropTarget={draggingHere && dropIdx === i}
onHover={() => { if (draggingHere) setDropIdx(i); }}
onDropFrom={(from) => { apply(dropSourceBefore(sources, from, i)); setDropIdx(null); }}
onToggle={(en) => setEnabled(i, en)}
onMoveUp={() => apply(moveSourceTo(sources, i, i - 1))}
onMoveDown={() => apply(moveSourceTo(sources, i, i + 1))}
moveUpLabel={moveUpLabel}
moveDownLabel={moveDownLabel}
/>
))}
</ul>
);
}
interface RowProps {
surface: BackdropSurface;
index: number;
count: number;
label: string;
enabled: boolean;
isDropTarget: boolean;
onHover: () => void;
onDropFrom: (fromIndex: number) => void;
onToggle: (enabled: boolean) => void;
onMoveUp: () => void;
onMoveDown: () => void;
moveUpLabel: string;
moveDownLabel: string;
}
function BackdropSourceRow({
surface, index, count, label, enabled, isDropTarget,
onHover, onDropFrom, onToggle, onMoveUp, onMoveDown, moveUpLabel, moveDownLabel,
}: RowProps) {
const rowRef = useRef<HTMLLIElement>(null);
const grip = useDragSource(() => ({
data: JSON.stringify({ type: DRAG_TYPE, surface, index } satisfies RowPayload),
label,
}));
// A `psy-drop` released over this row carries the dragged row's index.
useEffect(() => {
const el = rowRef.current;
if (!el) return;
const handler = (e: Event) => {
try {
const d = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
if (d.type === DRAG_TYPE && d.surface === surface && typeof d.index === 'number' && d.index !== index) {
onDropFrom(d.index);
}
} catch { /* malformed payload */ }
};
el.addEventListener('psy-drop', handler);
return () => el.removeEventListener('psy-drop', handler);
}, [surface, index, onDropFrom]);
const cls = [
'backdrop-source-row',
isDropTarget ? 'backdrop-source-row--drop' : '',
enabled ? '' : 'backdrop-source-row--off',
].filter(Boolean).join(' ');
return (
<li ref={rowRef} className={cls} onMouseMove={onHover}>
<span className="backdrop-source-grip" {...grip} aria-hidden="true">
<GripVertical size={16} />
</span>
<span className="backdrop-source-name">{label}</span>
<span className="backdrop-source-actions">
<button type="button" className="backdrop-source-move" onClick={onMoveUp} disabled={index === 0} aria-label={`${moveUpLabel}: ${label}`}>
<ChevronUp size={15} />
</button>
<button type="button" className="backdrop-source-move" onClick={onMoveDown} disabled={index === count - 1} aria-label={`${moveDownLabel}: ${label}`}>
<ChevronDown size={15} />
</button>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={enabled} onChange={(e) => onToggle(e.target.checked)} />
<span className="toggle-track" />
</label>
</span>
</li>
);
}
@@ -0,0 +1,198 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Clock3, Download, Upload } from 'lucide-react';
import { createPortal } from 'react-dom';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
import {
exportBackupToPath,
importAnyBackupFromPath,
pickBackupExportPath,
pickBackupImportPath,
} from '@/features/settings/utils/backup';
import { showToast } from '@/utils/ui/toast';
type BackupMode = 'full' | 'library' | 'config';
type BackupAction = 'export' | 'import';
export function BackupSection() {
const { t } = useTranslation();
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [mode, setMode] = useState<BackupMode>('full');
const [busyAction, setBusyAction] = useState<BackupAction | null>(null);
const waitForPaint = async () => {
await new Promise(resolve => window.setTimeout(resolve, 0));
};
const busy = exporting || importing;
const handleExport = async () => {
const exportMode = mode;
const path = await pickBackupExportPath(exportMode);
if (!path) return;
setBusyAction('export');
setExporting(true);
try {
await waitForPaint();
await exportBackupToPath(exportMode, path);
const successKey = exportMode === 'full'
? 'settings.backupFullExportSuccess'
: exportMode === 'library'
? 'settings.backupLibraryExportSuccess'
: 'settings.backupSuccess';
showToast(t(successKey), 3000, 'info');
} catch (e) {
console.error('Export failed', e);
const errorKey = mode === 'full'
? 'settings.backupFullImportError'
: mode === 'library'
? 'settings.backupLibraryImportError'
: 'settings.backupImportError';
showToast(t(errorKey), 4000, 'error');
} finally {
setExporting(false);
setBusyAction(null);
}
};
const handleImport = async () => {
if (!window.confirm(t('settings.backupImportAnyConfirm'))) return;
const path = await pickBackupImportPath();
if (!path) return;
setBusyAction('import');
setImporting(true);
try {
await waitForPaint();
const importedKind = await importAnyBackupFromPath(path);
if (importedKind === 'full') {
showToast(t('settings.backupFullImportSuccess'), 3000, 'info');
} else if (importedKind === 'databases') {
showToast(t('settings.backupLibraryImportSuccess'), 3000, 'info');
} else if (importedKind === 'config') {
showToast(t('settings.backupImportSuccess'), 3000, 'info');
}
} catch (e) {
console.error('Import failed', e);
showToast(t('settings.backupImportError'), 4000, 'error');
} finally {
setImporting(false);
setBusyAction(null);
}
};
const modeTitle = mode === 'full'
? t('settings.backupModeFull')
: mode === 'library'
? t('settings.backupModeLibrary')
: t('settings.backupModeConfig');
const modeDesc = mode === 'full'
? t('settings.backupFullDesc')
: mode === 'library'
? t('settings.backupLibraryExportDesc')
: t('settings.backupExportDesc');
const exportLabel = mode === 'full'
? t('settings.backupFullExport')
: mode === 'library'
? t('settings.backupLibraryExport')
: t('settings.backupExport');
const importLabel = t('settings.backupImportAny');
const overlayTitle = busyAction === 'import'
? t('settings.backupOverlayImportTitle')
: t('settings.backupOverlayExportTitle');
const overlayHint = t('settings.backupOverlayHint');
const busyOverlay = busy && typeof document !== 'undefined'
? createPortal(
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.38)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
zIndex: 99999,
pointerEvents: 'all',
boxSizing: 'border-box',
}}
aria-live="polite"
aria-busy="true"
>
<div
className="settings-card"
style={{
width: 'clamp(280px, 52vw, 560px)',
maxWidth: 'calc(100vw - 32px)',
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: '999px',
background: 'var(--surface-3)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '0.6rem',
}}
>
<Clock3 size={18} />
</div>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: '0.5rem' }}>{overlayTitle}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{overlayHint}</div>
</div>
</div>,
document.body,
)
: null;
return (
<div className="settings-card">
<SettingsGroup>
<div className="settings-segmented" style={{ marginBottom: '0.85rem' }}>
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
<button
key={candidate}
type="button"
className={`btn ${mode === candidate ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setMode(candidate)}
>
{candidate === 'full'
? t('settings.backupModeFull')
: candidate === 'library'
? t('settings.backupModeLibrary')
: t('settings.backupModeConfig')}
</button>
))}
</div>
<SettingsSubCard>
<SettingsField label={modeTitle} desc={modeDesc}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
<button
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
>
<Upload size={14} />
{exporting ? '…' : exportLabel}
</button>
<button
className="btn btn-surface"
onClick={handleImport}
disabled={importing}
>
<Download size={14} />
{importing ? '…' : importLabel}
</button>
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
{busyOverlay}
</div>
);
}
@@ -0,0 +1,426 @@
import { Image, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsSubCard } from '@/features/settings/components/SettingsSubCard';
import { useCoverStrategyStore } from '@/store/coverStrategyStore';
import { useAuthStore } from '@/store/authStore';
import {
coverCacheClearServer,
coverCacheStatsServer,
libraryCoverCatalogSize,
libraryCoverProgress,
} from '@/api/coverCache';
import { clearDiskSrcCacheForServer } from '@/cover/diskSrcCache';
import { serverListDisplayLabel } from '@/utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
import { showToast } from '@/utils/ui/toast';
import { formatBytes } from '@/utils/format/formatBytes';
import { clearImageCache, getImageCacheSize } from '@/utils/imageCache';
import { wakeLibraryCoverBackfill } from '@/utils/library/coverBackfillWake';
import {
COVER_CACHE_STRATEGIES,
type CoverCacheStrategy,
} from '@/utils/library/coverStrategy';
type ClearTarget =
| { kind: 'image' }
| { kind: 'disk'; serverId: string; indexKey: string; label: string };
const ROW_BORDER = { borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' } as const;
const TH_STYLE = {
textAlign: 'left' as const,
padding: '8px 10px',
fontSize: 12,
color: 'var(--text-muted)',
};
const TABLE_STYLE = {
width: '100%',
borderCollapse: 'collapse',
minWidth: 520,
tableLayout: 'fixed',
} as const;
const STRATEGY_GAP_TH: CSSProperties = { ...TH_STYLE, padding: '8px 10px' };
const STRATEGY_GAP_TD: CSSProperties = { padding: '10px' };
function CoverCacheColGroup() {
return (
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '38%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '18%' }} />
</colgroup>
);
}
type ServerRowState = {
bytes: number;
entryCount: number;
done: number;
total: number;
pending: number;
};
export default function CoverCacheStrategySection() {
const { t } = useTranslation();
const auth = useAuthStore();
const servers = auth.servers;
const activeServerId = auth.activeServerId;
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
const [clearingKey, setClearingKey] = useState<string | null>(null);
const [imageCacheBytes, setImageCacheBytes] = useState<number | null>(null);
const activeIndexKeys = useMemo(
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
[servers],
);
const removedServerKeys = useMemo(() => {
const known = new Set(Object.keys(strategyByServer));
return Array.from(known).filter(key => !activeIndexKeys.has(key));
}, [strategyByServer, activeIndexKeys]);
const refreshRow = useCallback(async (serverId: string, indexKey: string) => {
const [stats, progress, catalog] = await Promise.all([
coverCacheStatsServer(indexKey).catch(() => ({ bytes: 0, entryCount: 0 })),
libraryCoverProgress(indexKey, serverId).catch(() => ({ done: 0, totalDistinct: 0, pending: 0 })),
libraryCoverCatalogSize(serverId).catch(() => 0),
]);
const total = Math.max(progress.totalDistinct, catalog);
setRowState(prev => ({
...prev,
[indexKey]: {
bytes: stats.bytes,
entryCount: stats.entryCount,
done: progress.done,
total,
pending: Math.max(progress.pending, total > 0 ? total - progress.done : 0),
},
}));
}, []);
const refreshAll = useCallback(() => {
void Promise.all(
servers.map(server => refreshRow(server.id, serverIndexKeyForProfile(server))),
);
}, [servers, refreshRow]);
// Recompute on entry only. Live updates during an active backfill arrive via the
// `cover:library-progress` event (carries done/total/pending/bytes/entryCount), and
// clearing the cache emits `cover:cache-cleared`. A slow 5-minute tick is just a
// safety net for changes made outside this view (e.g. browsing-time caching); it is
// not needed for correctness, so we avoid re-walking the cover dirs in a tight loop.
useEffect(() => {
void getImageCacheSize().then(setImageCacheBytes);
refreshAll();
const id = window.setInterval(refreshAll, 300_000);
return () => window.clearInterval(id);
}, [refreshAll]);
useEffect(() => {
const unsubs: Array<() => void> = [];
void (async () => {
unsubs.push(await listen<{
serverIndexKey?: string;
done?: number;
total?: number;
pending?: number;
bytes?: number;
entryCount?: number;
}>('cover:library-progress', e => {
const key = e.payload.serverIndexKey;
if (!key) return;
setRowState(prev => {
const cur = prev[key];
if (!cur) return prev;
const done = typeof e.payload.done === 'number' ? e.payload.done : cur.done;
const total = typeof e.payload.total === 'number' ? e.payload.total : cur.total;
return {
...prev,
[key]: {
...cur,
done,
total,
pending: e.payload.pending ?? Math.max(0, total - done),
bytes: typeof e.payload.bytes === 'number' ? e.payload.bytes : cur.bytes,
entryCount:
typeof e.payload.entryCount === 'number' ? e.payload.entryCount : cur.entryCount,
},
};
});
}));
unsubs.push(await listen<{ serverIndexKey?: string }>('cover:cache-cleared', e => {
const key = e.payload.serverIndexKey;
if (key) {
setRowState(prev => ({
...prev,
[key]: { bytes: 0, entryCount: 0, done: 0, total: prev[key]?.total ?? 0, pending: prev[key]?.total ?? 0 },
}));
} else {
refreshAll();
}
}));
})();
return () => {
for (const u of unsubs) u();
};
}, [refreshAll]);
const strategyLabel = (s: CoverCacheStrategy) => {
switch (s) {
case 'lazy':
return t('settings.coverCacheStrategyLazy');
case 'aggressive':
return t('settings.coverCacheStrategyAggressive');
}
};
const progressLabel = (row: ServerRowState | undefined, strategy: CoverCacheStrategy) => {
if (!row || strategy !== 'aggressive' || row.total <= 0) {
return row ? t('settings.coverCacheStrategyDiskUsage', { size: formatBytes(row.bytes) }) : '—';
}
const percent = Math.max(0, Math.min(100, Math.round((row.done / row.total) * 100)));
return t('settings.coverCacheStrategyProgressValue', {
percent,
done: row.done.toLocaleString(),
total: row.total.toLocaleString(),
size: formatBytes(row.bytes),
});
};
const handleStrategyChange = (serverId: string, strategy: CoverCacheStrategy) => {
setServerStrategy(serverId, strategy);
if (serverId === activeServerId) {
wakeLibraryCoverBackfill();
}
};
const handleClearConfirm = async () => {
if (!clearTarget) return;
if (clearTarget.kind === 'image') {
setClearingKey('image');
try {
await clearImageCache();
setImageCacheBytes(await getImageCacheSize());
} finally {
setClearingKey(null);
setClearTarget(null);
}
return;
}
setClearingKey(clearTarget.indexKey);
try {
await coverCacheClearServer(clearTarget.indexKey);
clearDiskSrcCacheForServer(clearTarget.indexKey);
await refreshRow(clearTarget.serverId, clearTarget.indexKey);
if (clearTarget.serverId === activeServerId) {
wakeLibraryCoverBackfill();
}
showToast(t('settings.coverCacheStrategyClearSuccess'), 4000, 'success');
} catch {
showToast(t('settings.coverCacheStrategyClearError'), 5000, 'error');
} finally {
setClearingKey(null);
setClearTarget(null);
}
};
return (
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<div style={{ overflowX: 'auto' }}>
<table style={TABLE_STYLE}>
<CoverCacheColGroup />
<thead>
<tr>
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.imageCacheScopeLabel')}</th>
<th style={STRATEGY_GAP_TH} aria-hidden="true" />
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
</tr>
</thead>
<tbody>
<tr>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
{t('settings.imageCacheSubTitle')}
</td>
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
{imageCacheBytes !== null
? t('settings.coverCacheStrategyDiskUsage', { size: formatBytes(imageCacheBytes) })
: '—'}
</td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() => setClearTarget({ kind: 'image' })}
>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div
style={{
margin: '20px 0 10px',
fontSize: 13,
fontWeight: 500,
color: 'var(--text-primary)',
}}
>
{t('settings.coverDiskCacheSubTitle')}
</div>
<div style={{ overflowX: 'auto' }}>
<table style={TABLE_STYLE}>
<CoverCacheColGroup />
<thead>
<tr>
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.coverCacheStrategyServerLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
</tr>
</thead>
<tbody>
{servers.map(server => {
const strategy = getStrategyForServer(server.id);
const key = serverIndexKeyForProfile(server);
const row = rowState[key];
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={ROW_BORDER}>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
<td style={{ padding: '10px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{COVER_CACHE_STRATEGIES.map(s => (
<button
key={s}
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => handleStrategyChange(server.id, s)}
>
{strategyLabel(s)}
</button>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, lineHeight: 1.4 }}>
{strategy === 'aggressive'
? t('settings.coverCacheStrategyAggressiveDesc')
: t('settings.coverCacheStrategyLazyDesc')}
</div>
</td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
{progressLabel(row, strategy)}
</td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() =>
setClearTarget({ kind: 'disk', serverId: server.id, indexKey: key, label })}
>
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
</button>
</td>
</tr>
);
})}
{removedServerKeys.map(key => (
<tr key={`removed-${key}`} style={ROW_BORDER}>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-muted)' }}>
{key}
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
</td>
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() =>
setClearTarget({ kind: 'disk', serverId: key, indexKey: key, label: key })}
>
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
{t('settings.coverCacheStrategyDesc')}
</p>
{clearTarget && (
<div
style={{
marginTop: 16,
background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '10px 14px',
fontSize: 13,
}}
>
{clearTarget.kind === 'image' ? (
<>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.cacheClearBtn')}</div>
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>{t('settings.cacheClearWarning')}</div>
</>
) : (
<>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
</div>
</>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
disabled={clearingKey !== null}
onClick={() => void handleClearConfirm()}
>
{clearTarget.kind === 'image'
? t('settings.cacheClearConfirm')
: t('settings.coverCacheStrategyClearConfirm')}
</button>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13 }}
disabled={clearingKey !== null}
onClick={() => setClearTarget(null)}
>
{clearTarget.kind === 'image'
? t('settings.cacheClearCancel')
: t('settings.coverCacheStrategyClearCancel')}
</button>
</div>
</div>
)}
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
);
}
@@ -0,0 +1,114 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { CustomHeaderEntry, CustomHeadersApplyTo } from '@/store/authStoreTypes';
export type CustomHttpHeadersEditorProps = {
headers: CustomHeaderEntry[];
applyTo: CustomHeadersApplyTo;
open: boolean;
onOpenChange: (open: boolean) => void;
onHeadersChange: (headers: CustomHeaderEntry[]) => void;
onApplyToChange: (applyTo: CustomHeadersApplyTo) => void;
/** Optional id prefix for radio group name (avoid collisions when multiple forms mount). */
radioGroupName?: string;
};
export function CustomHttpHeadersEditor({
headers,
applyTo,
open,
onOpenChange,
onHeadersChange,
onApplyToChange,
radioGroupName = 'customHeadersApplyTo',
}: CustomHttpHeadersEditorProps) {
const { t } = useTranslation();
return (
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13, padding: '4px 0' }}
onClick={() => onOpenChange(!open)}
>
{open ? '▾' : '▸'} {t('settings.customHeadersTitle')}
</button>
{open && (
<div style={{ marginTop: 8 }}>
<p style={{ fontSize: 11, opacity: 0.75, margin: '0 0 8px' }}>
{t('settings.customHeadersHelp')}
</p>
{headers.map((row, index) => (
<div key={index} className="form-row" style={{ marginBottom: 6, gap: 8 }}>
<input
className="input"
type="text"
value={row.name}
onChange={e => {
const name = e.target.value;
onHeadersChange(headers.map((h, i) => (i === index ? { ...h, name } : h)));
}}
placeholder={t('settings.customHeadersNamePlaceholder')}
autoComplete="off"
/>
<input
className="input"
type="password"
value={row.value}
onChange={e => {
const value = e.target.value;
onHeadersChange(headers.map((h, i) => (i === index ? { ...h, value } : h)));
}}
placeholder={t('settings.customHeadersValuePlaceholder')}
autoComplete="off"
/>
<button
type="button"
className="btn btn-ghost"
aria-label={t('settings.customHeadersRemoveRow')}
onClick={() =>
onHeadersChange(
headers.length <= 1
? [{ name: '', value: '' }]
: headers.filter((_, i) => i !== index),
)
}
>
×
</button>
</div>
))}
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, marginBottom: 8 }}
onClick={() => onHeadersChange([...headers, { name: '', value: '' }])}
>
{t('settings.customHeadersAddRow')}
</button>
<fieldset
disabled={!headers.some(h => h.name.trim() || h.value)}
style={{ border: 'none', padding: 0, margin: 0 }}
>
<legend style={{ fontSize: 12, marginBottom: 4 }}>{t('settings.customHeadersApplyTo')}</legend>
{(['public', 'local', 'both'] as const).map(kind => (
<label key={kind} style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
<input
type="radio"
name={radioGroupName}
checked={applyTo === kind}
onChange={() => onApplyToChange(kind)}
/>{' '}
{t(`settings.customHeadersApplyTo_${kind}`)}
</label>
))}
</fieldset>
<p style={{ fontSize: 11, opacity: 0.65, marginTop: 6 }}>
{t('settings.customHeadersNotInShare')}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from 'react-i18next';
import { useHomeStore, HomeSectionId } from '@/store/homeStore';
export function HomeCustomizer() {
const { t } = useTranslation();
const { sections, toggleSection } = useHomeStore();
const SECTION_LABELS: Record<HomeSectionId, string> = {
hero: t('home.hero'),
recent: t('sidebar.newReleases'),
discover: t('home.discover'),
becauseYouLike: t('home.becauseYouLike'),
discoverSongs: t('home.discoverSongs'),
discoverArtists: t('home.discoverArtists'),
recentlyPlayed: t('home.recentlyPlayed'),
starred: t('home.starred'),
mostPlayed: t('home.mostPlayed'),
losslessAlbums: t('home.losslessAlbums'),
};
return (
<div style={{ padding: '4px 0' }}>
{sections.map(sec => (
<div key={sec.id} className="sidebar-customizer-row">
<span style={{ flex: 1, 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>
);
}
@@ -0,0 +1,194 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Keyboard, RotateCcw, X } from 'lucide-react';
import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '@/config/shortcutActions';
import { useGlobalShortcutsStore, type GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '@/store/globalShortcutsStore';
import { useKeybindingsStore, type KeyAction, buildInAppBinding, formatBinding } from '@/store/keybindingsStore';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsSubCard } from '@/features/settings/components/SettingsSubCard';
export function InputTab() {
const { t } = useTranslation();
const kb = useKeybindingsStore();
const gs = useGlobalShortcutsStore();
const [listeningFor, setListeningFor] = useState<KeyAction | null>(null);
const [listeningForGlobal, setListeningForGlobal] = useState<GlobalAction | null>(null);
return (
<>
<SettingsSubSection
title={t('settings.inputKeybindingsTitle')}
icon={<Keyboard size={16} />}
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => { kb.resetToDefaults(); setListeningFor(null); }}
data-tooltip={t('settings.shortcutsReset')}
aria-label={t('settings.shortcutsReset')}
>
<RotateCcw size={14} />
</button>
}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t);
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') {
setListeningFor(null);
window.removeEventListener('keydown', handler, true);
return;
}
const chord = buildInAppBinding(e);
if (!chord) return;
const existing = (Object.entries(kb.bindings) as [KeyAction, string | null][])
.find(([, c]) => c === chord)?.[0];
if (existing && existing !== action) kb.setBinding(existing, null);
kb.setBinding(action, chord);
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(--bg-app)' : bound ? 'var(--text-primary)' : 'var(--text-muted)',
border: `1px solid ${isListening ? 'var(--accent)' : 'var(--border-subtle)'}`,
cursor: 'pointer',
}}
>
{isListening ? t('settings.shortcutListening') : bound ? formatBinding(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>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.globalShortcutsTitle')}
icon={<Keyboard size={16} />}
description={t('settings.globalShortcutsNote')}
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => { gs.resetAll(); setListeningForGlobal(null); }}
data-tooltip={t('settings.shortcutsReset')}
aria-label={t('settings.shortcutsReset')}
>
<RotateCcw size={14} />
</button>
}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t);
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(--bg-app)' : 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>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,197 @@
import { useMemo, useState } from 'react';
import { Check, RefreshCw, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useThemeStore } from '@/store/themeStore';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import { uninstallTheme } from '@/utils/themes/uninstallTheme';
import { installThemeFromRegistry } from '@/utils/themes/installThemeFromRegistry';
import { useThemeUpdates } from '@/hooks/useThemeUpdates';
import { useThemeAnimationRisk } from '@/hooks/useThemeAnimationRisk';
import { showToast } from '@/utils/ui/toast';
import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge';
import { FIXED_THEMES } from '@/components/settings/fixedThemes';
/** Pull a 3-band swatch (bg / card / accent) out of an installed theme's CSS. */
function swatch(css: string): { bg: string; card: string; accent: string } {
const read = (name: string, fallback: string) => {
const m = css.match(new RegExp(`--${name}\\s*:\\s*([^;]+);`));
return m ? m[1].trim() : fallback;
};
return {
bg: read('bg-app', '#1e1e2e'),
card: read('bg-card', '#313244'),
accent: read('accent', '#cba6f7'),
};
}
interface Card {
id: string;
label: string;
bg: string;
card: string;
accent: string;
fixed: boolean;
accessibility: boolean;
animated: boolean;
/** Community themes carry a manifest version; fixed cores do not. */
version?: string;
}
/**
* Flat card grid of the user's available themes: the fixed cores plus every
* installed community theme. Click a card to apply it; community themes carry an
* uninstall control (the fixed cores do not). No accordion — this page is only
* about themes, so everything is shown at once.
*/
export function InstalledThemes() {
const { t } = useTranslation();
const active = useThemeStore(s => s.theme);
const setTheme = useThemeStore(s => s.setTheme);
const installed = useInstalledThemesStore(s => s.themes);
const animRisk = useThemeAnimationRisk();
const updates = useThemeUpdates();
const updateById = useMemo(() => new Map(updates.map(u => [u.id, u])), [updates]);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const handleUpdate = async (id: string) => {
const th = updateById.get(id);
if (!th) return;
setUpdatingId(id);
const result = await installThemeFromRegistry(th);
setUpdatingId(null);
if (result !== 'ok') showToast(t('settings.themeStoreInstallFailed'), 4000, 'error');
};
const cards: Card[] = [
...FIXED_THEMES.map(f => ({ id: f.id, label: f.label, bg: f.bg, card: f.card, accent: f.accent, fixed: true, accessibility: !!f.accessibility, animated: false })),
...installed.map(it => {
const s = swatch(it.css);
return { id: it.id, label: it.name, bg: s.bg, card: s.card, accent: s.accent, fixed: false, accessibility: (it.tags || []).includes('accessibility'), animated: /@(?:-[a-z]+-)?keyframes\b/i.test(it.css), version: it.version };
}),
];
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '10px' }}>
{cards.map(c => {
const isActive = active === c.id;
return (
<div key={c.id} style={{ position: 'relative' }}>
<button className="theme-card-btn" style={{ width: '100%' }} aria-pressed={isActive} onClick={() => setTheme(c.id)}>
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
<div style={{ background: c.bg, height: '55%' }} />
<div style={{ background: c.card, height: '20%' }} />
<div style={{ background: c.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: c.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
{animRisk && c.animated && <AnimatedThemeBadge variant="overlay" />}
</div>
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
{c.label}
</span>
{c.version && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', display: 'block', textAlign: 'center', lineHeight: 1.2 }}>
v{c.version}
</span>
)}
{c.accessibility && (
<span
aria-label={t('settings.themesCvdTooltip')}
data-tooltip={t('settings.themesCvdTooltip')}
data-tooltip-pos="top"
style={{
fontSize: 9,
fontWeight: 700,
lineHeight: 1,
padding: '2px 6px',
borderRadius: 999,
background: 'var(--accent-dim)',
color: 'var(--accent)',
letterSpacing: 0.3,
whiteSpace: 'nowrap',
}}
>
CVD-safe
</span>
)}
</button>
{!c.fixed && (
<button
onClick={() => uninstallTheme(c.id)}
aria-label={t('settings.themeStoreUninstall')}
data-tooltip={t('settings.themeStoreUninstall')}
data-tooltip-pos="top"
style={{
position: 'absolute',
top: 2,
left: 2,
width: 18,
height: 18,
borderRadius: '50%',
border: 'none',
background: 'var(--bg-elevated)',
color: 'var(--text-secondary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
padding: 0,
}}
>
<X size={11} />
</button>
)}
{updateById.has(c.id) && (
<button
type="button"
onClick={() => handleUpdate(c.id)}
disabled={updatingId === c.id}
data-tooltip={updatingId === c.id ? t('settings.themeStoreUpdating') : t('settings.themeStoreUpdate')}
data-tooltip-pos="top"
aria-label={t('settings.themeStoreUpdate')}
// Big centered icon overlay across the whole 46px preview so an
// available update is impossible to miss; click updates in place.
// Icon (not text) so it fits the small card in any language.
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 46,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
border: 'none',
overflow: 'hidden',
background: 'var(--accent)',
color: 'var(--text-on-accent, #fff)',
cursor: updatingId === c.id ? 'default' : 'pointer',
opacity: updatingId === c.id ? 0.7 : 1,
}}
>
<RefreshCw size={20} strokeWidth={2.5} className={updatingId === c.id ? 'spin' : undefined} />
</button>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,262 @@
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Check, Image as ImageIcon, Info, Sparkles, Wifi } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useThemeStore } from '@/store/themeStore';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
import { BackdropSourceList } from '@/features/settings/components/BackdropSourceList';
import type { BackdropSurface } from '@/store/themeStore';
import type { BackdropSource } from '@/cover/artistBackdrop';
import { MusicNetworkSection } from '@/features/settings/components/musicNetwork/MusicNetworkSection';
import { purgeExternalArtworkAllServers } from '@/api/coverCache';
export function IntegrationsTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const theme = useThemeStore();
const backdropSurfaces: { key: BackdropSurface; label: string }[] = [
{ key: 'mainstageHero', label: t('settings.backdropSurfaceMainstage') },
{ key: 'artistDetailHero', label: t('settings.backdropSurfaceArtistDetail') },
{ key: 'fullscreenPlayer', label: t('settings.backdropSurfaceFullscreen') },
];
const backdropSourceLabel = (s: BackdropSource): string =>
s === 'banner'
? t('settings.backdropSourceBanner')
: s === 'fanart'
? t('settings.backdropSourceFanart')
: t('settings.backdropSourceNavidrome');
return (
<>
<div
className="settings-privacy-notice"
role="note"
aria-label={t('settings.integrationsPrivacyTitle')}
>
<AlertTriangle size={16} className="settings-privacy-notice-icon" aria-hidden="true" />
<div>
<div className="settings-privacy-notice-title">{t('settings.integrationsPrivacyTitle')}</div>
<div
className="settings-privacy-notice-body"
// Enthaelt <strong> aus dem i18n-String — der Inhalt ist statisch
// und kommt nur aus unseren Locales, kein User-Input.
dangerouslySetInnerHTML={{ __html: t('settings.integrationsPrivacyBody') }}
/>
</div>
</div>
{/* Music Network — scrobbling + enrichment across multiple services */}
<MusicNetworkSection />
{/* Discord Rich Presence */}
<SettingsSubSection
title={t('settings.discordRichPresence')}
icon={<Sparkles size={16} />}
>
<div className="settings-card">
<div
style={{ fontWeight: 700, fontSize: 13, lineHeight: 1.5, marginBottom: 'var(--space-3)', color: 'var(--text-secondary)' }}
>
{t('settings.discordRichPresenceNotice')}
</div>
<SettingsGroup title={t('settings.discordRichPresence')}>
<SettingsToggle
desc={t('settings.discordRichPresenceDesc')}
ariaLabel={t('settings.discordRichPresence')}
checked={auth.discordRichPresence}
onChange={auth.setDiscordRichPresence}
/>
</SettingsGroup>
{auth.discordRichPresence && (
<>
<SettingsGroup title={t('settings.discordCoverTitle')} desc={t('settings.discordCoverDesc')}>
<SettingsToggle
label={t('settings.discordCoverNone')}
checked={auth.discordCoverSource === 'none'}
onChange={c => auth.setDiscordCoverSource(c ? 'none' : 'server')}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.discordCoverServer')}
checked={auth.discordCoverSource === 'server'}
onChange={c => auth.setDiscordCoverSource(c ? 'server' : 'none')}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.discordCoverApple')}
checked={auth.discordCoverSource === 'apple'}
onChange={c => auth.setDiscordCoverSource(c ? 'apple' : 'none')}
/>
</SettingsGroup>
<SettingsGroup title={t('settings.discordTemplates')} desc={t('settings.discordTemplatesDesc')}>
<SettingsSubCard>
<SettingsField label={t('settings.discordTemplateName')}>
<input
className="input"
type="text"
value={auth.discordTemplateName}
onChange={e => auth.setDiscordTemplateName(e.target.value)}
placeholder="{title}"
/>
</SettingsField>
<SettingsField label={t('settings.discordTemplateDetails')}>
<input
className="input"
type="text"
value={auth.discordTemplateDetails}
onChange={e => auth.setDiscordTemplateDetails(e.target.value)}
placeholder="{artist}"
/>
</SettingsField>
<SettingsField label={t('settings.discordTemplateState')}>
<input
className="input"
type="text"
value={auth.discordTemplateState}
onChange={e => auth.setDiscordTemplateState(e.target.value)}
placeholder="{title}"
/>
</SettingsField>
<SettingsField label={t('settings.discordTemplateLargeText')}>
<input
className="input"
type="text"
value={auth.discordTemplateLargeText}
onChange={e => auth.setDiscordTemplateLargeText(e.target.value)}
placeholder="{album}"
/>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</>
)}
</div>
</SettingsSubSection>
{/* Bandsintown */}
<SettingsSubSection
title={t('settings.enableBandsintown')}
icon={<Info size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsToggle
desc={t('settings.enableBandsintownDesc')}
ariaLabel={t('settings.enableBandsintown')}
checked={auth.enableBandsintown}
onChange={auth.setEnableBandsintown}
/>
</SettingsGroup>
</div>
</SettingsSubSection>
{/* External artist artwork (fanart.tv) */}
<SettingsSubSection
title={t('settings.externalArtwork')}
icon={<ImageIcon size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsToggle
desc={t('settings.externalArtworkDesc')}
note={t('settings.externalArtworkNote')}
ariaLabel={t('settings.externalArtwork')}
checked={theme.externalArtworkEnabled}
onChange={v => {
theme.setExternalArtworkEnabled(v);
// Opt-out: purge the fetched external images + lookup rows so
// turning the scraper off actually removes the third-party data,
// not just hides it (design-review §9/§12/B.4).
if (!v) void purgeExternalArtworkAllServers();
}}
/>
</SettingsGroup>
{theme.externalArtworkEnabled && (
<SettingsGroup
title={t('settings.externalArtworkByokTitle')}
desc={t('settings.externalArtworkByokDesc')}
>
<SettingsSubCard>
<SettingsField>
<input
className="input"
type="password"
value={theme.externalArtworkByok}
onChange={e => theme.setExternalArtworkByok(e.target.value)}
placeholder="fanart.tv personal API key"
spellCheck={false}
autoComplete="off"
/>
{theme.externalArtworkByok.trim() && (
<div
style={{
fontSize: 12,
color: 'var(--text-muted)',
marginTop: 6,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<Check size={13} /> {t('settings.externalArtworkByokSaved')}
</div>
)}
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
)}
{theme.externalArtworkEnabled && (
<SettingsGroup
title={t('settings.backdropSourcesTitle')}
desc={t('settings.backdropSourcesSub')}
>
<SettingsSubCard>
{backdropSurfaces.map(({ key, label }) => (
<div className="backdrop-surface-block" key={key}>
<SettingsToggle
label={label}
checked={theme.backdrops[key].enabled}
onChange={(v) => theme.setBackdropEnabled(key, v)}
/>
{theme.backdrops[key].enabled && (
<BackdropSourceList
surface={key}
sources={theme.backdrops[key].sources}
labelFor={backdropSourceLabel}
onChange={(next) => theme.setBackdropSources(key, next)}
moveUpLabel={t('settings.backdropMoveUp')}
moveDownLabel={t('settings.backdropMoveDown')}
/>
)}
</div>
))}
</SettingsSubCard>
</SettingsGroup>
)}
</div>
</SettingsSubSection>
{/* Now-Playing Share (Navidrome) */}
<SettingsSubSection
title={t('settings.nowPlayingEnabled')}
icon={<Wifi size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsToggle
desc={t('settings.nowPlayingEnabledDesc')}
note={t('settings.nowPlayingPluginNote')}
ariaLabel={t('settings.nowPlayingEnabled')}
checked={auth.nowPlayingEnabled}
onChange={auth.setNowPlayingEnabled}
/>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,201 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Shuffle, Star } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { MIX_MIN_RATING_FILTER_MAX_STARS } from '@/store/authStoreDefaults';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
import StarRating from '@/components/StarRating';
import AnalyticsStrategySection from '@/features/settings/components/AnalyticsStrategySection';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
export function LibraryTab() {
const { t } = useTranslation();
const [newGenre, setNewGenre] = useState('');
const auth = useAuthStore();
return (
<>
<AnalyticsStrategySection />
{/* Random Mix Blacklist */}
<SettingsSubSection
title={t('settings.randomMixTitle')}
icon={<Shuffle size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField desc={t('settings.randomMixBlacklistDesc')} />
<SettingsField label={t('settings.randomMixBlacklistTitle')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', 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>
</SettingsField>
<SettingsField label={t('settings.randomMixHardcodedTitle')}>
<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>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
{/* Ratings */}
<SettingsSubSection
title={t('settings.ratingsSectionTitle')}
icon={<Star size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<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" />
<SettingsToggle
label={t('settings.ratingsMixFilterTitle')}
desc={t('settings.ratingsMixFilterDesc', {
mix: t('sidebar.randomMix'),
albums: t('sidebar.randomAlbums'),
})}
checked={auth.mixMinRatingFilterEnabled}
onChange={auth.setMixMinRatingFilterEnabled}
/>
{auth.mixMinRatingFilterEnabled && (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 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>
</SettingsSubCard>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,24 @@
import type { LoudnessLufsPreset } from '@/store/authStoreTypes';
const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16];
export function LoudnessLufsButtonGroup(props: {
value: LoudnessLufsPreset;
onSelect: (v: LoudnessLufsPreset) => void;
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
{LOUDNESS_LUFS_BUTTON_ORDER.map(v => (
<button
key={v}
type="button"
className={`btn ${props.value === v ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => props.onSelect(v)}
>
{v}
</button>
))}
</div>
);
}
@@ -0,0 +1,100 @@
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/store/authStore';
import type { LyricsSourceId } from '@/store/authStoreTypes';
import { useListReorderDnd } from '@/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
server: 'settings.lyricsSourceServer',
lrclib: 'settings.lyricsSourceLrclib',
netease: 'settings.lyricsSourceNetease',
};
const REORDER_TYPE = 'lyrics_source_reorder';
export function LyricsSourcesCustomizer() {
const { t } = useTranslation();
const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources));
const setLyricsSources = useAuthStore(s => s.setLyricsSources);
const youLyPlusEnabled = useAuthStore(s => s.youLyPlusEnabled);
const setYouLyPlusEnabled = useAuthStore(s => s.setYouLyPlusEnabled);
const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly);
const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly);
const sourcesRef = useRef(lyricsSources);
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
sourcesRef.current = lyricsSources;
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(sourcesRef.current, draggedId, target);
if (next) setLyricsSources(next);
}, [setLyricsSources]);
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
const toggleSource = (id: LyricsSourceId) => {
setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s));
};
return (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('settings.lyricsSourcesDesc')}
</p>
{/* YouLyPlus (karaoke) — independent toggle. When on it is tried first and
the enabled sources below act as fallback; when off only those sources
are used. YouLyPlus off + every source off = lyrics fully disabled. */}
<div style={{ marginBottom: '0.75rem' }}>
<SettingsToggle
label={t('settings.lyricsYouLyPlus')}
desc={t('settings.lyricsYouLyPlusDesc')}
checked={youLyPlusEnabled}
onChange={setYouLyPlusEnabled}
/>
</div>
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
</div>
<div style={{ padding: '4px 0', marginBottom: '0.75rem' }} ref={setContainer} onMouseMove={onMouseMove}>
{lyricsSources.map((src) => {
const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]);
const edge = isDragging ? dropEdge(src.id) : null;
return (
<div
key={src.id}
data-reorder-id={src.id}
className="sidebar-customizer-row"
style={{
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<ReorderGripHandle id={src.id} type={REORDER_TYPE} label={label} />
<span style={{ flex: 1, fontSize: 14, opacity: src.enabled ? 1 : 0.45 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={src.enabled} onChange={() => toggleSource(src.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div style={{ marginBottom: '0.75rem' }}>
<SettingsToggle
label={t('settings.lyricsStaticOnly')}
desc={t('settings.lyricsStaticOnlyDesc')}
checked={lyricsStaticOnly}
onChange={setLyricsStaticOnly}
/>
</div>
</>
);
}
@@ -0,0 +1,52 @@
import { useTranslation } from 'react-i18next';
import { AudioLines, Music2 } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { LyricsSourcesCustomizer } from '@/features/settings/components/LyricsSourcesCustomizer';
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
export function LyricsTab() {
const { t } = useTranslation();
const sidebarLyricsStyle = useAuthStore(s => s.sidebarLyricsStyle);
const setSidebarLyricsStyle = useAuthStore(s => s.setSidebarLyricsStyle);
const lyricsStyleOptions: SegmentedOption<'classic' | 'apple'>[] = [
{ id: 'classic', label: t('settings.sidebarLyricsStyleClassic') },
{ id: 'apple', label: t('settings.sidebarLyricsStyleApple') },
];
const lyricsStyleDescKey =
sidebarLyricsStyle === 'classic'
? 'settings.sidebarLyricsStyleClassicDesc'
: 'settings.sidebarLyricsStyleAppleDesc';
return (
<>
<SettingsSubSection
title={t('settings.lyricsSourcesTitle')}
icon={<Music2 size={16} />}
>
<SettingsGroup>
<LyricsSourcesCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.sidebarLyricsStyle')}
icon={<AudioLines size={16} />}
>
<SettingsGroup>
<SettingsSegmented
options={lyricsStyleOptions}
value={sidebarLyricsStyle}
onChange={setSidebarLyricsStyle}
/>
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField desc={t(lyricsStyleDescKey)} />
</SettingsSubCard>
</SettingsGroup>
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,204 @@
import { useTranslation } from 'react-i18next';
import { Disc3, LayoutGrid, ListOrdered, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
import { useArtistLayoutStore } from '@/store/artistLayoutStore';
import { useAuthStore } from '@/store/authStore';
import type { QueueDisplayMode } from '@/store/authStoreTypes';
import { useHomeStore } from '@/store/homeStore';
import { usePlayerBarLayoutStore } from '@/store/playerBarLayoutStore';
import { usePlaylistLayoutStore } from '@/store/playlistLayoutStore';
import { useQueueToolbarStore } from '@/store/queueToolbarStore';
import { useSidebarStore } from '@/features/sidebar';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
import { ArtistLayoutCustomizer } from '@/features/settings/components/ArtistLayoutCustomizer';
import { HomeCustomizer } from '@/features/settings/components/HomeCustomizer';
import { PlayerBarLayoutCustomizer } from '@/features/settings/components/PlayerBarLayoutCustomizer';
import { PlaylistLayoutCustomizer } from '@/features/settings/components/PlaylistLayoutCustomizer';
import { QueueToolbarCustomizer } from '@/features/settings/components/QueueToolbarCustomizer';
import { SidebarCustomizer } from '@/features/settings/components/SidebarCustomizer';
export function PersonalisationTab() {
const { t } = useTranslation();
const queueDisplayMode = useAuthStore(s => s.queueDisplayMode);
const setQueueDisplayMode = useAuthStore(s => s.setQueueDisplayMode);
const preservePlayNextOrder = useAuthStore(s => s.preservePlayNextOrder);
const setPreservePlayNextOrder = useAuthStore(s => s.setPreservePlayNextOrder);
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
const queueModeOptions: SegmentedOption<QueueDisplayMode>[] = [
{ id: 'queue', label: t('queue.title') },
{ id: 'playlist', label: t('queue.modePlaylist') },
{ id: 'timeline', label: t('queue.modeTimeline') },
];
const queueModeDescKey =
queueDisplayMode === 'queue'
? 'settings.queueModeQueueSub'
: queueDisplayMode === 'playlist'
? 'settings.queueModePlaylistSub'
: 'settings.queueModeTimelineSub';
return (
<>
<SettingsSubSection
title={t('settings.sidebarTitle')}
icon={<PanelLeft size={16} />}
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => useSidebarStore.getState().reset()}
data-tooltip={t('settings.sidebarReset')}
aria-label={t('settings.sidebarReset')}
>
<RotateCcw size={14} />
</button>
}
>
<SidebarCustomizer />
</SettingsSubSection>
<SettingsSubSection
title={t('settings.homeCustomizerTitle')}
icon={<LayoutGrid size={16} />}
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => useHomeStore.getState().reset()}
data-tooltip={t('settings.sidebarReset')}
aria-label={t('settings.sidebarReset')}
>
<RotateCcw size={14} />
</button>
}
>
<SettingsGroup>
<HomeCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.artistLayoutTitle')}
icon={<Users size={16} />}
advanced
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => useArtistLayoutStore.getState().reset()}
data-tooltip={t('settings.artistLayoutReset')}
aria-label={t('settings.artistLayoutReset')}
>
<RotateCcw size={14} />
</button>
}
>
<SettingsGroup>
<ArtistLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection>
{/* Queue Settings — display mode, queue behaviour and (advanced) the
toolbar customizer, grouped under one category. */}
<SettingsSubSection
title={t('settings.queueSettingsTitle')}
icon={<ListOrdered size={16} />}
>
<>
{/* Three mutually exclusive modes — a segmented picker enforces that
exactly one is active, instead of toggles that read as independent. */}
<SettingsGroup title={t('settings.queueModeTitle')}>
<SettingsSegmented
options={queueModeOptions}
value={queueDisplayMode}
onChange={setQueueDisplayMode}
/>
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField desc={t(queueModeDescKey)} />
</SettingsSubCard>
</SettingsGroup>
<SettingsGroup title={t('settings.queueBehaviourTitle')}>
<SettingsToggle
label={t('settings.preservePlayNextOrder')}
desc={t('settings.preservePlayNextOrderDesc')}
checked={preservePlayNextOrder}
onChange={setPreservePlayNextOrder}
/>
</SettingsGroup>
{advancedSettingsEnabled && (
<SettingsGroup
title={t('settings.queueToolbarTitle')}
advanced
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => useQueueToolbarStore.getState().reset()}
data-tooltip={t('settings.queueToolbarReset')}
aria-label={t('settings.queueToolbarReset')}
>
<RotateCcw size={14} />
</button>
}
>
<QueueToolbarCustomizer />
</SettingsGroup>
)}
</>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.playlistLayoutTitle')}
icon={<ListTodo size={16} />}
advanced
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => usePlaylistLayoutStore.getState().reset()}
data-tooltip={t('settings.playlistLayoutReset')}
aria-label={t('settings.playlistLayoutReset')}
>
<RotateCcw size={14} />
</button>
}
>
<SettingsGroup>
<PlaylistLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.playerBarTitle')}
icon={<Disc3 size={16} />}
advanced
action={
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
onClick={() => usePlayerBarLayoutStore.getState().reset()}
data-tooltip={t('settings.playerBarReset')}
aria-label={t('settings.playerBarReset')}
>
<RotateCcw size={14} />
</button>
}
>
<SettingsGroup>
<PlayerBarLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,54 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Gauge, Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react';
import LastfmIcon from '@/components/LastfmIcon';
import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '@/store/playerBarLayoutStore';
const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
starRating: 'settings.playerBarStarRating',
favorite: 'settings.playerBarFavorite',
lastfmLove: 'settings.playerBarLastfmLove',
playbackRate: 'settings.playerBarPlaybackRate',
equalizer: 'settings.playerBarEqualizer',
miniPlayer: 'settings.playerBarMiniPlayer',
};
const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> = {
starRating: <Star size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
favorite: <Heart size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
lastfmLove: (
<span style={{ color: 'var(--text-muted)', display: 'inline-flex', flexShrink: 0 }} aria-hidden>
<LastfmIcon size={16} />
</span>
),
playbackRate: <Gauge size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
equalizer: <SlidersVertical size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
miniPlayer: <PictureInPicture2 size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
};
export function PlayerBarLayoutCustomizer() {
const { t } = useTranslation();
const items = usePlayerBarLayoutStore(s => s.items);
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
return (
<div style={{ padding: '4px 0' }}>
{items.map((it) => {
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
return (
<div key={it.id} className="sidebar-customizer-row">
{PLAYER_BAR_LAYOUT_ICONS[it.id]}
<span style={{ flex: 1, fontSize: 14, opacity: it.visible ? 1 : 0.45 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next';
import { Download, FileUp, HardDrive, Lightbulb, Search } from 'lucide-react';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '@/store/playlistLayoutStore';
const PLAYLIST_LAYOUT_ICONS: Record<PlaylistLayoutItemId, typeof Search> = {
addSongs: Search,
importCsv: FileUp,
downloadZip: Download,
offlineCache: HardDrive,
suggestions: Lightbulb,
};
const PLAYLIST_LAYOUT_LABEL_KEYS: Record<PlaylistLayoutItemId, string> = {
addSongs: 'playlists.addSongs',
importCsv: 'playlists.importCSV',
downloadZip: 'playlists.downloadZip',
offlineCache: 'playlists.cacheOffline',
suggestions: 'playlists.suggestions',
};
export function PlaylistLayoutCustomizer() {
const { t } = useTranslation();
const items = usePlaylistLayoutStore(s => s.items);
const toggleItem = usePlaylistLayoutStore(s => s.toggleItem);
return (
<div style={{ padding: '4px 0' }}>
{items.map((it) => {
const Icon = PLAYLIST_LAYOUT_ICONS[it.id];
const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]);
return (
<div key={it.id} className="sidebar-customizer-row">
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 14 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,86 @@
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { useQueueToolbarStore, QueueToolbarButtonId } from '@/store/queueToolbarStore';
import { useListReorderDnd } from '@/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
shuffle: Shuffle,
playlist: ListMusic,
share: Share2,
clear: Trash2,
separator: null, // No icon for separator
gapless: MoveRight,
crossfade: Waves,
autodj: Blend,
infinite: InfinityIcon,
};
const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
shuffle: 'queue.shuffle',
playlist: 'queue.playlist',
share: 'queue.shareQueue',
clear: 'queue.clear',
separator: 'settings.queueToolbarSeparator',
gapless: 'queue.gapless',
crossfade: 'queue.crossfade',
autodj: 'queue.autoDj',
infinite: 'queue.infiniteQueue',
};
const REORDER_TYPE = 'queue_toolbar_reorder';
export function QueueToolbarCustomizer() {
const { t } = useTranslation();
const { buttons, setButtons, toggleButton } = useQueueToolbarStore();
const buttonsRef = useRef(buttons);
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
buttonsRef.current = buttons;
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(buttonsRef.current, draggedId, target);
if (next) setButtons(next);
}, [setButtons]);
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
return (
<div ref={setContainer} onMouseMove={onMouseMove} style={{ padding: '4px 0' }}>
{buttons.map((btn) => {
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]);
const edge = isDragging ? dropEdge(btn.id) : null;
return (
<div
key={btn.id}
data-reorder-id={btn.id}
className="sidebar-customizer-row"
style={{
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<ReorderGripHandle id={btn.id} type={REORDER_TYPE} label={label} />
{Icon ? (
<Icon size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
) : (
// Reserve the same 16px icon column so the label lines up with the
// other rows; the 1px rule is centred within it.
<div style={{ width: 16, display: 'flex', justifyContent: 'center', flexShrink: 0 }}>
<div style={{ width: 1, height: 16, background: 'var(--border-subtle)' }} />
</div>
)}
<span style={{ flex: 1, fontSize: 14 }}>{label}</span>
<label className="toggle-switch" aria-label={label}>
<input type="checkbox" checked={btn.visible} onChange={() => toggleButton(btn.id)} />
<span className="toggle-track" />
</label>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next';
import { GripVertical } from 'lucide-react';
import { useDragSource } from '@/contexts/DragDropContext';
/**
* Drag handle shared by the reorder customizers. Emits an id-based payload
* (`{ type, id, section? }`) consumed by `useListReorderDnd`.
*/
export function ReorderGripHandle({
id, type, section, label,
}: { id: string; type: string; section?: string; label: string }) {
const { t } = useTranslation();
const { onMouseDown } = useDragSource(() => ({
data: JSON.stringify(section ? { type, id, section } : { type, id }),
label,
}));
return (
<span
className="sidebar-customizer-grip"
data-tooltip={t('settings.sidebarDrag')}
data-tooltip-pos="right"
onMouseDown={onMouseDown}
onClick={e => e.stopPropagation()}
>
<GripVertical size={16} />
</span>
);
}
@@ -0,0 +1,25 @@
import { useTranslation } from 'react-i18next';
import { getCapabilityDefinition } from '@/serverCapabilities/catalog';
import { isFeatureActiveForServer, resolveFeatureForServer } from '@/serverCapabilities/storeView';
/** Inline badge for auto-managed server capabilities (Settings → Servers header row). */
export function ServerCapabilityHeaderBadge({
serverId,
feature,
}: {
serverId: string;
feature: string;
}) {
const { t } = useTranslation();
const resolved = resolveFeatureForServer(serverId, feature);
if (!resolved || resolved.activation !== 'auto') return null;
if (!isFeatureActiveForServer(serverId, feature)) return null;
const def = getCapabilityDefinition(feature);
const labelKey = def?.badgeLabelKey ?? def?.labelKey;
if (!labelKey) return null;
return (
<span className="settings-server-inline-badge settings-server-inline-badge--positive">{t(labelKey)}</span>
);
}
@@ -0,0 +1,124 @@
import { RefreshCw, ShieldCheck, WifiOff, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { SyncStateDto } from '@/api/library';
import {
libraryStatusDisplayTrackCount,
libraryStatusIsReady,
} from '@/utils/library/libraryReady';
import type { LibraryServerConnection } from '@/hooks/useLibraryIndexSync';
interface ServerLibraryIndexControlsProps {
status: SyncStateDto | null;
connection: LibraryServerConnection;
progressLabel: string | null;
busy: boolean;
actionsDisabled: boolean;
onFullSync: () => void;
onDeltaSync: () => void;
onVerify: () => void;
onCancel: () => void;
}
export default function ServerLibraryIndexControls({
status,
connection,
progressLabel,
busy,
actionsDisabled,
onFullSync,
onDeltaSync,
onVerify,
onCancel,
}: ServerLibraryIndexControlsProps) {
const { t } = useTranslation();
const phaseLabel = (() => {
if (connection === 'offline') {
return t('settings.libraryIndexServerOffline');
}
if (progressLabel) return progressLabel;
if (!status) return t('settings.libraryIndexStatusIdle');
if (libraryStatusIsReady(status)) {
return t('settings.libraryIndexStatusReady', {
count: libraryStatusDisplayTrackCount(status),
});
}
switch (status.syncPhase) {
case 'initial_sync':
return t('settings.libraryIndexStatusInitial');
case 'error':
return t('settings.libraryIndexStatusError');
case 'probing':
return t('settings.libraryIndexStatusProbing');
default:
return t('settings.libraryIndexStatusIdle');
}
})();
return (
<div
style={{
marginTop: '0.75rem',
paddingTop: '0.75rem',
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)',
}}
>
<div style={{ fontSize: 12, lineHeight: 1.45, marginBottom: '0.5rem' }}>
{connection === 'offline' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, color: 'var(--text-muted)' }}>
<WifiOff size={12} style={{ flexShrink: 0 }} />
{phaseLabel}
</span>
) : busy ? (
<span style={{ color: 'var(--accent)' }}>
{t('settings.libraryIndexServerSyncing')} {phaseLabel}
</span>
) : (
<span style={{ color: 'var(--text-muted)' }}>{phaseLabel}</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onFullSync}
>
<RefreshCw size={13} />
{t('settings.libraryIndexFullResync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onDeltaSync}
>
<Zap size={13} />
{t('settings.libraryIndexDeltaSync')}
</button>
<button
type="button"
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
disabled={actionsDisabled || connection === 'offline'}
onClick={onVerify}
>
<ShieldCheck size={13} />
{t('settings.libraryIndexVerify')}
</button>
{busy && (
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={onCancel}
>
{t('settings.libraryIndexCancel')}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,602 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { AlertTriangle, CheckCircle2, Info, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Wifi, WifiOff } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { formatServerSoftware, isNavidromeAudiomuseSoftwareEligible, type InstantMixProbeResult, type SubsonicServerIdentity } from '@/utils/server/subsonicServerIdentity';
import { buildCapabilityContext } from '@/serverCapabilities/context';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { libraryDeleteServerData, librarySyncClearSession } from '@/api/library';
import { bootstrapIndexedServer } from '@/utils/library/librarySession';
import { useLibraryIndexSync } from '@/hooks/useLibraryIndexSync';
import ServerLibraryIndexControls from '@/features/settings/components/ServerLibraryIndexControls';
import type { ServerProfile } from '@/store/authStoreTypes';
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '@/api/subsonic';
import {
clearServerHttpContext,
syncServerHttpContextForProfile,
} from '@/utils/server/syncServerHttpContext';
import { type ServerMagicPayload } from '@/utils/server/serverMagicString';
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '@/utils/server/serverEndpoint';
import {
verifySameServerEndpoints,
type VerifySameServerResult,
} from '@/utils/server/serverFingerprint';
import {
indexKeyRemapForUrlChange,
runIndexKeyRemigration,
} from '@/utils/server/serverUrlRemigration';
import { useConfirmModalStore } from '@/store/confirmModalStore';
import { showToast } from '@/utils/ui/toast';
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS } from '@/serverCapabilities/catalog';
import { isFeatureActiveForServer, resolveFeatureForServer } from '@/serverCapabilities/storeView';
import type { ResolvedCapability } from '@/serverCapabilities/types';
import { serverIdentityLabel, serverListDisplayLabel, serverSettingsEntryTitle } from '@/utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { AddServerForm } from '@/features/settings/components/AddServerForm';
import { ServerCapabilityHeaderBadge } from '@/features/settings/components/ServerCapabilityHeaderBadge';
import { useListReorderDnd } from '@/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { tooltipAttrs } from '@/ui/tooltipAttrs';
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
/** Row visibility: same as main — hide only when manual strategy proves the feature absent. */
function showAudiomuseRow(resolved: ResolvedCapability | null): boolean {
if (!resolved || resolved.strategyId === null || resolved.status === 'ineligible') return false;
return !(resolved.activation === 'manual' && resolved.status === 'absent');
}
/** Legacy Navidrome (< 0.62): manual toggle row below the card (not the auto header badge). */
function showLegacyAudiomuseToggleRow(
identity: SubsonicServerIdentity | undefined,
instantMixProbe: InstantMixProbeResult | undefined,
resolved: ResolvedCapability | null,
): boolean {
const ctx = buildCapabilityContext(identity);
if (ctx.isNavidrome && ctx.semverGte([0, 62, 0])) return false;
if (showAudiomuseRow(resolved)) return true;
return isNavidromeAudiomuseSoftwareEligible(identity) && instantMixProbe !== 'empty';
}
export function ServersTab({
initialInvite,
}: {
initialInvite: ServerMagicPayload | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const auth = useAuthStore();
const librarySync = useLibraryIndexSync();
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState<boolean>(initialInvite != null);
const [editingServerId, setEditingServerId] = useState<string | null>(null);
const [pastedServerInvite, setPastedServerInvite] = useState<ServerMagicPayload | null>(initialInvite);
const serversRef = useRef(auth.servers);
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
// eslint-disable-next-line react-hooks/refs
serversRef.current = auth.servers;
const addServerInviteAnchorRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!showAddForm || !pastedServerInvite) return;
addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [showAddForm, pastedServerInvite]);
// Pick up later invites that arrive via the parent route handler while
// ServersTab is already mounted (initial mount is handled via useState).
useEffect(() => {
if (initialInvite) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPastedServerInvite(initialInvite);
setShowAddForm(true);
}
}, [initialInvite]);
const applyServerReorder = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const next = applyListReorderById(serversRef.current, draggedId, target);
if (next) auth.setServers(next);
}, [auth]);
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({
type: 'server_reorder',
apply: applyServerReorder,
});
const testConnection = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
try {
// Dual-address: probe through the connect layer so the test reflects
// whichever endpoint the app would actually use right now (LAN at home,
// public elsewhere). probe.baseUrl also feeds the AudioMuse probe so
// that one hits the same endpoint.
const probe = await ensureConnectUrlResolved(server);
if (probe.ok) {
const identity = {
type: probe.ping.type,
serverVersion: probe.ping.serverVersion,
openSubsonic: probe.ping.openSubsonic,
};
auth.setSubsonicServerIdentity(server.id, identity);
scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity, true);
}
setConnStatus(s => ({ ...s, [server.id]: probe.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' }));
// Auf der Servers-Seite bleiben, damit der User seinen Switch hier
// sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge).
} else {
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
}
};
const deleteServer = async (server: ServerProfile) => {
if (!confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) {
return;
}
// §5.6: when a local library index exists for this server, let the
// user keep the cached rows (offline use) or delete them. OK =
// delete the cache, Cancel = keep it.
const hadIndex = useLibraryIndexStore.getState().isIndexEnabled(server.id);
const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary'));
auth.removeServer(server.id);
try {
await clearServerHttpContext(server);
await librarySyncClearSession(server.id);
if (purgeLibrary) {
await libraryDeleteServerData(server.id);
}
} catch {
/* best-effort — server already removed from the store */
}
};
const closeAddServerForm = () => {
setShowAddForm(false);
setPastedServerInvite(null);
};
/**
* Surface a dual-address verify failure as a toast (mismatch /
* insufficient / unreachable). Returns true when the result is `ok` and
* the caller should proceed; false when the user must fix something
* before save.
*/
const announceVerifyResult = (result: VerifySameServerResult): boolean => {
if (result.ok) return true;
if (result.reason === 'unreachable') {
showToast(
t('settings.dualAddressUnreachable', { host: result.unreachableHost ?? '' }),
6000,
'error',
);
} else if (result.reason === 'mismatch') {
showToast(t('settings.dualAddressMismatch'), 6000, 'error');
} else {
showToast(t('settings.dualAddressInsufficient'), 6000, 'error');
}
return false;
};
const handleAddServer = async (data: Omit<ServerProfile, 'id'>) => {
setShowAddForm(false);
setPastedServerInvite(null);
const tempId = '_new';
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
try {
// Dual-address: confirm both addresses point at the same server
// before persisting anything. Single-address adds skip verify and go
// straight to the legacy ping (which is also the connect-test).
if (data.alternateUrl) {
const verify = await verifySameServerEndpoints(
{
url: data.url,
alternateUrl: data.alternateUrl,
customHeaders: data.customHeaders,
customHeadersApplyTo: data.customHeadersApplyTo,
},
data.username,
data.password,
);
if (!announceVerifyResult(verify)) {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
return;
}
}
const ping = await pingWithCredentialsForProfile(data, data.url);
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, true);
setConnStatus(s => ({ ...s, [id]: 'ok' }));
const added = useAuthStore.getState().servers.find(s => s.id === id);
if (added) {
void syncServerHttpContextForProfile(added);
void bootstrapIndexedServer(added);
}
} else {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
}
} catch {
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
}
};
// Edit normally saves unconditionally — ping result becomes a post-save
// status indicator (analog zum existing Test-Button) rather than blocking
// the save. Lets users update a profile even when the server is currently
// unreachable.
//
// **Dual-address exception:** when the edit introduces or changes the
// second address (or changes the primary url while a second address is
// already saved), verify both addresses are the same server *before*
// persisting. A mismatch here would silently bind library / cover / queue
// data to two unrelated boxes — the spec blocks save in v1.
const handleEditServer = async (id: string, data: Omit<ServerProfile, 'id'>) => {
const previous = auth.servers.find(s => s.id === id);
// URL-change remigration — runs BEFORE everything else when the edit
// changes the derived index key. User confirms first; on failure the
// edit is aborted with a stage-specific toast. Spec §8.
const remap = previous ? indexKeyRemapForUrlChange(previous, data) : null;
if (remap) {
const confirmed = await useConfirmModalStore.getState().request({
title: t('settings.urlRemigrationTitle'),
message: t('settings.urlRemigrationMessage', {
oldKey: remap.oldKey,
newKey: remap.newKey,
}),
confirmLabel: t('settings.urlRemigrationConfirm'),
cancelLabel: t('common.cancel'),
danger: true,
});
if (!confirmed) return;
setConnStatus(s => ({ ...s, [id]: 'testing' }));
const result = await runIndexKeyRemigration(remap);
if (!result.ok) {
const failureKey =
result.failure.stage === 'inspect'
? 'settings.urlRemigrationFailureInspect'
: result.failure.stage === 'run'
? 'settings.urlRemigrationFailureRun'
: 'settings.urlRemigrationFailureCoverRename';
showToast(t(failureKey), 8000, 'error');
setConnStatus(s => ({ ...s, [id]: 'error' }));
return;
}
}
const dualAddressChanged =
data.alternateUrl != null &&
data.alternateUrl !== '' &&
(data.alternateUrl !== previous?.alternateUrl ||
data.url !== previous?.url ||
data.username !== previous?.username ||
data.password !== previous?.password);
if (dualAddressChanged) {
setConnStatus(s => ({ ...s, [id]: 'testing' }));
const verify = await verifySameServerEndpoints(
{
url: data.url,
alternateUrl: data.alternateUrl,
customHeaders: data.customHeaders,
customHeadersApplyTo: data.customHeadersApplyTo,
},
data.username,
data.password,
);
if (!announceVerifyResult(verify)) {
setConnStatus(s => ({ ...s, [id]: 'error' }));
return;
}
}
setEditingServerId(null);
auth.updateServer(id, data);
const updated = useAuthStore.getState().servers.find(s => s.id === id);
if (updated) void syncServerHttpContextForProfile(updated);
// Profile edited → any cached sticky connect URL for this id may now be
invalidateReachableEndpointCache(id);
setConnStatus(s => ({ ...s, [id]: 'testing' }));
try {
const ping = await pingWithCredentialsForProfile(data, data.url);
if (ping.ok) {
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
auth.setSubsonicServerIdentity(id, identity);
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
}
setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' }));
} catch {
setConnStatus(s => ({ ...s, [id]: 'error' }));
}
};
const handleLogout = () => {
auth.logout();
navigate('/login');
};
return (
<>
<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
ref={setContainer}
onMouseMove={onMouseMove}
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
>
{auth.servers.map((srv) => {
if (editingServerId === srv.id) {
return (
<AddServerForm
key={srv.id}
editingServer={srv}
onSave={(data) => handleEditServer(srv.id, data)}
onCancel={() => setEditingServerId(null)}
onDelete={async () => {
await deleteServer(srv);
setEditingServerId(null);
}}
/>
);
}
const isActive = srv.id === auth.activeServerId;
const status = connStatus[srv.id];
const dropEdgeKind = isDragging ? dropEdge(srv.id) : null;
const isBefore = dropEdgeKind === 'before';
const isAfter = dropEdgeKind === 'after';
const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]);
const serverIdentity = auth.subsonicServerIdentityByServer[srv.id];
const resolvedAudiomuse = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS);
const versionTooltip = serverSoftware ?? t('settings.serverVersionUnknown');
const audiomuseActive = isFeatureActiveForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS);
const showLegacyAudiomuseToggle = showLegacyAudiomuseToggleRow(
serverIdentity,
auth.instantMixProbeByServer[srv.id],
resolvedAudiomuse,
);
return (
<div
key={srv.id}
data-reorder-id={srv.id}
className="settings-card"
style={{
border: isActive ? '1px solid var(--accent)' : undefined,
background: isActive ? 'color-mix(in srgb, var(--accent) 10%, var(--bg-card))' : undefined,
// Drop-target indicator via inset shadow rather than
// borderTop/borderBottom: mixing the `border` shorthand with
// border-side longhands in one inline style object makes React
// clear the unset sides, which drops the top/bottom border on
// the active card (only left/right remain).
boxShadow: isBefore
? 'inset 0 2px 0 0 var(--accent)'
: isAfter
? 'inset 0 -2px 0 0 var(--accent)'
: undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'stretch', gap: '0.75rem' }}>
<ReorderGripHandle id={srv.id} type="server_reorder" label={serverListDisplayLabel(srv, auth.servers)} />
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '2px', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>{serverSettingsEntryTitle(srv)}</span>
<ServerCapabilityHeaderBadge
serverId={srv.id}
feature={FEATURE_AUDIOMUSE_SIMILAR_TRACKS}
/>
{resolvedAudiomuse?.activation === 'auto' && audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
data-tooltip={t('settings.audiomuseIssueHint')}
aria-label={t('settings.audiomuseIssueHint')}
/>
)}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{srv.url.startsWith('https://') && (
<Lock size={10} style={{ color: 'var(--positive)', flexShrink: 0 }} aria-hidden />
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{serverIdentityLabel(srv)}
</span>
<button
type="button"
className="btn btn-ghost settings-server-version-info-btn"
{...tooltipAttrs(versionTooltip, { click: true })}
>
<Info size={12} aria-hidden />
</button>
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0, alignItems: 'center', marginLeft: 'auto' }}>
{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-ghost"
style={{ padding: '4px 8px' }}
onClick={() => {
setShowAddForm(false);
setPastedServerInvite(null);
setEditingServerId(srv.id);
}}
data-tooltip={t('settings.editServer')}
id={`settings-edit-server-${srv.id}`}
>
<Pencil size={14} />
</button>
<button
className="btn btn-surface"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => testConnection(srv)}
disabled={status === 'testing'}
data-tooltip={t('settings.testBtn')}
aria-label={t('settings.testBtn')}
>
<Wifi size={13} />
<span className="server-card-btn-label">{t('settings.testBtn')}</span>
</button>
{isActive ? (
<span className="settings-server-inline-badge settings-server-inline-badge--positive settings-server-use-active-slot">
{t('settings.serverActive')}
</span>
) : (
<button
className="btn btn-primary settings-server-use-active-slot"
style={{ fontSize: 12, padding: '4px 10px' }}
onClick={() => switchToServer(srv)}
disabled={status === 'testing'}
id={`settings-use-server-${srv.id}`}
data-tooltip={t('settings.useServer')}
aria-label={t('settings.useServer')}
>
<Power size={13} />
<span className="server-card-btn-label">{t('settings.useServer')}</span>
</button>
)}
</div>
</div>
</div>
<ServerLibraryIndexControls
status={librarySync.statusByServer[serverIndexKeyForProfile(srv)] ?? null}
connection={librarySync.connectionByServer[serverIndexKeyForProfile(srv)] ?? 'unknown'}
progressLabel={librarySync.progressByServer[serverIndexKeyForProfile(srv)] ?? null}
busy={librarySync.busyServerId === serverIndexKeyForProfile(srv)}
actionsDisabled={librarySync.globalBusy && librarySync.busyServerId !== serverIndexKeyForProfile(srv)}
onFullSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'full')}
onDeltaSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'delta')}
onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')}
onCancel={() => void librarySync.handleCancel()}
/>
{(() => {
if (!showLegacyAudiomuseToggle) return null;
const audiomuseManualActive = !!auth.audiomuseNavidromeByServer[srv.id];
return (
<div
className="settings-toggle-row"
data-settings-search={t('settings.audiomuseTitle')}
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
<Sparkles size={16} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{t('settings.audiomuseTitle')}
{audiomuseManualActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
data-tooltip={t('settings.audiomuseIssueHint')}
aria-label={t('settings.audiomuseIssueHint')}
/>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
<Trans
i18nKey="settings.audiomuseDesc"
components={{
pluginLink: (
<a
href={AUDIOMUSE_NV_PLUGIN_URL}
onClick={e => {
e.preventDefault();
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
}}
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
/>
),
}}
/>
</div>
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
<input
type="checkbox"
checked={audiomuseManualActive}
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
);
})()}
</div>
);
})}
</div>
)}
<div
ref={addServerInviteAnchorRef}
id="settings-add-server-anchor"
style={{ scrollMarginTop: '12px' }}
>
{showAddForm ? (
<AddServerForm
initialInvite={pastedServerInvite}
onSave={handleAddServer}
onCancel={closeAddServerForm}
/>
) : (
<button
className="btn btn-surface"
style={{ marginTop: '0.75rem' }}
onClick={() => {
setPastedServerInvite(null);
setShowAddForm(true);
}}
id="settings-add-server-btn"
>
<Plus size={16} /> {t('settings.addServer')}
</button>
)}
</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>
</>
);
}
@@ -0,0 +1,54 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
/** Accent uppercase header. Omit for a plain boxed panel (no header) —
* used when the surrounding SettingsSubSection already names the group. */
title?: string;
/** Optional accent-coloured icon shown before the title (e.g. on the flat
* Themes sections). Ignored when `title` is omitted. */
icon?: React.ReactNode;
/** Optional one-line description shown under the title. */
desc?: string;
/** Show an "Advanced" badge after the title (e.g. for a group that only
* renders in Advanced Mode). Ignored when `title` is omitted. */
advanced?: boolean;
/** Optional right-aligned node in the title row (e.g. a reset button).
* Ignored when `title` is omitted. */
action?: React.ReactNode;
children: React.ReactNode;
}
/**
* Boxed settings sub-section — a bordered panel (optionally with an accent
* uppercase header) that sets a group of related controls apart inside a
* settings card. Wraps the `.settings-group` styles so the look stays
* consistent everywhere it is used (Audio, Appearance, Library, …).
*/
export function SettingsGroup({ title, icon, desc, advanced, action, children }: Props) {
const { t } = useTranslation();
return (
<div className="settings-group">
{title && (
<div className="settings-group-title">
{icon && <span className="settings-group-title-icon">{icon}</span>}
{title}
{(advanced || action) && (
<span className="settings-group-title-end">
{advanced && (
<span className="settings-sub-section-advanced-badge">
{t('settings.advancedBadge')}
</span>
)}
{action}
</span>
)}
</div>
)}
<div className="settings-group-body">
{desc && <div className="settings-group-desc">{desc}</div>}
{children}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
import type { CSSProperties } from 'react';
export interface SegmentedOption<T extends string> {
id: T;
label: string;
/** Disables this single option while leaving the rest selectable. */
disabled?: boolean;
}
interface Props<T extends string> {
options: SegmentedOption<T>[];
value: T;
onChange: (id: T) => void;
/** Disables the whole control (e.g. an Orbit guest mirroring the host). */
disabled?: boolean;
/** Extra class appended to the `settings-segmented` wrapper. */
className?: string;
/** Inline style on the wrapper (e.g. the dimmed host-controlled state). */
style?: CSSProperties;
}
/**
* Shared `settings-segmented` picker: a row of mutually-exclusive pill buttons
* where exactly one is active (`btn-primary`) and the rest are `btn-ghost`. The
* canonical replacement for stacks of mutually-exclusive toggles, which falsely
* read as "you can turn several on" — see the Track-transitions section for the
* reference look.
*
* Scope is the segmented control only; callers render any per-option detail
* (sliders, descriptions, sub-cards) below it themselves.
*/
export function SettingsSegmented<T extends string>({
options,
value,
onChange,
disabled,
className,
style,
}: Props<T>) {
return (
<div className={className ? `settings-segmented ${className}` : 'settings-segmented'} style={style}>
{options.map(opt => (
<button
key={opt.id}
type="button"
className={`btn ${value === opt.id ? 'btn-primary' : 'btn-ghost'}`}
disabled={disabled || opt.disabled}
onClick={() => onChange(opt.id)}
>
{opt.label}
</button>
))}
</div>
);
}
@@ -0,0 +1,104 @@
import React from 'react';
/**
* Settings sub-card primitives — the canonical way to render per-mode / detail
* controls as a set-apart group inside a settings section (e.g. the "Target
* LUFS" box under the Normalization engine picker). The Audio tab's first four
* sections are the reference for this look.
*
* These wrap the shared `settings-norm-*` styles so callers never hand-roll the
* box with inline padding or one-off classes. New settings sub-options should
* use these instead of bare `<div>`s — see `NormalizationBlock` for usage.
*/
/** Bordered, accent-tinted panel that groups detail controls inside a section. */
export function SettingsSubCard({
children,
style,
className,
}: {
children: React.ReactNode;
style?: React.CSSProperties;
className?: string;
}) {
return (
<div className={`settings-norm-block${className ? ` ${className}` : ''}`} style={style}>
{children}
</div>
);
}
/**
* One labelled group inside a {@link SettingsSubCard}: an optional title and
* description, the controls, and an optional grey note line below them.
*
* `row` lays the label out beside the controls (for a slider/value pair, like
* the Normalization sliders); the default stacks label → description →
* controls (for a picker, like the Hi-Res blend-rate buttons).
*/
export function SettingsField({
label,
desc,
note,
row = false,
children,
}: {
label?: React.ReactNode;
desc?: React.ReactNode;
note?: React.ReactNode;
row?: boolean;
children?: React.ReactNode;
}) {
return (
<div className="settings-norm-field">
{row ? (
<>
<div className="settings-norm-row">
{label != null && <span className="settings-norm-label">{label}</span>}
{children}
</div>
{desc != null && <div className="settings-norm-help">{desc}</div>}
</>
) : (
<>
{label != null && (
<span className="settings-norm-label" style={{ minWidth: 0 }}>
{label}
</span>
)}
{desc != null && <div className="settings-norm-help">{desc}</div>}
{children}
</>
)}
{note != null && (
<div className="settings-norm-help" role="note">
{note}
</div>
)}
</div>
);
}
/**
* A horizontal control row (label and/or control + value side by side), for a
* standalone slider inside a {@link SettingsSubCard} or a nested slider/value
* pair inside a stacked {@link SettingsField}. For a labelled field whose
* description sits below the row, prefer `<SettingsField row …>`.
*/
export function SettingsRow({ children }: { children: React.ReactNode }) {
return <div className="settings-norm-row">{children}</div>;
}
/** Right-aligned, tabular value readout for a slider row. */
export function SettingsValue({ children }: { children: React.ReactNode }) {
return <span className="settings-norm-value">{children}</span>;
}
/**
* Accent callout (border-left + tinted background) for an important caveat
* inside a {@link SettingsSubCard} — e.g. the Normalization first-play note.
* For a plain grey note line, use the `note` prop on {@link SettingsField}.
*/
export function SettingsCallout({ children }: { children: React.ReactNode }) {
return <div className="settings-norm-note">{children}</div>;
}
@@ -0,0 +1,76 @@
import React, { useId } from 'react';
import { ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
interface SettingsSubSectionProps {
title: string;
icon?: React.ReactNode;
defaultOpen?: boolean;
description?: string;
searchText?: string;
// Rechts im Summary neben dem Chevron (z.B. Reset-Button). Clicks werden
// gestoppt, damit sie das Accordion nicht togglen.
action?: React.ReactNode;
/** Hidden unless the global Advanced Mode toggle is on; renders a small badge when visible. */
advanced?: boolean;
children: React.ReactNode;
}
// Wird innerhalb eines Settings-Tabs als Accordion-Gruppe genutzt. Natives
// <details> liefert Keyboard + ARIA gratis; der CSS-Stil setzt den Chevron
// im Summary mittels [open]-Selektor.
export default function SettingsSubSection({
title,
icon,
defaultOpen = false,
description,
searchText,
action,
advanced = false,
children,
}: SettingsSubSectionProps) {
const { t } = useTranslation();
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
const headingId = useId();
if (advanced && !advancedSettingsEnabled) return null;
return (
<details
className="settings-sub-section"
data-settings-search={searchText ?? title}
data-advanced={advanced ? 'true' : undefined}
open={defaultOpen}
>
<summary
className="settings-sub-section-summary"
aria-labelledby={headingId}
>
{icon && <span className="settings-sub-section-icon">{icon}</span>}
<span id={headingId} className="settings-sub-section-title">{title}</span>
{advanced && (
<span className="settings-sub-section-advanced-badge" aria-hidden="true">
{t('settings.advancedBadge')}
</span>
)}
{action && (
<span
className="settings-sub-section-action"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{action}
</span>
)}
<ChevronDown size={16} className="settings-sub-section-chevron" aria-hidden="true" />
</summary>
{description && (
<p className="settings-sub-section-desc">{description}</p>
)}
<div className="settings-sub-section-content">
{children}
</div>
</details>
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
interface Props {
/** Bold label. Omit for a desc-only row whose title is the enclosing group
* header — pass `ariaLabel` then so the switch keeps an accessible name. */
label?: string;
/** Muted description under the label (string, or JSX for inline links). */
desc?: ReactNode;
/** Bold secondary note under the description (e.g. a requirement hint). */
note?: ReactNode;
checked: boolean;
onChange: (checked: boolean) => void;
/** Dims the row and blocks interaction (e.g. mutually-exclusive options). */
disabled?: boolean;
/** Overrides the toggle's accessible name when it should differ from the label. */
ariaLabel?: string;
/** Indexes the row for the settings search (data-settings-search). */
searchText?: string;
/** Forwarded to the checkbox input (e.g. a tour/onboarding anchor). */
id?: string;
}
/**
* Standard settings toggle row — a label/description on the left and a switch
* on the right. Centralises the markup repeated across every settings tab so
* sections only describe what they toggle, not how a toggle row looks.
*/
export function SettingsToggle({ label, desc, note, checked, onChange, disabled, ariaLabel, searchText, id }: Props) {
return (
<div className="settings-toggle-row" data-settings-search={searchText} style={disabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
{label && <div style={{ fontWeight: 500 }}>{label}</div>}
{desc && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{desc}</div>}
{note && <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{note}</div>}
</div>
<label className="toggle-switch" aria-label={ariaLabel ?? label}>
<input type="checkbox" id={id} checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
);
}
@@ -0,0 +1,121 @@
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '@/features/sidebar';
import { useLuckyMixAvailable } from '@/hooks/useLuckyMixAvailable';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import { applySidebarReorderById } from '@/features/sidebar';
import { useListReorderDnd } from '@/hooks/useListReorderDnd';
import type { ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
const REORDER_TYPE = 'sidebar_reorder';
export function SidebarCustomizer() {
const { t } = useTranslation();
const { items, setItems, toggleItem } = useSidebarStore();
const itemsRef = useRef(items);
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
// eslint-disable-next-line react-hooks/refs
itemsRef.current = items;
const randomNavMode = useAuthStore(s => s.randomNavMode);
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop);
const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop);
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
const setShowLuckyMixMenu = useAuthStore(s => s.setShowLuckyMixMenu);
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const libraryItems = items.filter(cfg => {
if (CONSERVED_SIDEBAR_NAV_IDS.has(cfg.id)) return false;
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
return true;
});
const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system');
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
const section = ALL_NAV_ITEMS[draggedId]?.section;
if (section !== 'library' && section !== 'system') return;
const next = applySidebarReorderById(itemsRef.current, section, draggedId, target);
if (next) setItems(next);
}, [setItems]);
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
const renderRow = (cfg: SidebarItemConfig, section: 'library' | 'system') => {
const meta = ALL_NAV_ITEMS[cfg.id];
if (!meta) return null;
const Icon = meta.icon;
const edge = isDragging ? dropEdge(cfg.id) : null;
return (
<div
key={cfg.id}
data-reorder-id={cfg.id}
data-reorder-section={section}
className="sidebar-customizer-row"
style={{
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
}}
>
<ReorderGripHandle id={cfg.id} type={REORDER_TYPE} 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 (
<>
<SettingsGroup>
<SettingsToggle
label={t('settings.randomNavSplitTitle')}
desc={t('settings.randomNavSplitDesc')}
checked={randomNavMode === 'separate'}
onChange={c => setRandomNavMode(c ? 'separate' : 'hub')}
/>
<SettingsToggle
label={t('settings.nowPlayingTopTitle')}
desc={t('settings.nowPlayingTopDesc')}
searchText={t('settings.nowPlayingTopTitle')}
checked={nowPlayingAtTop}
onChange={setNowPlayingAtTop}
/>
<SettingsToggle
label={t('settings.luckyMixMenuTitle')}
desc={t('settings.luckyMixMenuDesc')}
checked={showLuckyMixMenu}
onChange={setShowLuckyMixMenu}
/>
</SettingsGroup>
<SettingsGroup>
<div ref={setContainer} onMouseMove={onMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Library block */}
<div style={{ padding: '4px 0' }}>
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
{libraryItems.map(cfg => renderRow(cfg, 'library'))}
</div>
{/* System block */}
<div style={{ padding: '4px 0' }}>
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
{systemItems.map(cfg => renderRow(cfg, 'system'))}
<div className="sidebar-customizer-fixed-hint">
<span>{t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}</span>
</div>
</div>
</div>
</SettingsGroup>
</>
);
}
@@ -0,0 +1,218 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { Download, FolderOpen, Trash2, X } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { countHotCacheTracks } from '@/store/hotCacheStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { formatBytes, snapHotCacheMb } from '@/utils/format/formatBytes';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsValue } from '@/features/settings/components/SettingsSubCard';
import CoverCacheStrategySection from '@/features/settings/components/CoverCacheStrategySection';
export function StorageTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const clearHotCacheDisk = useLocalPlaybackStore(s => s.purgeEphemeralDisk);
const localPlaybackEntries = useLocalPlaybackStore(s => s.entries);
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
const mediaDir = auth.mediaDir || null;
/** Match ephemeral disk usage (all servers); resolve UUID vs URL index keys. */
const hotCacheTrackCount = useMemo(
() => countHotCacheTracks(localPlaybackEntries),
[localPlaybackEntries],
);
const refreshHotCacheSize = useCallback(() => {
invoke<number>('get_media_tier_size', { tier: 'ephemeral', mediaDir })
.then(setHotCacheBytes)
.catch(() => setHotCacheBytes(0));
}, [mediaDir]);
useEffect(() => {
refreshHotCacheSize();
}, [refreshHotCacheSize]);
useEffect(() => {
if (!auth.hotCacheEnabled) return;
refreshHotCacheSize();
const interval = window.setInterval(refreshHotCacheSize, 15_000);
return () => window.clearInterval(interval);
}, [auth.hotCacheEnabled, refreshHotCacheSize]);
useEffect(() => {
if (!auth.hotCacheEnabled) return;
const handle = window.setTimeout(refreshHotCacheSize, 400);
return () => window.clearTimeout(handle);
}, [localPlaybackEntries, auth.hotCacheEnabled, refreshHotCacheSize]);
const pickMediaDir = async () => {
const selected = await openDialog({
directory: true,
multiple: false,
title: t('settings.mediaDirChange'),
});
if (selected && typeof selected === 'string') {
auth.setMediaDir(selected);
refreshHotCacheSize();
}
};
const pickDownloadFolder = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
if (selected && typeof selected === 'string') {
auth.setDownloadFolder(selected);
}
};
return (
<>
<SettingsSubSection
title={t('settings.mediaDirTitle')}
icon={<FolderOpen size={16} />}
>
<div className="settings-card">
<SettingsGroup desc={t('settings.mediaDirDesc')}>
<SettingsSubCard>
<SettingsField note={auth.mediaDir ? t('settings.mediaDirHint') : undefined}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.mediaDir || t('settings.mediaDirDefault')}
style={{ flex: 1, fontSize: 13, color: auth.mediaDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.mediaDir && (
<button
className="btn btn-ghost"
onClick={() => { auth.setMediaDir(''); refreshHotCacheSize(); }}
data-tooltip={t('settings.mediaDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button className="btn btn-surface" onClick={pickMediaDir} style={{ flexShrink: 0 }}>
<FolderOpen size={16} /> {t('settings.mediaDirChange')}
</button>
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<CoverCacheStrategySection />
<SettingsSubSection
title={t('settings.nextTrackBufferingTitle')}
icon={<Download size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsToggle
label={t('settings.hotCacheTitle')}
desc={t('settings.hotCacheDisclaimer')}
ariaLabel={t('settings.hotCacheEnabled')}
id="hot-cache-enabled-toggle"
checked={auth.hotCacheEnabled}
onChange={async enabled => {
if (!enabled) {
await clearHotCacheDisk(mediaDir);
setHotCacheBytes(0);
auth.setHotCacheEnabled(false);
} else {
auth.setHotCacheEnabled(true);
refreshHotCacheSize();
}
}}
/>
{auth.hotCacheEnabled && (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField>
<div style={{ fontSize: 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>
</SettingsField>
<SettingsField label={t('settings.hotCacheMaxMb')} row>
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} id="hot-cache-max-mb-slider" />
<SettingsValue>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</SettingsValue>
</SettingsField>
<SettingsField label={t('settings.hotCacheDebounce')} row>
<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))} id="hot-cache-debounce-slider" />
<SettingsValue>
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
? t('settings.hotCacheDebounceImmediate')
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
</SettingsValue>
</SettingsField>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13, alignSelf: 'flex-start' }}
onClick={async () => {
await clearHotCacheDisk(mediaDir);
refreshHotCacheSize();
}}
>
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
</button>
</SettingsSubCard>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.downloadsTitle')}
icon={<FolderOpen size={16} />}
>
<div className="settings-card">
<SettingsGroup desc={t('settings.downloadsFolderDesc')}>
<SettingsSubCard>
<SettingsField>
<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>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,338 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { AppWindow, ChevronDown, Download, ExternalLink, Globe, HardDrive, Info, Scale, Sliders, Users } from 'lucide-react';
import { version as appVersion } from '@/../package.json';
import i18n from '@/i18n';
import { useAuthStore } from '@/store/authStore';
import type { ClockFormat, LinuxWaylandTextRenderProfile, LoggingMode } from '@/store/authStoreTypes';
import { IS_LINUX } from '@/utils/platform';
import { showToast } from '@/utils/ui/toast';
import { AboutPsysonicBrandHeader } from '@/components/AboutPsysonicLol';
import CustomSelect from '@/ui/CustomSelect';
import LicensesPanel from '@/components/LicensesPanel';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
import { BackupSection } from '@/features/settings/components/BackupSection';
import { CONTRIBUTORS, MAINTAINERS } from '@/config/settingsCredits';
export function SystemTab() {
const { t } = useTranslation();
const navigate = useNavigate();
const auth = useAuthStore();
const [waylandTextRenderAvailable, setWaylandTextRenderAvailable] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
invoke<boolean>('linux_wayland_text_render_settings_available')
.then(setWaylandTextRenderAvailable)
.catch(() => {});
}, []);
const exportRuntimeLogs = async () => {
const suggestedName = `psysonic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.log`;
const selected = await saveDialog({
defaultPath: suggestedName,
filters: [{ name: 'Log files', extensions: ['log', 'txt'] }],
title: t('settings.loggingExport'),
});
if (!selected || Array.isArray(selected)) return;
try {
const lines = await invoke<number>('export_runtime_logs', { path: selected });
showToast(t('settings.loggingExportSuccess', { count: lines }), 3500, 'info');
} catch (e) {
console.error(e);
showToast(t('settings.loggingExportError'), 4500, 'error');
}
};
return (
<>
<SettingsSubSection
title={t('settings.language')}
icon={<Globe size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField>
<CustomSelect
value={i18n.language}
onChange={v => 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') },
{ value: 'ro', label: t('settings.languageRo') },
{ value: 'ja', label: t('settings.languageJa') },
{ value: 'hu', label: t('settings.languageHu') },
{ value: 'pl', label: t('settings.languagePl') },
]}
/>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
{/* App-Verhalten (aus altem library/general Behavior-Block) */}
<SettingsSubSection
title={t('settings.behavior')}
icon={<AppWindow size={16} />}
>
<div className="settings-card">
<SettingsGroup title={t('settings.groupTray')}>
<SettingsToggle
label={t('settings.showTrayIcon')}
desc={t('settings.showTrayIconDesc')}
checked={auth.showTrayIcon}
onChange={auth.setShowTrayIcon}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.minimizeToTray')}
desc={t('settings.minimizeToTrayDesc')}
checked={auth.minimizeToTray}
onChange={auth.setMinimizeToTray}
/>
</SettingsGroup>
{IS_LINUX && (
<SettingsGroup title={t('settings.groupLinuxRendering')}>
<SettingsToggle
label={t('settings.linuxWebkitSmoothScroll')}
desc={t('settings.linuxWebkitSmoothScrollDesc')}
checked={auth.linuxWebkitKineticScroll}
onChange={auth.setLinuxWebkitKineticScroll}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.linuxWebkitInputForceRepaint')}
desc={t('settings.linuxWebkitInputForceRepaintDesc')}
checked={auth.linuxWebkitInputForceRepaint}
onChange={auth.setLinuxWebkitInputForceRepaint}
/>
{waylandTextRenderAvailable && (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField
label={t('settings.linuxWaylandTextRender')}
desc={t('settings.linuxWaylandTextRenderDesc')}
>
<CustomSelect
value={auth.linuxWaylandTextRenderProfile}
onChange={v => auth.setLinuxWaylandTextRenderProfile(v as LinuxWaylandTextRenderProfile)}
options={[
{ value: 'balanced', label: t('settings.linuxWaylandTextRenderBalanced') },
{ value: 'sharp', label: t('settings.linuxWaylandTextRenderSharp') },
{ value: 'gpu', label: t('settings.linuxWaylandTextRenderGpu') },
{ value: 'minimal', label: t('settings.linuxWaylandTextRenderMinimal') },
]}
/>
</SettingsField>
</SettingsSubCard>
)}
</SettingsGroup>
)}
<SettingsGroup title={t('settings.groupClock')}>
<SettingsSubCard>
<SettingsField label={t('settings.clockFormat')} desc={t('settings.clockFormatDesc')} row>
<div style={{ minWidth: 160 }}>
<CustomSelect
value={auth.clockFormat}
onChange={(v) => auth.setClockFormat(v as ClockFormat)}
options={[
{ value: 'auto', label: t('settings.clockFormatAuto') },
{ value: '24h', label: t('settings.clockFormatTwentyFour') },
{ value: '12h', label: t('settings.clockFormatTwelve') },
]}
/>
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.backupTitle')}
icon={<HardDrive size={16} />}
>
<BackupSection />
</SettingsSubSection>
<SettingsSubSection
title={t('settings.loggingTitle')}
icon={<Sliders size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField desc={t('settings.loggingModeDesc')}>
<CustomSelect
value={auth.loggingMode}
onChange={(v) => auth.setLoggingMode(v as LoggingMode)}
options={[
{ value: 'off', label: t('settings.loggingModeOff') },
{ value: 'normal', label: t('settings.loggingModeNormal') },
{ value: 'debug', label: t('settings.loggingModeDebug') },
]}
/>
{auth.loggingMode === 'debug' && (
<div>
<button className="btn btn-surface" onClick={exportRuntimeLogs}>
<Download size={14} />
{t('settings.loggingExport')}
</button>
</div>
)}
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.aboutTitle')}
icon={<Info size={16} />}
>
<div className="settings-card settings-about">
<AboutPsysonicBrandHeader appVersion={appVersion} aboutVersionLabel={t('settings.aboutVersion')} />
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6, margin: '1rem 0 0.5rem' }}>
{t('settings.aboutDesc')}
</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', alignItems: 'center' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0 }}>{t('settings.aboutMaintainersLabel')}</span>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{MAINTAINERS.map(m => (
<div key={m.github} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<img
src={`https://github.com/${m.github}.png?size=32`}
width={20} height={20}
style={{ borderRadius: '50%', flexShrink: 0 }}
alt={m.github}
/>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
onClick={() => openUrl(`https://github.com/${m.github}`)}
>
@{m.github}
</button>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutReleaseNotesLabel')}</span>
<button
onClick={() => {
useAuthStore.getState().setLastSeenChangelogVersion('');
navigate('/whats-new');
}}
style={{ color: 'var(--accent)', background: 'none', border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left' }}
>
{t('settings.aboutReleaseNotesLink')}
</button>
</div>
</div>
<div className="settings-section-divider" style={{ marginTop: '1.25rem' }} />
<SettingsToggle
label={t('settings.showChangelogOnUpdate')}
desc={t('settings.showChangelogOnUpdateDesc')}
checked={auth.showChangelogOnUpdate}
onChange={auth.setShowChangelogOnUpdate}
/>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
<button
className="btn btn-ghost"
style={{ alignSelf: 'flex-start' }}
onClick={() => openUrl('https://github.com/Psychotoxical/psysonic')}
>
<ExternalLink size={14} />
{t('settings.aboutRepo')}
</button>
</div>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.aboutContributorsLabel')}
icon={<Users size={16} />}
>
<div className="contributors-grid">
{CONTRIBUTORS.map(c => (
<details key={c.github} className="contributor-card">
<summary className="contributor-card-summary">
<img
src={`https://github.com/${c.github}.png?size=48`}
width={32}
height={32}
className="contributor-card-avatar"
alt={c.github}
/>
<div className="contributor-card-meta">
<span
className="contributor-card-name"
role="button"
tabIndex={0}
onClick={e => { e.stopPropagation(); openUrl(`https://github.com/${c.github}`); }}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
openUrl(`https://github.com/${c.github}`);
}
}}
>
@{c.github}
</span>
<span className="contributor-card-sub">
<span className="contributor-card-since">v{c.since}</span>
<span>·</span>
<span>{t('settings.aboutContributorsCount', { count: c.contributions.length })}</span>
</span>
</div>
<ChevronDown size={14} className="contributor-card-chevron" aria-hidden />
</summary>
<ul className="contributor-card-list">
{c.contributions.map(item => <li key={item}>{item}</li>)}
</ul>
</details>
))}
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('licenses.title')}
icon={<Scale size={16} />}
>
<LicensesPanel />
</SettingsSubSection>
</>
);
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import { Upload, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import { validateThemePackage, type ValidatedTheme } from '@/utils/themes/validateThemePackage';
import { showToast } from '@/utils/ui/toast';
import ConfirmModal from '@/components/ConfirmModal';
/**
* Import a community theme from a local `.zip` (manifest.json + theme.css).
* Rust extracts the two entries (size-capped, outside the webview); the full
* store contract validation then runs before the theme is persisted. Anything
* off-contract is rejected with the exact reasons listed.
*/
export function ThemeImportSection() {
const { t } = useTranslation();
const install = useInstalledThemesStore(s => s.install);
const [importErrors, setImportErrors] = useState<string[] | null>(null);
const [importing, setImporting] = useState(false);
// A validated-but-not-yet-installed theme, awaiting the user's confirmation.
const [pending, setPending] = useState<ValidatedTheme | null>(null);
const handleImport = async () => {
setImportErrors(null);
let selected: string | null = null;
try {
const picked = await open({
multiple: false,
directory: false,
filters: [{ name: 'Theme', extensions: ['zip'] }],
});
if (typeof picked === 'string') selected = picked;
} catch {
return; // dialog dismissed / unavailable
}
if (!selected) return;
setImporting(true);
try {
const files = await invoke<{ manifest: string; css: string }>('import_theme_zip', { path: selected });
const result = validateThemePackage(files.manifest, files.css);
if (!result.ok) {
setImportErrors(result.errors);
return;
}
// Validated — confirm with the user (name + author) before persisting.
setPending(result.theme);
} catch (e) {
setImportErrors([String(e)]);
} finally {
setImporting(false);
}
};
const confirmInstall = () => {
if (!pending) return;
install({ ...pending, installedAt: Date.now() });
showToast(t('settings.themeImportSuccess', { name: pending.name }), 4000, 'success');
setPending(null);
};
return (
<div>
<button
type="button"
onClick={handleImport}
disabled={importing}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
width: '100%',
textAlign: 'left',
padding: '14px 16px',
border: '1px dashed var(--border)',
borderRadius: 'var(--radius-md, 10px)',
background: 'var(--bg-elevated)',
color: 'var(--text-primary)',
cursor: importing ? 'default' : 'pointer',
}}
>
<Upload size={20} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600, fontSize: 13.5 }}>
{importing ? t('settings.themeImporting') : t('settings.themeImportButton')}
</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeImportHint')}</span>
</span>
</button>
{importErrors && (
<div
role="alert"
style={{
marginTop: '1rem',
padding: '10px 12px',
border: '1px solid var(--danger)',
borderRadius: 'var(--radius-md, 10px)',
background: 'var(--bg-elevated)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<strong style={{ fontSize: 13, color: 'var(--danger)' }}>{t('settings.themeImportErrorTitle')}</strong>
<button
onClick={() => setImportErrors(null)}
aria-label={t('common.close')}
style={{ background: 'none', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 0, lineHeight: 0 }}
>
<X size={14} />
</button>
</div>
<p style={{ margin: '6px 0 0', fontSize: 12.5, lineHeight: 1.5, color: 'var(--text-secondary)' }}>
{t('settings.themeImportErrorBody')}
</p>
{/* The raw contract diagnostics — useful to theme authors / bug reports,
tucked away so end users aren't confronted with token names. */}
<details style={{ marginTop: 8 }}>
<summary style={{ fontSize: 12, color: 'var(--text-muted)', cursor: 'pointer' }}>
{t('settings.themeImportErrorDetails')}
</summary>
<ul style={{ margin: '6px 0 0', paddingLeft: 18, color: 'var(--text-muted)' }}>
{importErrors.map((e, i) => (
<li key={i} style={{ fontSize: 11.5, lineHeight: 1.5 }}>{e}</li>
))}
</ul>
</details>
</div>
)}
<ConfirmModal
open={pending !== null}
title={t('settings.themeImportConfirmTitle')}
message={pending ? `${t('settings.themeImportConfirmBody', { name: pending.name, author: pending.author })} ${t('settings.themeImportConfirmRisk')}` : ''}
confirmLabel={t('settings.themeStoreInstall')}
cancelLabel={t('common.cancel')}
onConfirm={confirmInstall}
onCancel={() => setPending(null)}
/>
</div>
);
}
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/utils/themes/themeRegistry';
// Control the registry the store browses so pagination/refresh are deterministic.
vi.mock('@/utils/themes/themeRegistry', () => ({
fetchRegistry: vi.fn(),
fetchThemeCss: vi.fn(async () => 'css'),
assetUrl: (p: string) => `https://raw.example/${p}`,
}));
import { fetchRegistry } from '@/utils/themes/themeRegistry';
const fetchRegistryMock = vi.mocked(fetchRegistry);
/** A registry with `n` themes named `Theme 01`…`Theme NN` (zero-padded so the
* component's alphabetical sort matches numeric order). */
function makeRegistry(n: number): Registry {
const themes = Array.from({ length: n }, (_, i) => {
const num = String(i + 1).padStart(2, '0');
return {
id: `t${num}`,
name: `Theme ${num}`,
author: 'Tester',
version: '1.0.0',
description: `Description ${num}`,
mode: (i % 2 === 0 ? 'dark' : 'light') as 'dark' | 'light',
css: `themes/t${num}/theme.css`,
thumbnail: `themes/t${num}/thumb.png`,
tags: [],
};
});
return { schemaVersion: 1, generatedAt: '2026-06-07T00:00:00Z', themes };
}
const rows = (container: HTMLElement) => container.querySelectorAll('.theme-store-row');
/** Visible theme names in row order (the first span in each row is the name). */
const rowNames = (container: HTMLElement) =>
[...container.querySelectorAll('.theme-store-row')].map(r => r.querySelector('span')?.textContent);
function mkTheme(id: string, name: string, extra: Partial<RegistryTheme> = {}): RegistryTheme {
return {
id,
name,
author: 'Tester',
version: '1.0.0',
description: `Description ${id}`,
mode: 'dark',
css: `themes/${id}/theme.css`,
thumbnail: `themes/${id}/thumb.webp`,
tags: [],
...extra,
};
}
function registryOf(themes: RegistryTheme[]): Registry {
return { schemaVersion: 1, generatedAt: '2026-06-08T00:00:00Z', themes };
}
/** Open the sort dropdown and pick an option by its visible label. */
async function selectSort(
user: ReturnType<typeof userEvent.setup>,
container: HTMLElement,
optionLabel: string,
) {
await user.click(container.querySelector('.custom-select-trigger') as HTMLElement);
// The option closes the list on mousedown, so fire it directly.
fireEvent.mouseDown(await screen.findByRole('option', { name: optionLabel }));
}
describe('ThemeStoreSection — pagination & refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
// jsdom has no layout engine; goToPage() scrolls the list back up.
Element.prototype.scrollIntoView = vi.fn();
});
it('shows only one page of themes and a pager when the catalogue is large', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
await screen.findByText('Theme 01');
// PAGE_SIZE is 12 → 30 themes span 3 pages.
expect(rows(container)).toHaveLength(12);
expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument();
// Page-2 themes are not rendered yet.
expect(screen.queryByText('Theme 13')).not.toBeInTheDocument();
});
it('does not paginate when everything fits on one page', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(8), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
await screen.findByText('Theme 01');
expect(rows(container)).toHaveLength(8);
expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument();
expect(screen.queryByText(/page 1 of/i)).not.toBeInTheDocument();
});
it('navigates between pages and disables prev/next at the bounds', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
expect(screen.getByLabelText('Previous page')).toBeDisabled();
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByRole('button', { name: '2', current: 'page' })).toBeInTheDocument();
expect(screen.getByText('Theme 13')).toBeInTheDocument();
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
expect(screen.getByLabelText('Previous page')).toBeEnabled();
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
expect(rows(container)).toHaveLength(6); // 30 - 24
expect(screen.getByLabelText('Next page')).toBeDisabled();
});
it('filters the catalogue by the search query', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
await user.type(screen.getByRole('searchbox'), 'Theme 05');
await waitFor(() => expect(rows(container)).toHaveLength(1));
expect(screen.getByText('Theme 05')).toBeInTheDocument();
expect(screen.queryByText('Theme 04')).not.toBeInTheDocument();
});
it('resets to the first page when the search query changes', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
await user.click(screen.getByLabelText('Next page'));
expect(screen.getByRole('button', { name: '2', current: 'page' })).toBeInTheDocument();
// 'theme' matches all 30 names, so the catalogue is unchanged in size — but
// the page must snap back to 1 so the user isn't stranded past the end.
await user.type(screen.getByRole('searchbox'), 'theme');
await waitFor(() => expect(screen.getByRole('button', { name: '1', current: 'page' })).toBeInTheDocument());
expect(screen.getByText('Theme 01')).toBeInTheDocument();
});
it('keeps the list mounted while refreshing (no scroll-resetting unmount)', async () => {
const reg = makeRegistry(30);
let resolveRefresh!: (v: FetchRegistryResult) => void;
const pending = new Promise<FetchRegistryResult>(res => { resolveRefresh = res; });
fetchRegistryMock
.mockResolvedValueOnce({ registry: reg, stale: false }) // initial load
.mockReturnValueOnce(pending); // manual refresh, held pending
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
expect(container.querySelector('.animate-spin')).toBeNull();
await user.click(screen.getByLabelText('Refresh'));
// The whole point of the fix: refreshing must NOT swap the list for the
// full-page loading placeholder (which collapses the scroll viewport and
// jumps it to the top). The rows stay; only the icon spins.
expect(rows(container)).toHaveLength(12);
expect(screen.queryByText(/loading themes/i)).not.toBeInTheDocument();
expect(container.querySelector('.animate-spin')).not.toBeNull();
resolveRefresh({ registry: reg, stale: false });
await waitFor(() => expect(container.querySelector('.animate-spin')).toBeNull());
expect(rows(container)).toHaveLength(12);
});
it('shows an offline banner and hides the toolbar when the registry is unavailable', async () => {
fetchRegistryMock.mockRejectedValue(new Error('offline'));
renderWithProviders(<ThemeStoreSection />);
expect(await screen.findByText('The Theme Store is offline')).toBeInTheDocument();
// No catalogue to browse → the search/filter toolbar is not rendered.
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
it('defaults to newest and sorts alphabetically', async () => {
const themes = [
mkTheme('a', 'Alpha', { updatedAt: '2026-06-01T00:00:00Z' }),
mkTheme('b', 'Bravo', { updatedAt: '2026-06-05T00:00:00Z' }),
mkTheme('c', 'Charlie', { updatedAt: '2026-06-03T00:00:00Z' }),
];
fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
const { container } = renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Bravo');
// Default sort is newest first: Bravo (06-05) > Charlie (06-03) > Alpha (06-01).
expect(rowNames(container)).toEqual(['Bravo', 'Charlie', 'Alpha']);
// Alphabetical.
await selectSort(user, container, 'Alphabetical');
await waitFor(() => expect(rowNames(container)).toEqual(['Alpha', 'Bravo', 'Charlie']));
});
it('jumps directly to a page via the numbered pager', async () => {
fetchRegistryMock.mockResolvedValue({ registry: makeRegistry(30), stale: false });
renderWithProviders(<ThemeStoreSection />);
const user = userEvent.setup();
await screen.findByText('Theme 01');
await user.click(screen.getByRole('button', { name: '3' }));
// Page 3 holds items 2530; page 1 is gone and page 3 is marked current.
expect(screen.getByText('Theme 25')).toBeInTheDocument();
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
});
});
@@ -0,0 +1,476 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import CoverLightbox from '@/components/CoverLightbox';
import { useThemeAnimationRisk } from '@/hooks/useThemeAnimationRisk';
import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge';
import CustomSelect from '@/ui/CustomSelect';
import { formatRelativeTime } from '@/utils/format/relativeTime';
import { useThemeStore } from '@/store/themeStore';
import { useInstalledThemesStore, type InstalledTheme } from '@/store/installedThemesStore';
import {
assetUrl,
fetchRegistry,
type RegistryTheme,
} from '@/utils/themes/themeRegistry';
import { installThemeFromRegistry } from '@/utils/themes/installThemeFromRegistry';
import { uninstallTheme } from '@/utils/themes/uninstallTheme';
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
type ModeFilter = 'all' | 'dark' | 'light';
type SortMode = 'newest' | 'name';
type AnimFilter = 'all' | 'animated' | 'static';
const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
/** Themes shown per page — the catalogue is large enough to paginate. */
const PAGE_SIZE = 12;
/** Page numbers for the pager: all of them when there are few, otherwise the
* first and last page plus a window around the current one, with gaps. */
function pageItemsList(current: number, total: number): (number | 'gap')[] {
if (total <= 10) return Array.from({ length: total }, (_, i) => i + 1);
const out: (number | 'gap')[] = [1];
const lo = Math.max(2, current - 2);
const hi = Math.min(total - 1, current + 2);
if (lo > 2) out.push('gap');
for (let p = lo; p <= hi; p++) out.push(p);
if (hi < total - 1) out.push('gap');
out.push(total);
return out;
}
/**
* The community Theme Store: browse the GitHub-hosted registry, filter by name
* and light/dark, install (fetch + persist + runtime inject), apply, update and
* uninstall. Built-in themes are not in the registry, so they never appear here.
*/
export function ThemeStoreSection() {
const { t, i18n } = useTranslation();
const activeTheme = useThemeStore(s => s.theme);
const setTheme = useThemeStore(s => s.setTheme);
const installed = useInstalledThemesStore(s => s.themes);
const animRisk = useThemeAnimationRisk();
const [themes, setThemes] = useState<RegistryTheme[] | null>(null);
const [generatedAt, setGeneratedAt] = useState('');
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(false);
const [stale, setStale] = useState(false);
const [query, setQuery] = useState('');
const [mode, setMode] = useState<ModeFilter>('all');
const [sortMode, setSortMode] = useState<SortMode>('newest');
const [animFilter, setAnimFilter] = useState<AnimFilter>('all');
const [page, setPage] = useState(1);
const [busyId, setBusyId] = useState<string | null>(null);
const [failedId, setFailedId] = useState<string | null>(null);
const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null);
const topRef = useRef<HTMLDivElement>(null);
// A manual refresh must not unmount the list: blanking it collapses the
// scroll viewport's content height, which clamps scrollTop to 0 — i.e. the
// page jumps to the top. So keep the existing list mounted (`refreshing`,
// shown only via the spinning icon) and reserve the full-page loading/error
// placeholders for the initial load, when there is nothing to show anyway.
const load = (force = false) => {
if (force) setRefreshing(true);
else setLoading(true);
setError(false);
fetchRegistry({ force })
.then(r => { setThemes(r.registry.themes); setGeneratedAt(r.registry.generatedAt); setStale(r.stale); })
.catch(() => { if (force) setStale(true); else setError(true); })
.finally(() => { setLoading(false); setRefreshing(false); });
};
// GitHub raw caches thumbnails only briefly, but the webview can still hold an
// old copy; tie a cache-buster to the registry's generatedAt — it changes on
// every themes push — so refreshed thumbnails show up after a registry refresh
// instead of being stuck on the old image.
const thumbUrl = (rel: string) =>
generatedAt ? `${assetUrl(rel)}?v=${encodeURIComponent(generatedAt)}` : assetUrl(rel);
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(false); }, []);
const installedMap = useMemo(() => {
const m = new Map<string, InstalledTheme>();
for (const it of installed) m.set(it.id, it);
return m;
}, [installed]);
const filtered = useMemo(() => {
if (!themes) return [];
const q = query.trim().toLowerCase();
const matched = themes.filter(th => {
if (mode !== 'all' && th.mode !== mode) return false;
if (animFilter === 'animated' && !th.animated) return false;
if (animFilter === 'static' && th.animated) return false;
if (!q) return true;
return (
th.name.toLowerCase().includes(q) ||
th.author.toLowerCase().includes(q) ||
th.description.toLowerCase().includes(q) ||
(th.tags || []).some(tag => tag.includes(q))
);
});
// Name is the stable tie-breaker — keeps ordering deterministic when many
// themes share the same last-modified date.
const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name);
if (sortMode === 'name') return matched.sort(byName);
return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
}, [themes, query, mode, sortMode, animFilter]);
// A changed filter can shrink the result set below the current page; reset to
// the first page whenever the query or mode filter changes.
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setPage(1); }, [query, mode, sortMode, animFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
// Clamp defensively so a stale `page` (e.g. after the registry shrank) never
// points past the end and shows a blank list.
const safePage = Math.min(page, pageCount);
const pageItems = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const goToPage = (n: number) => {
setPage(Math.min(Math.max(1, n), pageCount));
// Start the new page from the top of the store instead of mid-scroll.
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleInstall = async (th: RegistryTheme) => {
setBusyId(th.id);
setFailedId(null);
const result = await installThemeFromRegistry(th);
if (result !== 'ok') setFailedId(th.id);
setBusyId(null);
};
const modeBtns: { key: ModeFilter; label: string }[] = [
{ key: 'all', label: t('settings.themeStoreModeAll') },
{ key: 'dark', label: t('settings.themeStoreModeDark') },
{ key: 'light', label: t('settings.themeStoreModeLight') },
];
const sortOptions = [
{ value: 'newest', label: t('settings.themeStoreSortNewest') },
{ value: 'name', label: t('settings.themeStoreSortName') },
];
const animFilterOptions = [
{ value: 'all', label: t('settings.themeStoreAnimAll') },
{ value: 'animated', label: t('settings.themeStoreAnimAnimated') },
{ value: 'static', label: t('settings.themeStoreAnimStatic') },
];
return (
<div className="settings-card">
{/* Submit-your-own-theme hint */}
<div className="settings-hint settings-hint-info" style={{ marginBottom: '1rem' }}>
{t('settings.themeStoreSubmitText')}{' '}
<button
type="button"
onClick={() => void openUrl(THEMES_REPO_URL)}
style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', color: 'var(--accent)', cursor: 'pointer', textDecoration: 'underline' }}
>
{t('settings.themeStoreSubmitLink')}
</button>
</div>
{/* Network disclosure — the store reaches external services. */}
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 1rem', lineHeight: 1.5 }}>
{t('settings.themeStoreNetworkNotice')}{' '}{t('settings.themeStoreStatsNotice')}
</p>
{/* Toolbar: search + mode filter + refresh. Hidden when offline with no
catalogue to browse — the offline banner below stands in for it. */}
{!error && (
<div ref={topRef} style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: '1rem', scrollMarginTop: 8 }}>
<input
type="search"
className="input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('settings.themeStoreSearchPlaceholder')}
aria-label={t('settings.themeStoreSearchPlaceholder')}
style={{ flex: '1 1 180px', minWidth: 140 }}
/>
<CustomSelect
value={sortMode}
options={sortOptions}
onChange={v => setSortMode(v as SortMode)}
style={{
width: 170,
flexShrink: 0,
alignSelf: 'stretch',
boxSizing: 'border-box',
padding: '0 var(--space-4)',
background: 'var(--input-bg)',
border: '1px solid var(--input-border)',
borderRadius: 'var(--radius-md)',
fontSize: 14,
lineHeight: 1,
}}
/>
<CustomSelect
value={animFilter}
options={animFilterOptions}
onChange={v => setAnimFilter(v as AnimFilter)}
style={{
width: 150,
flexShrink: 0,
alignSelf: 'stretch',
boxSizing: 'border-box',
padding: '0 var(--space-4)',
background: 'var(--input-bg)',
border: '1px solid var(--input-border)',
borderRadius: 'var(--radius-md)',
fontSize: 14,
lineHeight: 1,
}}
/>
<div style={{ display: 'flex', gap: 4 }} role="group" aria-label={t('settings.themeStoreFilterMode')}>
{modeBtns.map(b => (
<button
key={b.key}
className={`btn ${mode === b.key ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 10px' }}
aria-pressed={mode === b.key}
onClick={() => setMode(b.key)}
>
{b.label}
</button>
))}
</div>
<button
className="btn btn-ghost"
style={{ padding: '4px 10px' }}
onClick={() => load(true)}
disabled={loading || refreshing}
aria-label={t('settings.themeStoreRefresh')}
data-tooltip={t('settings.themeStoreRefresh')}
data-tooltip-pos="left"
>
<RefreshCw size={15} className={refreshing ? 'animate-spin' : ''} />
</button>
</div>
)}
{!loading && stale && (
<div className="settings-hint settings-hint-info" role="status" style={{ marginBottom: '0.75rem' }}>
{t('settings.themeStoreOffline')}
</div>
)}
{loading && (
<p role="status" style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreLoading')}</p>
)}
{!loading && error && (
<div
role="alert"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 12,
padding: '28px 16px',
border: '1px dashed var(--border)',
borderRadius: 'var(--radius-md, 10px)',
background: 'var(--bg-elevated)',
}}
>
<WifiOff size={28} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
<div>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>{t('settings.themeStoreOfflineTitle')}</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreError')}</div>
</div>
<button className="btn btn-ghost" onClick={() => load(true)} disabled={refreshing}>
{t('settings.themeStoreRetry')}
</button>
</div>
)}
{!loading && !error && filtered.length === 0 && (
<p role="status" style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('settings.themeStoreEmpty')}</p>
)}
{!loading && !error && filtered.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{pageItems.map((th, idx) => {
const inst = installedMap.get(th.id);
const isInstalled = !!inst;
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
const isActive = activeTheme === th.id;
const busy = busyId === th.id;
return (
<div
key={th.id}
className="theme-store-row"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 14,
padding: 12,
border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md, 10px)',
// Subtle zebra striping so adjacent rows read as distinct boxes.
background: idx % 2 === 1 ? 'var(--bg-hover)' : 'var(--bg-card)',
}}
>
<button
type="button"
onClick={() => setLightbox({ src: thumbUrl(th.thumbnail), name: th.name })}
aria-label={t('settings.themeStoreEnlarge')}
data-tooltip={t('settings.themeStoreEnlarge')}
data-tooltip-pos="right"
style={{ padding: 0, border: 'none', background: 'none', cursor: 'zoom-in', flexShrink: 0, alignSelf: 'flex-start', lineHeight: 0, borderRadius: 6 }}
>
<img
src={thumbUrl(th.thumbnail)}
alt=""
loading="lazy"
width={200}
height={112}
// Offline / missing thumbnail: hide the broken-image glyph; the
// image's own neutral background stands in as a placeholder.
onError={e => { e.currentTarget.style.opacity = '0'; }}
style={{ width: 200, height: 112, objectFit: 'cover', borderRadius: 6, background: 'var(--bg-deep)' }}
/>
</button>
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600 }}>{th.name}</span>
{isActive && (
<span style={{ fontSize: 11, color: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: 3 }}>
<Check size={12} /> {t('settings.themeStoreActive')}
</span>
)}
{animRisk && th.animated && <AnimatedThemeBadge variant="inline" />}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.themeStoreByAuthor', { author: th.author })}
{' · '}
{updateAvailable ? (
<>v{inst!.version} <span style={{ color: 'var(--accent)' }}> v{th.version}</span></>
) : (
<>v{th.version}</>
)}
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.4, marginTop: 10 }}>
{th.description}
</div>
{th.updatedAt && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 8 }}>
{t('settings.themeStoreLastChanged')}: {formatRelativeTime(th.updatedAt, i18n.language)}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
{!isInstalled && (
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '4px 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
onClick={() => handleInstall(th)}
disabled={busy}
>
<Download size={14} /> {busy ? t('settings.themeStoreInstalling') : t('settings.themeStoreInstall')}
</button>
)}
{isInstalled && !isActive && (
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => setTheme(th.id)}
>
{t('settings.themeStoreApply')}
</button>
)}
{updateAvailable && (
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => handleInstall(th)}
disabled={busy}
>
{busy ? t('settings.themeStoreUpdating') : t('settings.themeStoreUpdate')}
</button>
)}
{isInstalled && (
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
onClick={() => uninstallTheme(th.id)}
>
<Trash2 size={14} /> {t('settings.themeStoreUninstall')}
</button>
)}
{failedId === th.id && (
<span role="status" style={{ fontSize: 12, color: 'var(--danger)', alignSelf: 'center' }}>
{t('settings.themeStoreInstallFailed')}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{!loading && !error && pageCount > 1 && (
<div
role="navigation"
aria-label={t('settings.themeStorePageStatus', { page: safePage, total: pageCount })}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap', gap: 6, marginTop: 16 }}
>
<button
className="btn btn-ghost"
style={{ padding: '4px 10px' }}
onClick={() => goToPage(safePage - 1)}
disabled={safePage <= 1}
aria-label={t('settings.themeStorePagePrev')}
data-tooltip={t('settings.themeStorePagePrev')}
data-tooltip-pos="top"
>
<ChevronLeft size={16} />
</button>
{pageItemsList(safePage, pageCount).map((it, i) =>
it === 'gap' ? (
<span key={`gap-${i}`} style={{ color: 'var(--text-muted)', padding: '0 2px' }}></span>
) : (
<button
key={it}
className={`btn ${it === safePage ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12.5, padding: '4px 10px', minWidth: 34 }}
aria-current={it === safePage ? 'page' : undefined}
onClick={() => goToPage(it)}
>
{it}
</button>
)
)}
<button
className="btn btn-ghost"
style={{ padding: '4px 10px' }}
onClick={() => goToPage(safePage + 1)}
disabled={safePage >= pageCount}
aria-label={t('settings.themeStorePageNext')}
data-tooltip={t('settings.themeStorePageNext')}
data-tooltip-pos="top"
>
<ChevronRight size={16} />
</button>
</div>
)}
{lightbox && (
<CoverLightbox src={lightbox.src} alt={lightbox.name} onClose={() => setLightbox(null)} />
)}
</div>
);
}
@@ -0,0 +1,173 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Clock, Palette, Store, Upload } from 'lucide-react';
import { useThemeStore } from '@/store/themeStore';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import CustomSelect from '@/ui/CustomSelect';
import BackToTopButton from '@/ui/BackToTopButton';
import { FIXED_THEMES } from '@/components/settings/fixedThemes';
import { InstalledThemes } from '@/features/settings/components/InstalledThemes';
import { ThemeImportSection } from '@/features/settings/components/ThemeImportSection';
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
/**
* A flat, always-visible section. The Themes tab has a single purpose, so its
* parts are laid out one below the other with no collapsing — deliberately not
* the collapsible <details> SettingsSubSection used elsewhere. `data-settings-
* search` keeps each section reachable from the global settings search.
*/
function ThemesSection({ icon, title, children, boxed }: { icon: ReactNode; title: string; children: ReactNode; boxed?: boolean }) {
if (boxed) {
return (
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
<div className="settings-card">
<SettingsGroup title={title} icon={icon}>{children}</SettingsGroup>
</div>
</section>
);
}
return (
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 15, fontWeight: 600, margin: '0 0 0.75rem' }}>
<span style={{ display: 'inline-flex', color: 'var(--accent)' }}>{icon}</span>
{title}
</h2>
{children}
</section>
);
}
/**
* Dedicated Themes tab: pick a theme (fixed cores + installed community themes),
* the day/night scheduler, and the community Theme Store — all flat on one page.
*/
export function ThemesTab() {
const { t, i18n } = useTranslation();
const theme = useThemeStore();
const installed = useInstalledThemesStore(s => s.themes);
return (
<>
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')} boxed>
{theme.enableThemeScheduler && (
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.themeSchedulerActiveHint')}
</div>
)}
<InstalledThemes />
</ThemesSection>
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')} boxed>
<div>
<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 = [
...FIXED_THEMES.map(f => ({ value: f.id, label: f.label })),
...installed.map(it => ({
value: it.id,
label: it.name,
group: t('settings.themesYourThemesTitle'),
})),
];
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];
const isSystem = theme.schedulerMode === 'system';
return (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField label={t('settings.themeSchedulerModeLabel')}>
<div className="settings-segmented">
<button
type="button"
className={`btn ${!isSystem ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => theme.setSchedulerMode('time')}
>
{t('settings.themeSchedulerModeTime')}
</button>
<button
type="button"
className={`btn ${isSystem ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => theme.setSchedulerMode('system')}
>
{t('settings.themeSchedulerModeSystem')}
</button>
</div>
</SettingsField>
{isSystem && (
<div className="settings-hint settings-hint-info">
{t('settings.themeSchedulerSystemRestartHint')}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem' }}>
<div className="form-group">
<label className="settings-label" style={{ marginBottom: 6 }}>
{isSystem ? t('settings.themeSchedulerLightTheme') : t('settings.themeSchedulerDayTheme')}
</label>
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
</div>
{!isSystem && (
<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 }}>
{isSystem ? t('settings.themeSchedulerDarkTheme') : t('settings.themeSchedulerNightTheme')}
</label>
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
</div>
{!isSystem && (
<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>
</SettingsSubCard>
);
})()}
</div>
</ThemesSection>
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')} boxed>
<ThemeImportSection />
</ThemesSection>
<ThemesSection icon={<Store size={16} />} title={t('settings.themeStoreTitle')}>
<ThemeStoreSection />
</ThemesSection>
<BackToTopButton />
</>
);
}
@@ -0,0 +1,366 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Shield, Wand2 } from 'lucide-react';
import { ndUpdateUser, type NdLibrary, type NdUser } from '@/api/navidromeAdmin';
import { showToast } from '@/utils/ui/toast';
import {
copyTextToClipboard,
encodeServerMagicString,
magicPayloadAddressFields,
} from '@/utils/server/serverMagicString';
import { shortHostFromServerUrl } from '@/utils/server/serverDisplayName';
import { useAuthStore } from '@/store/authStore';
export interface UserFormState {
userName: string;
name: string;
email: string;
password: string;
isAdmin: boolean;
libraryIds: number[];
}
function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]): UserFormState {
const defaultIds = allLibraries.map(l => l.id);
return {
userName: u?.userName ?? '',
name: u?.name ?? '',
email: u?.email ?? '',
password: '',
isAdmin: !!u?.isAdmin,
libraryIds: u ? [...u.libraryIds] : defaultIds,
};
}
export function UserForm({
initial,
libraries,
shareServerUrl,
ndToken,
onUsersDirty,
onSave,
onSaveAndGetMagic,
onCancel,
busy,
}: {
initial: NdUser | null;
libraries: NdLibrary[];
shareServerUrl: string;
ndToken: string;
onUsersDirty?: () => void | Promise<void>;
onSave: (form: UserFormState) => void;
/** New user only: create on Navidrome then copy magic string to clipboard. */
onSaveAndGetMagic?: (form: UserFormState) => void | Promise<void>;
onCancel: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<UserFormState>(() => initialUserFormState(initial ?? undefined, libraries));
const [magicGenBusy, setMagicGenBusy] = useState(false);
const [showNewUserRequiredErrors, setShowNewUserRequiredErrors] = useState(false);
const isEdit = !!initial;
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowNewUserRequiredErrors(false);
}, [initial?.id]);
useEffect(() => {
if (!isEdit && form.userName.trim() && form.name.trim() && form.password.trim()) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowNewUserRequiredErrors(false);
}
}, [isEdit, form.userName, form.name, form.password]);
const set = <K extends keyof UserFormState>(k: K, v: UserFormState[K]) =>
setForm(f => ({ ...f, [k]: v }));
const toggleLib = (id: number) =>
setForm(f => ({
...f,
libraryIds: f.libraryIds.includes(id)
? f.libraryIds.filter(x => x !== id)
: [...f.libraryIds, id],
}));
const newUserPasswordOk = form.password.trim().length > 0;
const canSave =
form.userName.trim().length > 0 &&
form.name.trim().length > 0 &&
(isEdit || newUserPasswordOk) &&
(form.isAdmin || form.libraryIds.length > 0);
const generateMagicString = async () => {
if (!shareServerUrl.trim() || !form.password.trim() || !initial || !ndToken.trim()) return;
setMagicGenBusy(true);
try {
await ndUpdateUser(shareServerUrl.trim(), ndToken, initial.id, {
userName: form.userName.trim(),
name: form.name.trim(),
email: form.email.trim(),
password: form.password,
isAdmin: form.isAdmin,
});
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtUpdateError'), 5000, 'error');
return;
} finally {
setMagicGenBusy(false);
}
const addressFields = magicPayloadAddressFields(
shareServerUrl.trim(),
useAuthStore.getState().servers,
);
const str = encodeServerMagicString({
...addressFields,
username: form.userName.trim(),
password: form.password,
name: shortHostFromServerUrl(shareServerUrl),
});
const ok = await copyTextToClipboard(str);
showToast(
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
ok ? 3000 : 5000,
ok ? 'info' : 'error',
);
if (ok) void onUsersDirty?.();
};
const runSaveAndGetMagic = async () => {
if (!onSaveAndGetMagic) return;
if (!form.userName.trim() || !form.name.trim() || !form.password.trim()) {
setShowNewUserRequiredErrors(true);
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
return;
}
setMagicGenBusy(true);
try {
await onSaveAndGetMagic(form);
} finally {
setMagicGenBusy(false);
}
};
const invalidNewUserCore =
!isEdit && (!form.userName.trim() || !form.name.trim() || !form.password.trim());
const trySave = () => {
if (invalidNewUserCore) {
setShowNewUserRequiredErrors(true);
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
return;
}
onSave(form);
};
const markInvalid = showNewUserRequiredErrors && !isEdit;
return (
<div className="settings-card" style={{ marginBottom: '1.25rem' }}>
<h3 style={{ fontWeight: 600, marginBottom: '1rem', fontSize: '14px' }}>
{isEdit ? t('settings.userMgmtEditUserTitle') : t('settings.userMgmtAddUserTitle')}
</h3>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<div className="form-group">
<label style={{ fontSize: 13 }}>
{t('settings.userMgmtUsername')}
{!isEdit && <span style={{ color: 'var(--text-muted)' }}> *</span>}
</label>
<input
className="input"
type="text"
value={form.userName}
onChange={e => set('userName', e.target.value)}
disabled={isEdit}
autoComplete="off"
aria-invalid={markInvalid && !form.userName.trim()}
style={markInvalid && !form.userName.trim() ? { borderColor: 'var(--danger)' } : undefined}
/>
</div>
<div className="form-group">
<label style={{ fontSize: 13 }}>
{t('settings.userMgmtName')}
{!isEdit && <span style={{ color: 'var(--text-muted)' }}> *</span>}
</label>
<input
className="input"
type="text"
value={form.name}
onChange={e => set('name', e.target.value)}
autoComplete="off"
aria-invalid={markInvalid && !form.name.trim()}
style={markInvalid && !form.name.trim() ? { borderColor: 'var(--danger)' } : undefined}
/>
</div>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtEmail')}</label>
<input
className="input"
type="email"
value={form.email}
onChange={e => set('email', e.target.value)}
autoComplete="off"
/>
</div>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 13 }}>
{t('settings.userMgmtPassword')}
{!isEdit && <span style={{ color: 'var(--text-muted)' }}> *</span>}
</label>
<input
className="input"
type="password"
value={form.password}
onChange={e => set('password', e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
aria-invalid={markInvalid && !form.password.trim()}
style={markInvalid && !form.password.trim() ? { borderColor: 'var(--danger)' } : undefined}
/>
{isEdit && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4 }}>
{t('settings.userMgmtPasswordEditHint')}
</div>
)}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', marginBottom: '1rem' }}>
<input
type="checkbox"
checked={form.isAdmin}
onChange={e => set('isAdmin', e.target.checked)}
/>
<Shield size={14} />
{t('settings.userMgmtRoleAdmin')}
</label>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: 13, marginBottom: 6, display: 'block' }}>
{t('settings.userMgmtLibraries')}
</label>
{form.isAdmin ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.userMgmtLibrariesAdminHint')}
</div>
) : libraries.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.userMgmtLibrariesEmpty')}
</div>
) : (
<>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
maxHeight: 180,
overflowY: 'auto',
padding: '6px 8px',
border: `1px solid ${form.libraryIds.length === 0 ? 'var(--danger)' : 'var(--border)'}`,
borderRadius: 6,
}}
>
{libraries.map(lib => (
<label
key={lib.id}
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', padding: '2px 0' }}
>
<input
type="checkbox"
checked={form.libraryIds.includes(lib.id)}
onChange={() => toggleLib(lib.id)}
/>
{lib.name}
</label>
))}
</div>
{form.libraryIds.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 4 }}>
{t('settings.userMgmtLibrariesValidation')}
</div>
)}
</>
)}
</div>
{!form.isAdmin && !isEdit && onSaveAndGetMagic && shareServerUrl.trim() && ndToken.trim() && (
<div style={{ marginBottom: '1rem' }}>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<button
type="button"
className="btn btn-surface"
onClick={() => void runSaveAndGetMagic()}
disabled={busy || magicGenBusy}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Wand2 size={16} />
{t('settings.userMgmtSaveAndMagicString')}
</button>
</div>
)}
{!form.isAdmin && isEdit && shareServerUrl.trim() && form.password.trim().length > 0 && ndToken.trim() && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8, lineHeight: 1.45 }}>
{t('settings.userMgmtMagicStringPasswordNavHint')}
</div>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: 10,
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<button
type="button"
className="btn btn-surface"
onClick={() => void generateMagicString()}
disabled={busy || magicGenBusy}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Wand2 size={16} />
{t('settings.userMgmtMagicStringGenerate')}
</button>
</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn btn-ghost" onClick={onCancel} disabled={busy}>
{t('settings.userMgmtCancel')}
</button>
<button
className="btn btn-primary"
onClick={() => trySave()}
disabled={busy || (isEdit && !canSave)}
>
{t('settings.userMgmtSave')}
</button>
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RotateCcw, UserPlus, Users } from 'lucide-react';
import type { NdUser } from '@/api/navidromeAdmin';
import ConfirmModal from '@/components/ConfirmModal';
import { useUserMgmtData } from '@/features/settings/hooks/useUserMgmtData';
import { useUserMgmtActions } from '@/features/settings/hooks/useUserMgmtActions';
import { UserForm } from '@/features/settings/components/UserForm';
import { UserMgmtRow } from '@/features/settings/components/userMgmt/UserMgmtRow';
import { MagicStringModal } from '@/features/settings/components/userMgmt/MagicStringModal';
export function UserManagementSection({
serverUrl,
token,
currentUsername,
}: {
serverUrl: string;
token: string;
currentUsername: string;
}) {
const { t, i18n } = useTranslation();
const { users, libraries, loading, loadError, load } = useUserMgmtData(serverUrl, token, t);
const [editing, setEditing] = useState<NdUser | 'new' | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState<NdUser | null>(null);
const [magicRowUser, setMagicRowUser] = useState<NdUser | null>(null);
const { busy, handleSave, handleSaveAndGetMagic, performDelete } = useUserMgmtActions({
serverUrl, token, libraries, editing, setEditing, reload: load, t,
});
return (
<section className="settings-section">
<div className="settings-section-header">
<Users size={18} />
<h2>{t('settings.userMgmtTitle')}</h2>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.userMgmtDesc')}
</div>
{loading && (
<div className="settings-card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="spinner" style={{ width: 14, height: 14 }} />
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}></span>
</div>
)}
{!loading && loadError && (
<div
className="settings-card"
style={{
color: 'var(--danger)',
fontSize: 13,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t('settings.userMgmtLoadFriendly')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', wordBreak: 'break-word' }}>{loadError}</div>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => void load()}
style={{ flexShrink: 0 }}
>
<RotateCcw size={14} /> {t('settings.userMgmtRetry')}
</button>
</div>
)}
{!loading && !loadError && (
<>
{editing ? (
<UserForm
initial={editing === 'new' ? null : editing}
libraries={libraries}
shareServerUrl={serverUrl}
ndToken={token}
onUsersDirty={load}
onSave={handleSave}
onSaveAndGetMagic={editing === 'new' ? handleSaveAndGetMagic : undefined}
onCancel={() => setEditing(null)}
busy={busy}
/>
) : (
<button
className="btn btn-surface"
style={{ marginBottom: '0.75rem' }}
onClick={() => setEditing('new')}
disabled={busy}
>
<UserPlus size={16} /> {t('settings.userMgmtAddUser')}
</button>
)}
{users.length === 0 ? (
<div className="settings-card" style={{ color: 'var(--text-muted)', fontSize: 14 }}>
{t('settings.userMgmtEmpty')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{users.map(u => (
<UserMgmtRow
key={u.id}
user={u}
libraries={libraries}
isSelf={u.userName === currentUsername}
busy={busy}
onEdit={setEditing}
onRequestDelete={setConfirmingDelete}
onRequestMagic={setMagicRowUser}
t={t}
i18n={i18n}
/>
))}
</div>
)}
</>
)}
<ConfirmModal
open={!!confirmingDelete}
title={t('settings.userMgmtDelete')}
message={confirmingDelete
? t('settings.userMgmtConfirmDelete', { username: confirmingDelete.userName })
: ''}
confirmLabel={t('settings.userMgmtDelete')}
cancelLabel={t('settings.userMgmtCancel')}
danger
onConfirm={() => {
if (!confirmingDelete) return;
const target = confirmingDelete;
setConfirmingDelete(null);
void performDelete(target);
}}
onCancel={() => setConfirmingDelete(null)}
/>
{magicRowUser && (
<MagicStringModal
user={magicRowUser}
serverUrl={serverUrl}
token={token}
onClose={() => setMagicRowUser(null)}
onSuccess={load}
t={t}
/>
)}
</section>
);
}
@@ -0,0 +1,100 @@
import React from 'react';
import { invoke } from '@tauri-apps/api/core';
import { AudioLines, RotateCcw } from 'lucide-react';
import type { TFunction } from 'i18next';
import CustomSelect from '@/ui/CustomSelect';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { useAuthStore } from '@/store/authStore';
import { useEqStore } from '@/store/eqStore';
import { buildAudioDeviceSelectOptions } from '@/utils/audio/audioDeviceLabels';
interface Props {
audioDevices: string[];
osDefaultAudioDeviceId: string | null;
deviceSwitching: boolean;
devicesLoading: boolean;
setDeviceSwitching: (v: boolean) => void;
refreshAudioDevices: (opts?: { silent?: boolean }) => void;
t: TFunction;
}
/**
* Audio output device picker. Not rendered on macOS — the audio stream is
* pinned to the system default there, so the whole category is gated out by
* the caller (`AudioTab`).
*
* The device switch is best-effort: if `audio_set_device` rejects (e.g.
* device disappeared) we leave the previous selection in the store.
*/
export function AudioOutputDeviceSection({
audioDevices,
osDefaultAudioDeviceId,
deviceSwitching,
devicesLoading,
setDeviceSwitching,
refreshAudioDevices,
t,
}: Props) {
const audioOutputDevice = useAuthStore(s => s.audioOutputDevice);
const setAudioOutputDevice = useAuthStore(s => s.setAudioOutputDevice);
const rememberEqPerDevice = useEqStore(s => s.rememberPerDevice);
const setRememberEqPerDevice = useEqStore(s => s.setRememberPerDevice);
return (
<SettingsSubSection
title={t('settings.audioOutputDevice')}
icon={<AudioLines size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.audioOutputDeviceDesc')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<CustomSelect
style={{ flex: 1 }}
value={audioOutputDevice ?? ''}
disabled={deviceSwitching || devicesLoading}
onChange={async (val) => {
const device = val || null;
setDeviceSwitching(true);
try {
await invoke('audio_set_device', { deviceName: device });
setAudioOutputDevice(device);
} catch { /* device open failed — don't persist */ }
setDeviceSwitching(false);
}}
options={buildAudioDeviceSelectOptions(
audioDevices,
t('settings.audioOutputDeviceDefault'),
osDefaultAudioDeviceId,
t('settings.audioOutputDeviceOsDefaultNow'),
audioOutputDevice,
t('settings.audioOutputDeviceNotInCurrentList'),
)}
/>
<button
className="icon-btn"
onClick={() => refreshAudioDevices()}
disabled={devicesLoading || deviceSwitching}
data-tooltip={t('settings.audioOutputDeviceRefresh')}
>
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
</button>
</div>
</SettingsGroup>
<SettingsGroup>
<SettingsToggle
label={t('settings.audioOutputDeviceRememberEq')}
desc={t('settings.audioOutputDeviceRememberEqDesc')}
checked={rememberEqPerDevice}
onChange={setRememberEqPerDevice}
searchText={t('settings.audioOutputDeviceRememberEq')}
/>
</SettingsGroup>
</div>
</SettingsSubSection>
);
}
@@ -0,0 +1,53 @@
import {
HI_RES_CROSSFADE_RESAMPLE_OPTIONS,
type HiResCrossfadeResampleHz,
sanitizeHiResCrossfadeResampleHz,
} from '@/utils/audio/hiResCrossfadeResample';
import type { TFunction } from 'i18next';
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
interface Props {
enabled: boolean;
resampleHz: HiResCrossfadeResampleHz;
onResampleHzChange: (hz: HiResCrossfadeResampleHz) => void;
t: TFunction;
}
function labelForHz(t: TFunction, hz: HiResCrossfadeResampleHz): string {
if (hz === 88_200) return t('settings.hiResCrossfadeResample88');
if (hz === 96_000) return t('settings.hiResCrossfadeResample96');
return t('settings.hiResCrossfadeResample44');
}
/** Hi-Res crossfade / AutoDJ / gapless blend-rate picker (visible when hi-res is on). */
export function HiResCrossfadeResampleBlock({
enabled,
resampleHz,
onResampleHzChange,
t,
}: Props) {
if (!enabled) return null;
return (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField
label={t('settings.hiResCrossfadeResampleTitle')}
desc={t('settings.hiResCrossfadeResampleDesc')}
note={t('settings.hiResCrossfadeResampleWarning')}
>
<div className="settings-segmented">
{HI_RES_CROSSFADE_RESAMPLE_OPTIONS.map((hz) => (
<button
key={hz}
type="button"
className={`btn ${resampleHz === hz ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => onResampleHzChange(sanitizeHiResCrossfadeResampleHz(hz))}
>
{labelForHz(t, hz)}
</button>
))}
</div>
</SettingsField>
</SettingsSubCard>
);
}
@@ -0,0 +1,171 @@
import React from 'react';
import { RotateCcw } from 'lucide-react';
import type { TFunction } from 'i18next';
import { useAuthStore } from '@/store/authStore';
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '@/store/authStoreDefaults';
import { LoudnessLufsButtonGroup } from '@/features/settings/components/LoudnessLufsButtonGroup';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsSubCard, SettingsField, SettingsValue, SettingsCallout } from '@/features/settings/components/SettingsSubCard';
interface Props {
preAnalysisEffectiveDb: number;
t: TFunction;
}
/**
* Normalization engine picker (Off / ReplayGain / LUFS) plus the
* engine-specific configuration blocks.
*
* - ReplayGain → mode (auto/track/album), pre-gain slider, fallback gain.
* `auto` mode toggles between track/album based on what the playlist
* provides; the help line explains that.
* - Loudness → target LUFS button group + pre-analysis attenuation slider
* with reset-to-default. The effective dB readout reflects how much
* headroom is being applied for the current target.
*
* Switching engines clears the other engine's enabled flag so only one
* can be live at a time.
*
* Rendered as its own top-level "Normalization" category in the Audio tab, so
* the boxed `SettingsGroup` is title-less — the `SettingsSubSection` header and
* description name it.
*/
export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
const auth = useAuthStore();
return (
<SettingsGroup>
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
<button
type="button"
className={`btn ${auth.normalizationEngine === 'off' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => {
auth.setReplayGainEnabled(false);
auth.setNormalizationEngine('off');
}}
>
{t('settings.normalizationOff')}
</button>
<button
type="button"
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => {
auth.setReplayGainEnabled(true);
auth.setNormalizationEngine('replaygain');
}}
>
{t('settings.normalizationReplayGain')}
</button>
<button
type="button"
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => {
auth.setReplayGainEnabled(false);
if (auth.normalizationEngine !== 'loudness') auth.setLoudnessTargetLufs(-12);
auth.setNormalizationEngine('loudness');
}}
>
{t('settings.normalizationLufs')}
</button>
</div>
{auth.normalizationEngine === 'replaygain' && (
<SettingsSubCard>
<SettingsField
label={t('settings.replayGainMode')}
desc={auth.replayGainMode === 'auto' ? t('settings.replayGainAutoDesc') : undefined}
row
>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<button
className={`btn ${auth.replayGainMode === 'auto' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => auth.setReplayGainMode('auto')}
>
{t('settings.replayGainAuto')}
</button>
<button
className={`btn ${auth.replayGainMode === 'track' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => auth.setReplayGainMode('track')}
>
{t('settings.replayGainTrack')}
</button>
<button
className={`btn ${auth.replayGainMode === 'album' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => auth.setReplayGainMode('album')}
>
{t('settings.replayGainAlbum')}
</button>
</div>
</SettingsField>
<SettingsField label={t('settings.replayGainPreGain')} desc={t('settings.replayGainPreGainDesc')} row>
<input
type="range" min={0} max={6} step={0.5}
value={auth.replayGainPreGainDb}
onChange={e => auth.setReplayGainPreGainDb(Number(e.target.value))}
/>
<SettingsValue>
{auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
</SettingsValue>
</SettingsField>
<SettingsField label={t('settings.replayGainFallback')} desc={t('settings.replayGainFallbackDesc')} row>
<input
type="range" min={-6} max={0} step={0.5}
value={auth.replayGainFallbackDb}
onChange={e => auth.setReplayGainFallbackDb(Number(e.target.value))}
/>
<SettingsValue>
{auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
</SettingsValue>
</SettingsField>
</SettingsSubCard>
)}
{auth.normalizationEngine === 'loudness' && (
<SettingsSubCard>
<SettingsField label={t('settings.loudnessTargetLufs')} desc={t('settings.loudnessTargetLufsDesc')} row>
<LoudnessLufsButtonGroup value={auth.loudnessTargetLufs} onSelect={auth.setLoudnessTargetLufs} />
</SettingsField>
<SettingsField
label={t('settings.loudnessPreAnalysisAttenuation')}
desc={
<>
{t('settings.loudnessPreAnalysisAttenuationDesc')}{' '}
{t('settings.loudnessPreAnalysisAttenuationRef', {
ref: auth.loudnessPreAnalysisAttenuationDb,
eff: preAnalysisEffectiveDb,
tgt: auth.loudnessTargetLufs,
})}
</>
}
row
>
<input
type="range"
min={-24}
max={0}
step={0.5}
value={auth.loudnessPreAnalysisAttenuationDb}
onChange={e => auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
/>
<SettingsValue>{preAnalysisEffectiveDb} dB</SettingsValue>
<button
type="button"
className="icon-btn"
style={{ flexShrink: 0 }}
disabled={
auth.loudnessPreAnalysisAttenuationDb === DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB
}
onClick={() => auth.resetLoudnessPreAnalysisAttenuationDbDefault()}
data-tooltip={t('settings.loudnessPreAnalysisAttenuationReset')}
aria-label={t('settings.loudnessPreAnalysisAttenuationReset')}
>
<RotateCcw size={15} />
</button>
</SettingsField>
<SettingsCallout>{t('settings.loudnessFirstPlayNote')}</SettingsCallout>
</SettingsSubCard>
)}
</SettingsGroup>
);
}
@@ -0,0 +1,296 @@
import React, { useCallback } from 'react';
import type { TFunction } from 'i18next';
import {
PLAYBACK_PITCH_MAX,
PLAYBACK_PITCH_MIN,
PLAYBACK_SPEED_MAX,
PLAYBACK_SPEED_MIN,
PLAYBACK_SPEED_PRESETS,
PLAYBACK_STRATEGIES,
clampPlaybackPitch,
clampPlaybackSpeed,
derivedVarispeedSemitones,
formatPitchLabel,
formatSpeedLabel,
isPlaybackRateApplied,
playbackPitchStep,
playbackSpeedStep,
varispeedSpeedFromSemitones,
type PlaybackStrategy,
} from '@/utils/audio/playbackRateHelpers';
import { usePlaybackRateStore } from '@/store/playbackRateStore';
import { useOrbitStore } from '@/store/orbitStore';
import { useAuthStore } from '@/store/authStore';
import { isOrbitPlaybackSyncActive } from '@/utils/orbit';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard } from '@/features/settings/components/SettingsSubCard';
interface Props {
t: TFunction;
/** When false, hide master enable (player popup). */
showEnable?: boolean;
}
export function PlaybackRateControls({ t, showEnable = true }: Props) {
const compact = !showEnable;
const enabled = usePlaybackRateStore(s => s.enabled);
const strategy = usePlaybackRateStore(s => s.strategy);
const speed = usePlaybackRateStore(s => s.speed);
const pitchSemitones = usePlaybackRateStore(s => s.pitchSemitones);
const fineStep = usePlaybackRateStore(s => s.fineStep);
const {
setEnabled,
setStrategy,
setSpeed,
setPitchSemitones,
applyPresetSpeed,
setFineStep,
} = usePlaybackRateStore();
const orbitRole = useOrbitStore(s => s.role);
const orbitPhase = useOrbitStore(s => s.phase);
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
const orbitActive = isOrbitPlaybackSyncActive(orbitRole, orbitPhase);
const effectActive = isPlaybackRateApplied(enabled, strategy, speed, pitchSemitones, orbitActive);
const derivedPitch = derivedVarispeedSemitones(speed);
const speedStep = playbackSpeedStep(fineStep);
const pitchStep = playbackPitchStep(fineStep);
const pitchDecimals = fineStep ? 2 : 1;
const strategyLabel = (s: PlaybackStrategy) => {
switch (s) {
case 'speed_corrected':
return t('settings.playbackRateStrategySpeed');
case 'varispeed':
return t('settings.playbackRateStrategyVarispeed');
case 'varispeed_semitones':
return t('settings.playbackRateStrategyVarispeedSemitones');
case 'preserve_pitch':
return t('settings.playbackRateStrategyPreserve');
}
};
const strategyTip = (s: PlaybackStrategy) => {
switch (s) {
case 'speed_corrected':
return t('settings.playbackRateStrategySpeedTip');
case 'varispeed':
return t('settings.playbackRateStrategyVarispeedTip');
case 'varispeed_semitones':
return t('settings.playbackRateStrategyVarispeedSemitonesTip');
case 'preserve_pitch':
return t('settings.playbackRateStrategyPreserveTip');
}
};
const handleWheelSpeed = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!compact || !enabled) return;
e.preventDefault();
e.stopPropagation();
if (strategy === 'varispeed_semitones') {
const step = e.deltaY > 0 ? -pitchStep : pitchStep;
const st = clampPlaybackPitch(derivedVarispeedSemitones(speed) + step);
setSpeed(clampPlaybackSpeed(varispeedSpeedFromSemitones(st)));
return;
}
const delta = e.deltaY > 0 ? -speedStep : speedStep;
setSpeed(clampPlaybackSpeed(speed + delta));
}, [compact, enabled, strategy, speed, speedStep, pitchStep, setSpeed]);
const handleWheelPitch = useCallback((e: React.WheelEvent<HTMLElement>) => {
if (!compact || !enabled || strategy !== 'preserve_pitch') return;
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? -pitchStep : pitchStep;
setPitchSemitones(clampPlaybackPitch(pitchSemitones + delta));
}, [compact, enabled, strategy, pitchSemitones, pitchStep, setPitchSemitones]);
return (
<div
className={`playback-rate-controls${compact ? ' playback-rate-controls--compact' : ''}`}
onWheel={compact ? handleWheelSpeed : undefined}
>
{showEnable && (
<SettingsToggle
label={t('settings.playbackRateEnabled')}
desc={t('settings.playbackRateEnabledDesc')}
checked={enabled}
onChange={setEnabled}
/>
)}
{(!showEnable || enabled) && (() => {
const body = (
<>
<div className="playback-rate-strategy-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRateStrategy')}</span>
)}
<div className="playback-rate-strategy-btns">
{PLAYBACK_STRATEGIES.map(s => (
<button
key={s}
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setStrategy(s)}
data-tooltip={strategyTip(s)}
data-tooltip-wrap=""
>
{strategyLabel(s)}
</button>
))}
</div>
</div>
{strategy !== 'varispeed_semitones' && (
<div className="playback-rate-slider-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRateSpeed')}</span>
)}
<input
type="range"
min={PLAYBACK_SPEED_MIN}
max={PLAYBACK_SPEED_MAX}
step={speedStep}
value={speed}
onChange={e => setSpeed(parseFloat(e.target.value))}
className="playback-rate-slider"
aria-label={t('settings.playbackRateSpeed')}
/>
<span className="playback-rate-value">{formatSpeedLabel(speed)}</span>
</div>
)}
{strategy === 'varispeed_semitones' && (
<div className="playback-rate-slider-row">
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRatePitch')}</span>
)}
<input
type="range"
min={PLAYBACK_PITCH_MIN}
max={PLAYBACK_PITCH_MAX}
step={pitchStep}
value={clampPlaybackPitch(derivedPitch)}
onChange={e =>
setSpeed(clampPlaybackSpeed(varispeedSpeedFromSemitones(parseFloat(e.target.value))))
}
className="playback-rate-slider"
aria-label={t('settings.playbackRatePitch')}
/>
<span className="playback-rate-value">{formatPitchLabel(derivedPitch, pitchDecimals)}</span>
</div>
)}
<div className="playback-rate-presets">
{PLAYBACK_SPEED_PRESETS.map(preset => (
<button
key={preset}
type="button"
className={`btn btn-sm ${Math.abs(speed - preset) < 0.001 ? 'btn-primary' : 'btn-surface'}`}
onClick={() => applyPresetSpeed(preset)}
>
{formatSpeedLabel(preset)}
</button>
))}
</div>
{strategy === 'varispeed' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateDerivedPitch', {
value: formatPitchLabel(derivedPitch, pitchDecimals),
})}
</div>
)}
{strategy === 'varispeed_semitones' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateDerivedSpeed', {
value: formatSpeedLabel(speed),
})}
</div>
)}
{strategy === 'speed_corrected' && !compact && (
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateAutoPitch')}
</div>
)}
{strategy === 'preserve_pitch' && (
<div className="playback-rate-slider-row" onWheel={compact ? handleWheelPitch : undefined}>
{!compact && (
<span className="playback-rate-label">{t('settings.playbackRatePitch')}</span>
)}
<input
type="range"
min={PLAYBACK_PITCH_MIN}
max={PLAYBACK_PITCH_MAX}
step={pitchStep}
value={pitchSemitones}
onChange={e => setPitchSemitones(parseFloat(e.target.value))}
className="playback-rate-slider"
aria-label={t('settings.playbackRatePitch')}
/>
<span className="playback-rate-value">{formatPitchLabel(pitchSemitones, pitchDecimals)}</span>
</div>
)}
{!compact && advancedSettingsEnabled && (
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}>
{t('settings.playbackRateFineStep')}
<span
className="settings-sub-section-advanced-badge"
style={{ marginRight: 0 }}
aria-hidden="true"
>
{t('settings.advancedBadge')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateFineStepDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.playbackRateFineStep')}>
<input
type="checkbox"
checked={fineStep}
onChange={e => setFineStep(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
)}
{!compact && (
<p className="playback-rate-hint" style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
{t('settings.playbackRateHint')}
</p>
)}
{orbitActive && enabled && (
<p className="playback-rate-orbit" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t(compact ? 'settings.playbackRateOrbitPausedShort' : 'settings.playbackRateOrbitPaused')}
</p>
)}
{!compact && !effectActive && enabled && !orbitActive && (
<p className="playback-rate-neutral" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('settings.playbackRateNeutral')}
</p>
)}
</>
);
return compact
? body
: <SettingsSubCard style={{ marginTop: '0.85rem' }}>{body}</SettingsSubCard>;
})()}
</div>
);
}
export function PlaybackRateBlock({ t }: { t: TFunction }) {
return <PlaybackRateControls t={t} showEnable />;
}
@@ -0,0 +1,103 @@
import React from 'react';
import { Play } from 'lucide-react';
import type { TFunction } from 'i18next';
import { useAuthStore } from '@/store/authStore';
import { TRACK_PREVIEW_LOCATIONS } from '@/store/authStoreDefaults';
import type { TrackPreviewLocation } from '@/store/authStoreTypes';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsValue } from '@/features/settings/components/SettingsSubCard';
interface Props {
t: TFunction;
}
/**
* Track previews subsection: master toggle on top, then (when enabled)
* a per-location toggle grid, a "start at %" slider, and a duration
* slider. Locations come from `TRACK_PREVIEW_LOCATIONS` so adding a new
* surface (Search, Now Playing suggestions, …) only needs a single
* source-of-truth update.
*/
export function TrackPreviewsSection({ t }: Props) {
const auth = useAuthStore();
return (
<SettingsSubSection
title={t('settings.trackPreviewsTitle')}
icon={<Play size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsToggle
label={t('settings.trackPreviewsToggle')}
desc={t('settings.trackPreviewsDesc')}
checked={auth.trackPreviewsEnabled}
onChange={auth.setTrackPreviewsEnabled}
/>
{auth.trackPreviewsEnabled && (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField
label={t('settings.trackPreviewLocationsTitle')}
desc={t('settings.trackPreviewLocationsDesc')}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => (
<div key={loc} className="settings-toggle-row" style={{ padding: '6px var(--space-3)' }}>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t(`settings.trackPreviewLocation_${loc}`)}
</div>
<label className="toggle-switch" aria-label={t(`settings.trackPreviewLocation_${loc}`)}>
<input type="checkbox" checked={auth.trackPreviewLocations[loc]}
onChange={e => auth.setTrackPreviewLocation(loc, e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
))}
</div>
</SettingsField>
<SettingsField
label={t('settings.trackPreviewStart')}
desc={t('settings.trackPreviewStartDesc')}
row
>
<input
type="range"
min={0}
max={0.9}
step={0.01}
value={auth.trackPreviewStartRatio}
onChange={e => auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
aria-label={t('settings.trackPreviewStart')}
/>
<SettingsValue>{Math.round(auth.trackPreviewStartRatio * 100)}%</SettingsValue>
</SettingsField>
<SettingsField
label={t('settings.trackPreviewDuration')}
desc={t('settings.trackPreviewDurationDesc')}
row
>
<input
type="range"
min={5}
max={60}
step={1}
value={auth.trackPreviewDurationSec}
onChange={e => auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
aria-label={t('settings.trackPreviewDuration')}
/>
<SettingsValue>
{t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
</SettingsValue>
</SettingsField>
</SettingsSubCard>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
);
}
@@ -0,0 +1,134 @@
import React from 'react';
import type { TFunction } from 'i18next';
import { useAuthStore } from '@/store/authStore';
import { useOrbitStore } from '@/store/orbitStore';
import {
AUTODJ_OVERLAP_CAP_MAX_SEC,
AUTODJ_OVERLAP_CAP_MIN_SEC,
} from '@/utils/playback/autodjOverlapCap';
import {
getTransitionMode,
setTransitionMode,
type TransitionMode,
} from '@/utils/playback/playbackTransition';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsRow, SettingsValue } from '@/features/settings/components/SettingsSubCard';
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
interface Props {
t: TFunction;
}
/**
* Track-transition picker. Crossfade, AutoDJ and Gapless are mutually
* exclusive — only one can be active — so they are presented as a single
* `Off | Gapless | Crossfade | AutoDJ` segmented control backed by the shared
* transition-mode helper.
*
* Classic crossfade exposes the seconds slider; AutoDJ is content-driven and
* exposes an optional overlap cap (auto vs manual limit).
*
* Rendered as its own top-level "Track transitions" category in the Audio tab,
* so the boxed `SettingsGroup` is title-less — the `SettingsSubSection` header
* names it.
*/
export function TrackTransitionsBlock({ t }: Props) {
const auth = useAuthStore();
const mode = getTransitionMode(auth);
// While a guest in a live Orbit session, transitions mirror the host's and
// are re-applied every read tick — let the user see them but not fight the
// sync. Restored to their own on leave.
const hostControlled = useOrbitStore(
s => s.role === 'guest' && (s.phase === 'active' || s.phase === 'joining'),
);
const transitions: SegmentedOption<TransitionMode>[] = [
{ id: 'none', label: t('settings.transitionOff') },
{ id: 'gapless', label: t('settings.gapless') },
{ id: 'crossfade', label: t('settings.crossfade') },
{ id: 'autodj', label: t('settings.autoDj') },
];
const overlapCapOptions: SegmentedOption<'auto' | 'limit'>[] = [
{ id: 'auto', label: t('settings.autodjOverlapCapAuto') },
{ id: 'limit', label: t('settings.autodjOverlapCapLimit') },
];
return (
<SettingsGroup>
{hostControlled && (
<div style={{ marginBottom: '0.6rem', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.transitionsHostControlled')}
</div>
)}
<SettingsSegmented
options={transitions}
value={mode}
onChange={setTransitionMode}
disabled={hostControlled}
style={hostControlled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}
/>
{mode === 'crossfade' && (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsRow>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={auth.crossfadeSecs}
disabled={hostControlled}
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
id="crossfade-secs-slider"
/>
<SettingsValue>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
</SettingsValue>
</SettingsRow>
</SettingsSubCard>
)}
{mode === 'autodj' && (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField desc={t('settings.autoDjDesc')} />
<SettingsField
label={t('settings.autodjOverlapCapTitle')}
desc={t('settings.autodjOverlapCapDesc')}
>
<SettingsSegmented
options={overlapCapOptions}
value={auth.autodjOverlapCapMode}
onChange={auth.setAutodjOverlapCapMode}
disabled={hostControlled}
/>
{auth.autodjOverlapCapMode === 'limit' && (
<SettingsRow>
<input
type="range"
min={AUTODJ_OVERLAP_CAP_MIN_SEC}
max={AUTODJ_OVERLAP_CAP_MAX_SEC}
step={1}
value={auth.autodjOverlapCapSec}
disabled={hostControlled}
onChange={e => auth.setAutodjOverlapCapSec(parseInt(e.target.value, 10))}
id="autodj-overlap-cap-slider"
/>
<SettingsValue>
{t('settings.autodjOverlapCapSecs', { n: auth.autodjOverlapCapSec })}
</SettingsValue>
</SettingsRow>
)}
</SettingsField>
<SettingsToggle
label={t('settings.autodjSmoothSkip')}
desc={t('settings.autodjSmoothSkipDesc')}
checked={auth.autodjSmoothSkip}
disabled={hostControlled}
onChange={auth.setAutodjSmoothSkip}
/>
</SettingsSubCard>
)}
</SettingsGroup>
);
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { moveSourceTo, dropSourceBefore } from '@/features/settings/components/backdropReorder';
import type { BackdropSource, BackdropSourcePref } from '@/cover/artistBackdrop';
const S = (...names: BackdropSource[]): BackdropSourcePref[] =>
names.map((source) => ({ source, enabled: true }));
const ids = (arr: BackdropSourcePref[] | null) => arr?.map((p) => p.source);
describe('moveSourceTo (↑/↓ buttons — land at exact index)', () => {
it('moves an item up to a lower index', () => {
expect(ids(moveSourceTo(S('navidrome', 'banner', 'fanart'), 2, 0))).toEqual(['fanart', 'navidrome', 'banner']);
});
it('moves an item down to a higher index', () => {
expect(ids(moveSourceTo(S('banner', 'fanart', 'navidrome'), 0, 1))).toEqual(['fanart', 'banner', 'navidrome']);
});
it('returns null on a no-op or out-of-range move', () => {
expect(moveSourceTo(S('banner', 'fanart'), 1, 1)).toBeNull();
expect(moveSourceTo(S('banner', 'fanart'), 0, 5)).toBeNull();
});
});
describe('dropSourceBefore (drag-drop — insert before target row)', () => {
it('drops before a lower row (dragging up)', () => {
expect(ids(dropSourceBefore(S('banner', 'fanart', 'navidrome'), 2, 0))).toEqual(['navidrome', 'banner', 'fanart']);
});
it('drops before a higher row (dragging down), accounting for the vacated slot', () => {
// drag `banner` onto `navidrome` → lands between fanart and navidrome
expect(ids(dropSourceBefore(S('banner', 'fanart', 'navidrome'), 0, 2))).toEqual(['fanart', 'banner', 'navidrome']);
});
it('dropping just before the next row leaves the order unchanged', () => {
expect(ids(dropSourceBefore(S('banner', 'fanart', 'navidrome'), 0, 1))).toEqual(['banner', 'fanart', 'navidrome']);
});
it('returns null when dropping a row onto itself', () => {
expect(dropSourceBefore(S('banner', 'fanart'), 1, 1)).toBeNull();
});
});
@@ -0,0 +1,36 @@
import type { BackdropSourcePref } from '@/cover/artistBackdrop';
/**
* Move the source at `from` so it lands at exactly index `to` — the ↑/↓ buttons,
* i.e. a swap with the neighbour. Returns a new array, or `null` for an
* out-of-range or no-op move (so the caller can skip a redundant update).
*/
export function moveSourceTo(
sources: BackdropSourcePref[],
from: number,
to: number,
): BackdropSourcePref[] | null {
if (from === to || from < 0 || to < 0 || from >= sources.length || to >= sources.length) return null;
const next = sources.slice();
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
/**
* Insert the source at `from` immediately before the row at `beforeIndex` — the
* drag-drop case, matching the "line above this row" drop indicator. The -1 when
* dragging downward accounts for the slot the dragged item vacated. Returns a
* new array, or `null` for an out-of-range or no-op move.
*/
export function dropSourceBefore(
sources: BackdropSourcePref[],
from: number,
beforeIndex: number,
): BackdropSourcePref[] | null {
if (from === beforeIndex || from < 0 || from >= sources.length) return null;
const next = sources.slice();
const [item] = next.splice(from, 1);
next.splice(from < beforeIndex ? beforeIndex - 1 : beforeIndex, 0, item);
return next;
}
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
errorI18nKey,
isMusicNetworkError,
listPresets,
type BuiltinPreset,
type PresetId,
} from '@/music-network';
import { renderPresetIcon } from '@/components/settings/musicNetwork/presetIcon';
/**
* "Add a service" list, driven entirely by the preset registry. Token-poll
* presets connect immediately (browser flow); paste presets expand an inline
* form built from the manifest's `fields`. Adding a provider needs no edit here.
*/
export function ConnectProviderForm({
connectedPresetIds,
onConnect,
}: {
connectedPresetIds: PresetId[];
onConnect: (presetId: PresetId, fields: Record<string, string>) => Promise<void>;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<PresetId | null>(null);
const [fields, setFields] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<PresetId | null>(null);
const [error, setError] = useState<string | null>(null);
// Bundled single-instance presets disappear once connected; self-hosted /
// custom presets can be added repeatedly.
const available = listPresets().filter(
p => !(p.manifest.credentials === 'bundled' && connectedPresetIds.includes(p.manifest.presetId)),
);
const toMessage = (e: unknown): string =>
isMusicNetworkError(e) ? t(errorI18nKey(e.code)) : t('musicNetwork.connectFailed');
const run = async (presetId: PresetId, payload: Record<string, string>) => {
// Enforce the manifest's `required` fields client-side so an empty URL/token
// gives a clear message instead of falling through to a confusing NETWORK error.
const preset = available.find(p => p.manifest.presetId === presetId);
const missing = preset?.manifest.fields.find(f => f.required && !(payload[f.name] ?? '').trim());
if (missing) {
setError(t('musicNetwork.fieldRequired', { field: t(missing.labelKey) }));
return;
}
setBusy(presetId);
setError(null);
try {
await onConnect(presetId, payload);
setExpanded(null);
setFields({});
} catch (e) {
setError(toMessage(e));
} finally {
setBusy(null);
}
};
const onPrimaryAction = (preset: BuiltinPreset) => {
const id = preset.manifest.presetId;
if (preset.manifest.fields.length === 0) {
void run(id, {});
} else {
setError(null);
setFields({});
setExpanded(expanded === id ? null : id);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div className="settings-group-title">{t('musicNetwork.addService')}</div>
{available.map(preset => {
const id = preset.manifest.presetId;
const isExpanded = expanded === id;
const isBusy = busy === id;
return (
<div key={id} className="settings-group" style={{ padding: '0.75rem 1rem', marginTop: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>{preset.manifest.displayName}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(preset.manifest.descriptionKey)}</div>
</div>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px', flexShrink: 0 }}
disabled={isBusy}
onClick={() => onPrimaryAction(preset)}
>
{isBusy ? t('musicNetwork.connecting') : t('musicNetwork.connect')}
</button>
</div>
{isExpanded && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.75rem' }}>
{preset.manifest.fields.map(field => (
<div className="form-group" key={field.name}>
<label style={{ fontSize: 12 }}>{t(field.labelKey)}</label>
<input
className="input"
type={field.type === 'password' ? 'password' : field.type === 'url' ? 'url' : 'text'}
placeholder={field.placeholder}
value={fields[field.name] ?? ''}
onChange={e => setFields(f => ({ ...f, [field.name]: e.target.value }))}
/>
{field.helpKey && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, whiteSpace: 'pre-line', lineHeight: 1.5 }}>
{t(field.helpKey)}
</div>
)}
</div>
))}
<button
className="btn btn-primary"
style={{ alignSelf: 'flex-start', fontSize: 12 }}
disabled={isBusy}
onClick={() => void run(id, fields)}
>
{isBusy ? t('musicNetwork.connecting') : t('musicNetwork.connect')}
</button>
</div>
)}
</div>
);
})}
{error && <p style={{ fontSize: 12, color: 'var(--danger)' }}>{error}</p>}
</div>
);
}
@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next';
import CustomSelect from '@/ui/CustomSelect';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import type { Account } from '@/music-network';
/**
* Picks the single enrichment primary (love / similar / stats source). Only
* enrichment-eligible accounts are offered; Maloja / ListenBrainz never appear.
* Hidden when there are no eligible accounts.
*/
export function EnrichmentPrimarySelect({
accounts,
primaryId,
onChange,
}: {
accounts: Account[];
primaryId: string | null;
onChange: (id: string | null) => void;
}) {
const { t } = useTranslation();
const candidates = accounts.filter(a => a.roles.enrichmentEligible);
if (candidates.length === 0) return null;
const options = [
{ value: '', label: t('musicNetwork.primaryNone') },
...candidates.map(a => ({ value: a.id, label: a.label })),
];
return (
<SettingsGroup title={t('musicNetwork.primaryLabel')}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
<CustomSelect
value={primaryId ?? ''}
options={options}
onChange={v => onChange(v || null)}
style={{ minWidth: 180 }}
/>
</div>
</SettingsGroup>
);
}
@@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next';
import { AlertTriangle } from 'lucide-react';
import type { Account } from '@/music-network';
/**
* Shown when a Maloja account is connected AND Last.fm scrobbling is enabled —
* Maloja can forward scrobbles to Last.fm, so both paths active risks duplicates.
*/
export function MalojaProxyWarning({ accounts }: { accounts: Account[] }) {
const { t } = useTranslation();
const hasMaloja = accounts.some(a => a.presetId.startsWith('maloja'));
const lastfmScrobbling = accounts.some(a => a.presetId === 'lastfm' && a.scrobbleEnabled);
if (!hasMaloja || !lastfmScrobbling) return null;
return (
<div className="settings-privacy-notice" role="note" style={{ marginTop: '0.5rem' }}>
<AlertTriangle size={16} className="settings-privacy-notice-icon" aria-hidden="true" />
<div className="settings-privacy-notice-body">{t('musicNetwork.malojaProxyWarning')}</div>
</div>
);
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Share2 } from 'lucide-react';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { showToast } from '@/utils/ui/toast';
import { useAuthStore } from '@/store/authStore';
import {
errorI18nKey,
getMusicNetworkRuntime,
isMusicNetworkError,
type PresetId,
type UserProfile,
} from '@/music-network';
import { useMusicNetworkState } from '@/features/settings/components/musicNetwork/useMusicNetworkState';
import { ScrobbleDestinationCard } from '@/features/settings/components/musicNetwork/ScrobbleDestinationCard';
import { EnrichmentPrimarySelect } from '@/features/settings/components/musicNetwork/EnrichmentPrimarySelect';
import { ConnectProviderForm } from '@/features/settings/components/musicNetwork/ConnectProviderForm';
import { MalojaProxyWarning } from '@/features/settings/components/musicNetwork/MalojaProxyWarning';
/**
* Integrations UI for the Music Network framework — replaces the old Last.fm
* card. Manifest-driven: connected destinations, the enrichment-primary picker,
* the Maloja proxy warning, and the add-a-service list all come from the
* registry. Mutations go through the runtime; state is read reactively from the
* auth store (see useMusicNetworkState).
*/
export function MusicNetworkSection() {
const { t } = useTranslation();
const { accounts, enrichmentPrimaryId, scrobblingMasterEnabled } = useMusicNetworkState();
const [primaryProfile, setPrimaryProfile] = useState<UserProfile | null>(null);
// Profile stats (scrobbles / member-since) for the enrichment primary.
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!enrichmentPrimaryId) { setPrimaryProfile(null); return; }
let cancelled = false;
setPrimaryProfile(null);
getMusicNetworkRuntime().getUserProfile()
.then(p => { if (!cancelled) setPrimaryProfile(p); })
.catch(() => { if (!cancelled) setPrimaryProfile(null); });
return () => { cancelled = true; };
}, [enrichmentPrimaryId]);
const setMaster = (v: boolean) => useAuthStore.getState().setScrobblingMasterEnabled(v);
const toggleScrobble = (id: string, v: boolean) =>
getMusicNetworkRuntime().updateAccount(id, { scrobbleEnabled: v });
const disconnect = (id: string) => getMusicNetworkRuntime().disconnect(id);
const setPrimary = (id: string | null) => {
try {
getMusicNetworkRuntime().setEnrichmentPrimaryId(id);
} catch (e) {
showToast(isMusicNetworkError(e) ? t(errorI18nKey(e.code)) : t('musicNetwork.connectFailed'), 4000, 'error');
}
};
const connect = async (presetId: PresetId, fields: Record<string, string>) => {
const account = await getMusicNetworkRuntime().connect(presetId, { fields });
// The wire's connect only checks the credential is present; for paste-auth
// providers the real validation is the capability probe. Surface a probe
// error (e.g. an invalid token) so the connect does not look silently OK.
const scrobble = account.capabilities?.scrobble;
if (scrobble?.status === 'error') {
showToast(
t('musicNetwork.connectProbeFailed', { provider: account.label, message: scrobble.message ?? '' }),
6000,
'error',
);
}
};
const connectedPresetIds = accounts.map(a => a.presetId);
return (
<SettingsSubSection title={t('musicNetwork.title')} icon={<Share2 size={16} />}>
<div className="settings-card">
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, marginBottom: '0.75rem' }}>
{t('musicNetwork.desc')}
</p>
<SettingsGroup title={t('musicNetwork.masterToggle')}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</SettingsGroup>
<EnrichmentPrimarySelect
accounts={accounts}
primaryId={enrichmentPrimaryId}
onChange={setPrimary}
/>
{accounts.length > 0 && (
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{accounts.map(account => (
<ScrobbleDestinationCard
key={account.id}
account={account}
profile={account.id === enrichmentPrimaryId ? primaryProfile : null}
onToggleScrobble={v => toggleScrobble(account.id, v)}
onDisconnect={() => disconnect(account.id)}
/>
))}
</div>
<MalojaProxyWarning accounts={accounts} />
</SettingsGroup>
)}
<div style={{ marginTop: 'var(--space-3)' }}>
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
</div>
</div>
</SettingsSubSection>
);
}
@@ -0,0 +1,93 @@
import { useTranslation } from 'react-i18next';
import { getPreset, type Account, type UserProfile } from '@/music-network';
import { renderPresetIcon } from '@/components/settings/musicNetwork/presetIcon';
/**
* One connected account as a single self-contained block: header (icon, label,
* status, optional profile stats for the enrichment primary) on top, and a
* footer row holding the per-account scrobble toggle + disconnect — so it is
* unambiguous which account the toggle belongs to.
*/
export function ScrobbleDestinationCard({
account,
profile,
onToggleScrobble,
onDisconnect,
}: {
account: Account;
profile: UserProfile | null;
onToggleScrobble: (enabled: boolean) => void;
onDisconnect: () => void;
}) {
const { t } = useTranslation();
const preset = getPreset(account.presetId);
const icon = preset?.manifest.icon ?? 'custom';
return (
<div
style={{
borderRadius: '10px',
background: 'color-mix(in srgb, var(--accent) 8%, transparent)',
border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)',
overflow: 'hidden',
}}
>
{/* Header: identity + status + profile stats */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem' }}>
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(icon, 20)}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontWeight: 600, fontSize: 14 }}>
{account.label}
<span
className={`connection-led connection-led--${account.sessionError ? 'disconnected' : 'connected'}`}
data-tooltip={account.sessionError ? t('musicNetwork.statusError') : t('musicNetwork.statusConnected')}
/>
</div>
{account.username && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>@{account.username}</div>
)}
{profile && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<span>{t('musicNetwork.scrobbles', { n: profile.playcount.toLocaleString() })}</span>
{profile.registeredAt > 0 && (
<span>{t('musicNetwork.memberSince', { year: new Date(profile.registeredAt * 1000).getFullYear() })}</span>
)}
</div>
)}
</div>
</div>
{/* Footer: the scrobble toggle (clearly inside this account's block) + disconnect */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.75rem',
padding: '0.6rem 1rem',
borderTop: '1px solid color-mix(in srgb, var(--accent) 15%, transparent)',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}>
<span className="toggle-switch" style={{ flexShrink: 0 }}>
<input
type="checkbox"
checked={account.scrobbleEnabled}
onChange={e => onToggleScrobble(e.target.checked)}
aria-label={t('musicNetwork.scrobbleHere')}
/>
<span className="toggle-track" />
</span>
{t('musicNetwork.scrobbleHere')}
</label>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={onDisconnect}
>
{t('musicNetwork.disconnect')}
</button>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/store/authStore';
import { getPreset, type Account } from '@/music-network';
/**
* Reactive view of the persisted Music Network state for the Integrations UI.
* Accounts re-render on any auth-store change; roles are derived from the preset
* manifest (static). Mutations go through the runtime (see MusicNetworkSection),
* which writes back to the store and re-renders this.
*/
export function useMusicNetworkState(): {
accounts: Account[];
enrichmentPrimaryId: string | null;
scrobblingMasterEnabled: boolean;
} {
const { accounts, enrichmentPrimaryId, scrobblingMasterEnabled } = useAuthStore(
useShallow(s => ({
accounts: s.musicNetworkAccounts,
enrichmentPrimaryId: s.enrichmentPrimaryId,
scrobblingMasterEnabled: s.scrobblingMasterEnabled,
})),
);
const richAccounts = useMemo<Account[]>(
() =>
accounts.map(a => ({
...a,
roles:
getPreset(a.presetId)?.manifest.defaultRoles
?? { scrobble: false, enrichmentEligible: false },
})),
[accounts],
);
return { accounts: richAccounts, enrichmentPrimaryId, scrobblingMasterEnabled };
}
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import type { TFunction } from 'i18next';
import { matchScore } from '@/features/settings/components/settingsTabs';
import { searchSettings } from '@/features/settings/components/settingsSearch';
const t = vi.fn((key: string) => {
const labels: Record<string, string> = {
'settings.audiomuseTitle': 'AudioMuse-AI (Navidrome)',
'settings.servers': 'Servers',
'settings.inputKeybindingsTitle': 'In-app shortcuts',
'settings.globalShortcutsTitle': 'Global shortcuts',
'settings.shortcutVolumeUp': 'Volume up',
'settings.shortcutVolumeDown': 'Volume down',
'settings.playbackTitle': 'Playback',
};
return labels[key] ?? key;
});
describe('matchScore', () => {
it('prefers earlier substring matches', () => {
expect(matchScore('volume up global shortcuts', 'volume')).toBeGreaterThan(0);
});
it('rejects repeated-character junk queries via fuzzy matching', () => {
expect(matchScore('database backup analytics strategy', 'aaaaaaa')).toBe(0);
});
it('still fuzzy-matches compact typos', () => {
expect(matchScore('equalizer eq bass treble', 'equl')).toBeGreaterThan(0);
});
});
describe('searchSettings', () => {
it('finds AudioMuse by product name', () => {
const hits = searchSettings('AudioMuse', 'library', t as unknown as TFunction);
expect(hits.some(h => h.title.includes('AudioMuse'))).toBe(true);
});
it('finds global volume shortcuts', () => {
const hits = searchSettings('Volume', 'library', t as unknown as TFunction);
expect(hits.some(h => h.title === 'Volume up')).toBe(true);
expect(hits.some(h => h.title === 'Volume down')).toBe(true);
});
it('returns nothing for nonsense queries', () => {
expect(searchSettings('aaaaaaa', 'library', t as unknown as TFunction)).toEqual([]);
});
});
@@ -0,0 +1,54 @@
import type { TFunction } from 'i18next';
import { GLOBAL_SHORTCUT_ACTIONS, IN_APP_SHORTCUT_ACTIONS } from '@/config/shortcutActions';
import { SETTINGS_INDEX, matchScore, type Tab } from '@/features/settings/components/settingsTabs';
export type SettingsSearchHit = {
tab: Tab;
title: string;
focusTitle: string;
score: number;
key: string;
};
export function searchSettings(query: string, activeTab: Tab, t: TFunction): SettingsSearchHit[] {
const q = query.trim();
if (!q) return [];
const hits: SettingsSearchHit[] = [];
for (const entry of SETTINGS_INDEX) {
const title = t(entry.titleKey);
const hay = entry.keywords ? `${title} ${entry.keywords}` : title;
const score = matchScore(hay, q);
if (score <= 0) continue;
const focusTitle = entry.focusTitleKey ? t(entry.focusTitleKey) : title;
hits.push({ tab: entry.tab, title, focusTitle, score, key: entry.titleKey });
}
const inAppSection = t('settings.inputKeybindingsTitle');
for (const { id, getLabel } of IN_APP_SHORTCUT_ACTIONS) {
const title = getLabel(t);
const hay = `${title} ${inAppSection} shortcut hotkey keyboard in-app ${id.replace(/-/g, ' ')}`;
const score = matchScore(hay, q);
if (score <= 0) continue;
hits.push({ tab: 'input', title, focusTitle: inAppSection, score, key: `in-app:${id}` });
}
const globalSection = t('settings.globalShortcutsTitle');
for (const { id, getLabel } of GLOBAL_SHORTCUT_ACTIONS) {
const title = getLabel(t);
const hay = `${title} ${globalSection} shortcut hotkey global media ${id.replace(/-/g, ' ')}`;
const score = matchScore(hay, q);
if (score <= 0) continue;
hits.push({ tab: 'input', title, focusTitle: globalSection, score, key: `global:${id}` });
}
hits.sort((a, b) => {
const aCurrent = a.tab === activeTab ? 1 : 0;
const bCurrent = b.tab === activeTab ? 1 : 0;
if (aCurrent !== bCurrent) return bCurrent - aCurrent;
return b.score - a.score;
});
return hits;
}
@@ -0,0 +1,104 @@
export type Tab =
| 'library'
| 'servers'
| 'audio'
| 'lyrics'
| 'appearance'
| 'themes'
| 'personalisation'
| 'integrations'
| 'input'
| 'storage'
| 'system'
| 'users';
// Legacy Tab-IDs die via Route-State oder persisted State noch aufschlagen koennen
// auf die neue Struktur mappen. Gibt es keinen Match, faellt die Settings-Page
// einfach auf 'library' zurueck.
const LEGACY_TAB_ALIAS: Record<string, Tab> = {
general: 'library',
server: 'servers',
};
export function resolveTab(input: string | undefined | null): Tab {
if (!input) return 'servers';
const aliased = LEGACY_TAB_ALIAS[input];
if (aliased) return aliased;
const known: Tab[] = ['library', 'servers', 'audio', 'lyrics', 'appearance', 'themes', 'personalisation', 'integrations', 'input', 'storage', 'system', 'users'];
return (known as string[]).includes(input) ? (input as Tab) : 'servers';
}
// Statischer Suchindex ueber alle Sub-Sections aller Tabs. Mitpflegen, wenn eine
// neue SettingsSubSection hinzukommt — sonst taucht sie nicht in der Suche auf.
export type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string; focusTitleKey?: string };
export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' },
{ tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit crossfade autodj blend 44 88 96' },
{ tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
{ tab: 'audio', titleKey: 'settings.playbackRateTitle', keywords: 'speed playback rate tempo pitch varispeed preserve corrected time stretch' },
{ tab: 'audio', titleKey: 'settings.normalization', keywords: 'normalization normalisation loudness volume leveling level replaygain replay gain lufs pre-gain' },
{ tab: 'audio', titleKey: 'settings.transitionsTitle', keywords: 'track transitions crossfade autodj auto dj smart crossfade gapless blend fade trim silence' },
{ tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
{ tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
{ tab: 'integrations', titleKey: 'musicNetwork.title', keywords: 'last.fm lastfm libre.fm rocksky listenbrainz maloja scrobble scrobbling music network' },
{ tab: 'integrations', titleKey: 'settings.discordRichPresence', keywords: 'discord rich presence rpc' },
{ tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' },
{ tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
{ tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' },
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
{ tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'mainstage home page customize sections' },
{ tab: 'personalisation',titleKey: 'settings.queueSettingsTitle', keywords: 'queue settings display mode list playlist timeline toolbar buttons reorder customize shuffle save load behaviour behavior preserve play next order' },
{ tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' },
{ tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' },
{ tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' },
{ tab: 'servers', titleKey: 'settings.servers', keywords: 'local library index sync resync verify integrity offline delta background sqlite search' },
{ tab: 'servers', titleKey: 'settings.audiomuseTitle', keywords: 'audiomuse audio muse navidrome plugin instant mix similar songs lucky mix' },
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server image idb preview limit clear' },
{ tab: 'storage', titleKey: 'settings.mediaDirTitle', keywords: 'media folder offline library cache directory local playback' },
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering hot cache streaming' },
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
{ tab: 'themes', titleKey: 'settings.themesYourThemesTitle', keywords: 'theme color palette dark light install uninstall apply your' },
{ tab: 'themes', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' },
{ tab: 'themes', titleKey: 'settings.themeStoreTitle', keywords: 'theme store community download install browse marketplace' },
{ tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
{ tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
{ tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
{ tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
{ tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
{ tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
{ tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' },
{ tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start smooth scroll linux' },
{ tab: 'system', titleKey: 'settings.backupTitle', keywords: 'backup export import settings restore' },
{ tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' },
{ tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' },
{ tab: 'system', titleKey: 'settings.aboutContributorsLabel', keywords: 'contributors credits maintainers' },
{ tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' },
];
// Substring-first, compact fuzzy fallback (query chars in order within a
// short span). Returns 0 = no match. Higher = better.
export function matchScore(haystack: string, needle: string): number {
if (!needle) return 0;
const h = haystack.toLowerCase();
const n = needle.toLowerCase();
const idx = h.indexOf(n);
if (idx >= 0) return 1000 - Math.min(999, idx);
// Repeated single-char queries ("aaaaaaa") must not match via sparse fuzzy hits.
if (n.length >= 4 && /^(.)\1+$/.test(n)) return 0;
let hi = 0;
let start = -1;
for (const ch of n) {
const j = h.indexOf(ch, hi);
if (j < 0) return 0;
if (start < 0) start = j;
hi = j + 1;
}
const span = hi - start;
if (span > n.length * 2) return 0;
if (n.length >= 4 && span > n.length + 3) return 0;
return Math.max(1, 100 - Math.min(99, span - n.length));
}
@@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { ndUpdateUser, type NdUser } from '@/api/navidromeAdmin';
import { showToast } from '@/utils/ui/toast';
import {
copyTextToClipboard,
encodeServerMagicString,
magicPayloadAddressFields,
} from '@/utils/server/serverMagicString';
import { shortHostFromServerUrl } from '@/utils/server/serverDisplayName';
import { useAuthStore } from '@/store/authStore';
interface Props {
user: NdUser;
serverUrl: string;
token: string;
onClose: () => void;
onSuccess: () => Promise<void> | void;
t: TFunction;
}
/**
* Generate-magic-string flow for an existing non-admin user. The admin
* supplies a new password (Navidrome doesn't expose passwords in admin
* APIs, so we must re-set one); on success we encode it into a server
* magic string, copy it to the clipboard, and let the parent reload the
* list.
*
* Rendered into `document.body` via portal so the modal escapes the
* settings overflow box.
*/
export function MagicStringModal({
user,
serverUrl,
token,
onClose,
onSuccess,
t,
}: Props) {
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const closeIfIdle = () => {
if (!submitting) onClose();
};
const handleConfirm = () => {
if (!password.trim() || !token) return;
void (async () => {
setSubmitting(true);
try {
await ndUpdateUser(serverUrl, token, user.id, {
userName: user.userName,
name: user.name,
email: user.email,
password: password.trim(),
isAdmin: user.isAdmin,
});
} catch (e) {
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
showToast(msg ?? t('settings.userMgmtUpdateError'), 5000, 'error');
setSubmitting(false);
return;
}
setSubmitting(false);
const addressFields = magicPayloadAddressFields(
serverUrl,
useAuthStore.getState().servers,
);
const str = encodeServerMagicString({
...addressFields,
username: user.userName,
password: password.trim(),
name: shortHostFromServerUrl(serverUrl),
});
const ok = await copyTextToClipboard(str);
showToast(
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
ok ? 3000 : 5000,
ok ? 'info' : 'error',
);
if (ok) {
onClose();
await onSuccess();
}
})();
};
return createPortal(
<div
className="modal-overlay"
onClick={closeIfIdle}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '400px' }}
>
<button
type="button"
className="modal-close"
onClick={closeIfIdle}
aria-label={t('settings.userMgmtCancel')}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.userMgmtMagicStringModalTitle')}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5, fontSize: 13 }}>
{t('settings.userMgmtMagicStringModalDesc', { username: user.userName })}
</p>
<p style={{ color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.45, fontSize: 12 }}>
{t('settings.userMgmtMagicStringPasswordNavHint')}
</p>
<div
role="note"
style={{
fontSize: 11,
lineHeight: 1.45,
marginBottom: '1rem',
padding: '8px 10px',
borderRadius: 6,
border: '1px solid color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent)',
background: 'color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.userMgmtMagicStringPlaintextWarning')}
</div>
<div className="form-group" style={{ marginBottom: '1.25rem' }}>
<label style={{ fontSize: 13 }}>{t('settings.userMgmtPassword')}</label>
<input
className="input"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="off"
disabled={submitting}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
className="btn btn-ghost"
onClick={closeIfIdle}
disabled={submitting}
>
{t('settings.userMgmtCancel')}
</button>
<button
type="button"
className="btn btn-primary"
disabled={!password.trim() || submitting}
onClick={handleConfirm}
>
{t('settings.userMgmtMagicStringModalConfirm')}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,123 @@
import React from 'react';
import { Shield, Trash2, User, Wand2 } from 'lucide-react';
import type { i18n as I18nType, TFunction } from 'i18next';
import type { NdLibrary, NdUser } from '@/api/navidromeAdmin';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
interface Props {
user: NdUser;
libraries: NdLibrary[];
isSelf: boolean;
busy: boolean;
onEdit: (u: NdUser) => void;
onRequestDelete: (u: NdUser) => void;
onRequestMagic: (u: NdUser) => void;
t: TFunction;
i18n: I18nType;
}
/**
* Single user list row in the admin user management section.
*
* Whole row is keyboard-activatable as a button (Enter / Space → edit
* mode) since the click target is a `<div>` rather than a real button —
* we need nested action buttons (magic string, delete) and a `<button>`
* inside a `<button>` is invalid HTML.
*
* The library-names column is built from the union of the user's
* libraryIds and the live libraries list, so it stays in sync if the
* admin re-assigns libraries elsewhere.
*/
export function UserMgmtRow({
user: u,
libraries,
isSelf,
busy,
onEdit,
onRequestDelete,
onRequestMagic,
t,
i18n,
}: Props) {
const libNames = u.isAdmin
? null
: u.libraryIds.length === 0
? t('settings.userMgmtNoLibraries')
: libraries.filter(l => u.libraryIds.includes(l.id)).map(l => l.name).join(', ');
const lastSeen = formatLastSeen(u.lastAccessAt, i18n.language, t('settings.userMgmtNeverSeen'));
const lastSeenAbsolute = u.lastAccessAt
? new Date(u.lastAccessAt).toLocaleString(i18n.language)
: '';
return (
<div
className="settings-card user-row"
role="button"
tabIndex={0}
onClick={() => { if (!busy) onEdit(u); }}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !busy) {
e.preventDefault();
onEdit(u);
}
}}
style={{
padding: '6px 10px',
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: busy ? 'default' : 'pointer',
}}
>
<User size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: 13, flexShrink: 0 }}>{u.userName}</span>
{u.name && u.name !== u.userName && (
<span style={{ fontSize: 12, color: 'var(--text-muted)', flexShrink: 0 }}>· {u.name}</span>
)}
{isSelf && (
<span style={{ fontSize: 10, background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '1px 6px', borderRadius: 10, fontWeight: 600, flexShrink: 0 }}>
{t('settings.userMgmtYouBadge')}
</span>
)}
{u.isAdmin && (
<span
style={{ fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 10, fontWeight: 600, background: 'color-mix(in srgb, var(--warning, #f59e0b) 22%, transparent)', color: 'var(--text-primary)', flexShrink: 0 }}
data-tooltip={t('settings.userMgmtRoleAdmin')}
>
<Shield size={10} />
{t('settings.userMgmtAdminBadge')}
</span>
)}
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
{libNames || ''}
</span>
{!u.isAdmin && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: '2px 6px', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); onRequestMagic(u); }}
disabled={busy}
data-tooltip={t('settings.userMgmtMagicStringGenerate')}
>
<Wand2 size={14} />
</button>
)}
<span
style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0 }}
data-tooltip={lastSeenAbsolute || undefined}
>
{lastSeen}
</span>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)', padding: '2px 6px', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); onRequestDelete(u); }}
disabled={busy || isSelf}
data-tooltip={t('settings.userMgmtDelete')}
>
<Trash2 size={14} />
</button>
</div>
);
}