mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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 effect’s 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 effect’s 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 25–30; 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 effect’s 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 effect’s 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 effect’s 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
ndCreateUser,
|
||||
ndDeleteUser,
|
||||
ndSetUserLibraries,
|
||||
ndUpdateUser,
|
||||
type NdLibrary,
|
||||
type NdUser,
|
||||
} from '@/api/navidromeAdmin';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import {
|
||||
copyTextToClipboard,
|
||||
encodeServerMagicString,
|
||||
} from '@/utils/server/serverMagicString';
|
||||
import { shortHostFromServerUrl } from '@/utils/server/serverDisplayName';
|
||||
import type { UserFormState } from '@/features/settings/components/UserForm';
|
||||
|
||||
interface UseUserMgmtActionsArgs {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
libraries: NdLibrary[];
|
||||
editing: NdUser | 'new' | null;
|
||||
setEditing: (next: NdUser | 'new' | null) => void;
|
||||
reload: () => Promise<void>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
interface UseUserMgmtActionsResult {
|
||||
busy: boolean;
|
||||
handleSave: (form: UserFormState) => Promise<void>;
|
||||
handleSaveAndGetMagic: (form: UserFormState) => Promise<void>;
|
||||
performDelete: (u: NdUser) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRUD glue for the Navidrome admin user list. Wraps the four Tauri
|
||||
* commands (create / update / set-libraries / delete) with shared
|
||||
* validation, toast surfacing, and a busy flag the parent uses to
|
||||
* disable controls.
|
||||
*
|
||||
* `handleSaveAndGetMagic` is only meaningful in the create flow for
|
||||
* non-admin users — it creates the account, attaches the chosen
|
||||
* libraries, then copies a server-magic-string to the clipboard for
|
||||
* pasting into a fresh client install.
|
||||
*/
|
||||
export function useUserMgmtActions({
|
||||
serverUrl,
|
||||
token,
|
||||
libraries,
|
||||
editing,
|
||||
setEditing,
|
||||
reload,
|
||||
t,
|
||||
}: UseUserMgmtActionsArgs): UseUserMgmtActionsResult {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleSave = async (form: UserFormState) => {
|
||||
const userName = form.userName.trim();
|
||||
const name = form.name.trim();
|
||||
const email = form.email.trim();
|
||||
if (editing === 'new') {
|
||||
if (!userName || !name || !form.password.trim()) {
|
||||
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
} else if (editing) {
|
||||
if (!userName || !name) {
|
||||
showToast(t('settings.userMgmtValidationMissingIdentity'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
|
||||
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
let targetId: string;
|
||||
if (editing === 'new') {
|
||||
const created = await ndCreateUser(serverUrl, token, {
|
||||
userName, name, email, password: form.password, isAdmin: form.isAdmin,
|
||||
});
|
||||
targetId = created.id;
|
||||
showToast(t('settings.userMgmtCreated'), 3000, 'info');
|
||||
} else if (editing) {
|
||||
await ndUpdateUser(serverUrl, token, editing.id, {
|
||||
userName, name, email, password: form.password, isAdmin: form.isAdmin,
|
||||
});
|
||||
targetId = editing.id;
|
||||
showToast(t('settings.userMgmtUpdated'), 3000, 'info');
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!form.isAdmin && form.libraryIds.length > 0) {
|
||||
try {
|
||||
await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds);
|
||||
} catch (e) {
|
||||
const msg = (e instanceof Error && e.message) ? e.message : String(e);
|
||||
showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error');
|
||||
}
|
||||
}
|
||||
setEditing(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
|
||||
const fallback = editing === 'new'
|
||||
? t('settings.userMgmtCreateError')
|
||||
: t('settings.userMgmtUpdateError');
|
||||
showToast(msg ?? fallback, 5000, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAndGetMagic = async (form: UserFormState) => {
|
||||
if (editing !== 'new' || form.isAdmin) return;
|
||||
const userName = form.userName.trim();
|
||||
const name = form.name.trim();
|
||||
const email = form.email.trim();
|
||||
if (!userName || !name || !form.password.trim()) {
|
||||
showToast(t('settings.userMgmtValidationMissing'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) {
|
||||
showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error');
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const created = await ndCreateUser(serverUrl, token, {
|
||||
userName, name, email, password: form.password, isAdmin: form.isAdmin,
|
||||
});
|
||||
const targetId = created.id;
|
||||
showToast(t('settings.userMgmtCreated'), 3000, 'info');
|
||||
if (!form.isAdmin && form.libraryIds.length > 0) {
|
||||
try {
|
||||
await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds);
|
||||
} catch (e) {
|
||||
const msg = (e instanceof Error && e.message) ? e.message : String(e);
|
||||
showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error');
|
||||
}
|
||||
}
|
||||
const str = encodeServerMagicString({
|
||||
url: serverUrl.trim(),
|
||||
username: userName,
|
||||
password: form.password,
|
||||
name: shortHostFromServerUrl(serverUrl),
|
||||
});
|
||||
const ok = await copyTextToClipboard(str);
|
||||
showToast(
|
||||
ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'),
|
||||
ok ? 3000 : 5000,
|
||||
ok ? 'info' : 'error',
|
||||
);
|
||||
setEditing(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null);
|
||||
showToast(msg ?? t('settings.userMgmtCreateError'), 5000, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const performDelete = async (u: NdUser) => {
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await ndDeleteUser(serverUrl, token, u.id);
|
||||
showToast(t('settings.userMgmtDeleted'), 3000, 'info');
|
||||
await reload();
|
||||
} catch (e) {
|
||||
const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : t('settings.userMgmtDeleteError'));
|
||||
showToast(msg, 5000, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { busy, handleSave, handleSaveAndGetMagic, performDelete };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
ndListLibraries,
|
||||
ndListUsers,
|
||||
type NdLibrary,
|
||||
type NdUser,
|
||||
} from '@/api/navidromeAdmin';
|
||||
|
||||
interface UseUserMgmtDataResult {
|
||||
users: NdUser[];
|
||||
libraries: NdLibrary[];
|
||||
loading: boolean;
|
||||
loadError: string | null;
|
||||
load: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and keep the Navidrome admin users + libraries lists in sync.
|
||||
*
|
||||
* The two requests are **sequential, not parallel**: some nginx setups
|
||||
* with churning upstream keep-alive drop one of two parallel TLS
|
||||
* connections. Doing users first then libraries keeps us on one
|
||||
* connection at a time and pairs cleanly with the `nd_retry` backoff on
|
||||
* the Rust side.
|
||||
*
|
||||
* Tauri's `invoke` rejects with a bare `string` (our Rust commands
|
||||
* return `Err(String)`), so the error normalisation surfaces the real
|
||||
* cause (e.g. `tls handshake eof`) instead of falling back to the
|
||||
* generic i18n string.
|
||||
*/
|
||||
export function useUserMgmtData(serverUrl: string, token: string, t: TFunction): UseUserMgmtDataResult {
|
||||
const [users, setUsers] = useState<NdUser[]>([]);
|
||||
const [libraries, setLibraries] = useState<NdLibrary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const list = await ndListUsers(serverUrl, token);
|
||||
const libs = await ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]);
|
||||
setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName)));
|
||||
setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} catch (e) {
|
||||
const raw = typeof e === 'string'
|
||||
? e
|
||||
: (e instanceof Error && e.message)
|
||||
? e.message
|
||||
: '';
|
||||
const prefix = t('settings.userMgmtLoadError');
|
||||
setLoadError(raw ? `${prefix} ${raw}` : prefix);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverUrl, token, t]);
|
||||
|
||||
// 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(() => { void load(); }, [load]);
|
||||
|
||||
return { users, libraries, loading, loadError, load };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Settings feature — the Settings page (lazy via the deep path `pages/Settings`)
|
||||
* and all its tabs/sections (servers, audio, input, library, lyrics, storage,
|
||||
* personalisation, integrations, system, the theme management UI, music-network
|
||||
* config, user management), plus the settings layout primitives.
|
||||
*
|
||||
* Cross-cutting state this feature consumes but does NOT own: `authStore` and its
|
||||
* `auth*SettingsActions` slices, the theme stores + theme infra utils, the
|
||||
* library-index sync hook, and `settingsCredits` — all live in the core/global
|
||||
* layer.
|
||||
*
|
||||
* Public surface (consumed by other features / app shell):
|
||||
*/
|
||||
export { default as SettingsSubSection } from './components/SettingsSubSection';
|
||||
export { PlaybackRateControls } from './components/audio/PlaybackRateBlock';
|
||||
export { CustomHttpHeadersEditor } from './components/CustomHttpHeadersEditor';
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AudioLines, HardDrive, Info, Keyboard, LayoutGrid, Music2, Paintbrush, Palette, Search, Server, Sparkles, Users, X,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { IS_MACOS } from '@/utils/platform';
|
||||
import { AppearanceTab } from '@/features/settings/components/AppearanceTab';
|
||||
import { ThemesTab } from '@/features/settings/components/ThemesTab';
|
||||
import { AudioTab } from '@/features/settings/components/AudioTab';
|
||||
import { InputTab } from '@/features/settings/components/InputTab';
|
||||
import { IntegrationsTab } from '@/features/settings/components/IntegrationsTab';
|
||||
import { LibraryTab } from '@/features/settings/components/LibraryTab';
|
||||
import { LyricsTab } from '@/features/settings/components/LyricsTab';
|
||||
import { PersonalisationTab } from '@/features/settings/components/PersonalisationTab';
|
||||
import { ServersTab } from '@/features/settings/components/ServersTab';
|
||||
import { StorageTab } from '@/features/settings/components/StorageTab';
|
||||
import { SystemTab } from '@/features/settings/components/SystemTab';
|
||||
import { searchSettings, type SettingsSearchHit } from '@/features/settings/components/settingsSearch';
|
||||
import { type Tab, resolveTab } from '@/features/settings/components/settingsTabs';
|
||||
import { UserManagementSection } from '@/features/settings/components/UserManagementSection';
|
||||
import { ndLogin } from '@/api/navidromeAdmin';
|
||||
import { type ServerMagicPayload } from '@/utils/server/serverMagicString';
|
||||
|
||||
|
||||
export default function Settings() {
|
||||
const auth = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const routeState = location.state;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>(resolveTab((routeState as { tab?: string } | null)?.tab));
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SettingsSearchHit[]>([]);
|
||||
const [selectedResultIdx, setSelectedResultIdx] = useState(0);
|
||||
const [pendingFocusTitle, setPendingFocusTitle] = useState<string | null>(null);
|
||||
const [pendingServerInvite, setPendingServerInvite] = useState<ServerMagicPayload | null>(null);
|
||||
const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null);
|
||||
const [ndAuthChecked, setNdAuthChecked] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchResultsListRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const st = routeState as { openAddServerInvite?: ServerMagicPayload; tab?: Tab } | null;
|
||||
const inv = st?.openAddServerInvite;
|
||||
if (inv) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPendingServerInvite(inv);
|
||||
setActiveTab('servers');
|
||||
navigate(
|
||||
{ pathname: location.pathname, search: location.search, hash: location.hash },
|
||||
{ replace: true, state: { tab: 'servers' as Tab } },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (st?.tab) setActiveTab(st.tab);
|
||||
}, [routeState, location.pathname, location.search, location.hash, navigate]);
|
||||
|
||||
// Settings-Suche: matcht SETTINGS_INDEX gegen den Query (Substring + Fuzzy).
|
||||
// Ergebnis ist eine flache Liste; aktueller Tab zuerst, dann nach Score. Wenn
|
||||
// eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen
|
||||
// die Ergebnisliste angezeigt.
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSearchResults(searchSettings(searchQuery, activeTab, t));
|
||||
setSelectedResultIdx(0);
|
||||
}, [searchQuery, activeTab, t]);
|
||||
|
||||
// Selektion ins Blickfeld scrollen (nur wenn das Item out-of-view ist).
|
||||
useEffect(() => {
|
||||
if (!searchQuery || searchResults.length === 0) return;
|
||||
const list = searchResultsListRef.current;
|
||||
if (!list) return;
|
||||
const item = list.children[selectedResultIdx] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedResultIdx, searchQuery, searchResults.length]);
|
||||
|
||||
// Ctrl/Cmd+F oeffnet die Settings-Suche (nur auf der Settings-Seite — dieser
|
||||
// Effect ist ja an Settings gebunden). Fokussiert das Feld auch wenn's schon
|
||||
// offen ist. preventDefault blockt die native WebKit-Find-Bar.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'f' && e.key !== 'F') return;
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.altKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
window.setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
// Nach Klick auf ein Ergebnis: Ziel-Sub-Section oeffnen, scrollen und kurz
|
||||
// highlighten, damit der User auf dem neuen Tab sofort weiss welcher Eintrag
|
||||
// gemeint war.
|
||||
useEffect(() => {
|
||||
if (!pendingFocusTitle) return;
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
`[data-settings-search="${CSS.escape(pendingFocusTitle)}"]`,
|
||||
);
|
||||
if (!el) return;
|
||||
if (el instanceof HTMLDetailsElement) el.open = true;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
el.classList.remove('settings-sub-section--flash');
|
||||
// reflow, damit die Animation bei wiederholtem Klick auf dasselbe Ziel
|
||||
// erneut abspielt.
|
||||
void el.offsetWidth;
|
||||
el.classList.add('settings-sub-section--flash');
|
||||
const timer = window.setTimeout(() => {
|
||||
el.classList.remove('settings-sub-section--flash');
|
||||
}, 1500);
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPendingFocusTitle(null);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [pendingFocusTitle, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const server = auth.getActiveServer();
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setNdAuthChecked(false);
|
||||
if (!server) { setNdAdminAuth(null); setNdAuthChecked(true); return; }
|
||||
const serverUrl = (server.url.startsWith('http') ? server.url : `http://${server.url}`).replace(/\/$/, '');
|
||||
let cancelled = false;
|
||||
ndLogin(serverUrl, server.username, server.password)
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
setNdAdminAuth(res.isAdmin ? { token: res.token, serverUrl, username: server.username } : null);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setNdAdminAuth(null); })
|
||||
.finally(() => { if (!cancelled) setNdAuthChecked(true); });
|
||||
return () => { cancelled = true; };
|
||||
// Keyed on auth.activeServerId; the effect reads the active server via `auth`
|
||||
// but must only re-probe admin auth when the active server changes, not on
|
||||
// every auth-store update.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [auth.activeServerId]);
|
||||
|
||||
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 (activeTab === 'users' && ndAuthChecked && ndAdminAuth === null) setActiveTab('servers');
|
||||
}, [activeTab, ndAdminAuth, ndAuthChecked]);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'servers', label: t('settings.tabServers'), icon: <Server size={15} /> },
|
||||
{ id: 'library', label: t('settings.tabLibrary'), icon: <Music2 size={15} /> },
|
||||
{ id: 'audio', label: t('settings.tabAudio'), icon: <AudioLines size={15} /> },
|
||||
{ id: 'themes', label: t('settings.tabThemes'), icon: <Paintbrush size={15} /> },
|
||||
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
|
||||
{ id: 'lyrics', label: t('settings.tabLyrics'), icon: <Music2 size={15} /> },
|
||||
{ id: 'personalisation', label: t('settings.tabPersonalisation'), icon: <LayoutGrid size={15} /> },
|
||||
{ id: 'integrations', label: t('settings.tabIntegrations'), icon: <Sparkles size={15} /> },
|
||||
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
|
||||
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
|
||||
{ id: 'system', label: t('settings.tabSystem'), icon: <Info size={15} /> },
|
||||
...(ndAdminAuth ? [{ id: 'users' as Tab, label: t('settings.tabUsers'), icon: <Users size={15} /> }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className="settings-header">
|
||||
<h1 className="page-title">{t('settings.title')}</h1>
|
||||
<div className="settings-search">
|
||||
{!searchOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
aria-label={t('settings.searchPlaceholder')}
|
||||
data-tooltip={t('settings.searchPlaceholder')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Search size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="settings-search-wrap">
|
||||
<Search size={14} className="settings-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
className="input settings-search-input"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={`${t('settings.searchPlaceholder')} (${IS_MACOS ? '⌘F' : 'Ctrl+F'})`}
|
||||
aria-label={t('settings.searchPlaceholder')}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
if (searchResults.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedResultIdx(i => Math.min(i + 1, searchResults.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedResultIdx(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const hit = searchResults[selectedResultIdx];
|
||||
if (!hit) return;
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
setPendingFocusTitle(hit.focusTitle);
|
||||
setActiveTab(hit.tab);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-search-clear"
|
||||
onClick={() => { setSearchQuery(''); setSearchOpen(false); }}
|
||||
aria-label={t('common.clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className="settings-advanced-toggle"
|
||||
data-tooltip={t('settings.advancedModeTooltip')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<span className="settings-advanced-toggle-label">
|
||||
{t('settings.advancedMode')}
|
||||
</span>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auth.advancedSettingsEnabled}
|
||||
onChange={e => auth.setAdvancedSettingsEnabled(e.target.checked)}
|
||||
aria-label={t('settings.advancedMode')}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<nav className="settings-tabs" aria-label="Settings navigation">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{searchQuery && searchResults.length === 0 && (
|
||||
<div className="settings-search-empty" role="status">
|
||||
{t('settings.searchNoResults')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchQuery && searchResults.length > 0 && (
|
||||
<ul ref={searchResultsListRef} className="settings-search-results">
|
||||
{searchResults.map((hit, idx) => {
|
||||
const tabLabelKey = TAB_LABEL_KEY[hit.tab];
|
||||
const selected = idx === selectedResultIdx;
|
||||
return (
|
||||
<li key={hit.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-search-result-item"
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
onMouseEnter={() => setSelectedResultIdx(idx)}
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSearchOpen(false);
|
||||
setPendingFocusTitle(hit.focusTitle);
|
||||
setActiveTab(hit.tab);
|
||||
}}
|
||||
>
|
||||
<span className="settings-search-result-badge">{t(tabLabelKey)}</span>
|
||||
<span className="settings-search-result-title">{hit.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!searchQuery && <>
|
||||
{/* ── Audio ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'audio' && <AudioTab />}
|
||||
|
||||
{/* ── Lyrics ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'lyrics' && <LyricsTab />}
|
||||
|
||||
{/* ── Integrations ─────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'integrations' && <IntegrationsTab />}
|
||||
|
||||
|
||||
{/* ── Personalisation ──────────────────────────────────────────────────── */}
|
||||
{activeTab === 'personalisation' && <PersonalisationTab />}
|
||||
|
||||
{/* ── Library (legacy 'general' + 'server') ────────────────────────────── */}
|
||||
{activeTab === 'library' && <LibraryTab />}
|
||||
|
||||
|
||||
{/* ── Offline & Cache ──────────────────────────────────────────────────── */}
|
||||
{activeTab === 'storage' && <StorageTab />}
|
||||
|
||||
{/* ── Appearance ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'appearance' && <AppearanceTab />}
|
||||
|
||||
{/* ── Themes ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'themes' && <ThemesTab />}
|
||||
|
||||
{/* ── Input ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'input' && <InputTab />}
|
||||
|
||||
|
||||
{/* ── Server ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'servers' && (
|
||||
<ServersTab initialInvite={pendingServerInvite} />
|
||||
)}
|
||||
|
||||
{/* ── Users ────────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'users' && ndAdminAuth && (
|
||||
<UserManagementSection
|
||||
serverUrl={ndAdminAuth.serverUrl}
|
||||
token={ndAdminAuth.token}
|
||||
currentUsername={ndAdminAuth.username}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── System ───────────────────────────────────────────────────────────── */}
|
||||
{activeTab === 'system' && <SystemTab />}
|
||||
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB_LABEL_KEY: Record<Tab, string> = {
|
||||
library: 'settings.tabLibrary',
|
||||
servers: 'settings.tabServers',
|
||||
audio: 'settings.tabAudio',
|
||||
lyrics: 'settings.tabLyrics',
|
||||
appearance: 'settings.tabAppearance',
|
||||
themes: 'settings.tabThemes',
|
||||
personalisation: 'settings.tabPersonalisation',
|
||||
integrations: 'settings.tabIntegrations',
|
||||
input: 'settings.tabInput',
|
||||
storage: 'settings.tabStorage',
|
||||
system: 'settings.tabSystem',
|
||||
users: 'settings.tabUsers',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { save, open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile, readTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { version as appVersion } from '@/../package.json';
|
||||
|
||||
const BACKUP_VERSION = 1;
|
||||
export type ImportedBackupKind = 'config' | 'databases' | 'full';
|
||||
export type BackupExportMode = 'full' | 'library' | 'config';
|
||||
|
||||
const BACKUP_KEYS = [
|
||||
'psysonic-auth',
|
||||
'psysonic_theme',
|
||||
'psysonic_font',
|
||||
'psysonic_language',
|
||||
'psysonic_keybindings',
|
||||
'psysonic_sidebar',
|
||||
'psysonic-eq',
|
||||
'psysonic_global_shortcuts',
|
||||
'psysonic-player',
|
||||
'psysonic_player_prefs',
|
||||
'psysonic_queue_visible',
|
||||
'psysonic_lastfm_loved_cache',
|
||||
'psysonic_home',
|
||||
];
|
||||
|
||||
function collectStores(): Record<string, unknown> {
|
||||
const stores: Record<string, unknown> = {};
|
||||
for (const key of BACKUP_KEYS) {
|
||||
const val = localStorage.getItem(key);
|
||||
if (val !== null) {
|
||||
try {
|
||||
stores[key] = JSON.parse(val);
|
||||
} catch {
|
||||
stores[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
return stores;
|
||||
}
|
||||
|
||||
function buildSettingsManifest() {
|
||||
return {
|
||||
version: BACKUP_VERSION,
|
||||
app_version: appVersion,
|
||||
created_at: new Date().toISOString(),
|
||||
stores: collectStores(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function pickBackupExportPath(mode: BackupExportMode): Promise<string | null> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (mode === 'full') {
|
||||
return save({
|
||||
filters: [{ name: 'Psysonic Full Backup', extensions: ['psyfull', 'zip'] }],
|
||||
defaultPath: `psysonic-full-${today}.psyfull`,
|
||||
});
|
||||
}
|
||||
if (mode === 'library') {
|
||||
return save({
|
||||
filters: [{ name: 'Psysonic Library Databases Archive', extensions: ['psylib', 'zip'] }],
|
||||
defaultPath: `psysonic-library-databases-${today}.psylib`,
|
||||
});
|
||||
}
|
||||
return save({
|
||||
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
|
||||
defaultPath: `psysonic-backup-${today}.psybkp`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportBackupToPath(mode: BackupExportMode, path: string): Promise<void> {
|
||||
if (mode === 'full') {
|
||||
await invoke('backup_export_full', {
|
||||
destinationPath: path,
|
||||
stores: collectStores(),
|
||||
appVersion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mode === 'library') {
|
||||
await invoke('backup_export_library_db', { destinationPath: path });
|
||||
return;
|
||||
}
|
||||
const content = JSON.stringify(buildSettingsManifest(), null, 2);
|
||||
await writeFile(path, new TextEncoder().encode(content));
|
||||
}
|
||||
|
||||
export async function pickBackupImportPath(): Promise<string | null> {
|
||||
const path = await openDialog({
|
||||
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp', 'psylib', 'psyfull', 'zip'] }],
|
||||
multiple: false,
|
||||
title: 'Import Backup',
|
||||
});
|
||||
return path && typeof path === 'string' ? path : null;
|
||||
}
|
||||
|
||||
export async function importAnyBackupFromPath(path: string): Promise<ImportedBackupKind> {
|
||||
try {
|
||||
const raw = await readTextFile(path);
|
||||
const manifest = JSON.parse(raw);
|
||||
if (typeof manifest.version === 'number' && manifest.stores && typeof manifest.stores === 'object') {
|
||||
for (const [key, value] of Object.entries(manifest.stores as Record<string, unknown>)) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
window.location.reload();
|
||||
return 'config';
|
||||
}
|
||||
} catch {
|
||||
// Not a plain JSON settings backup, continue detection.
|
||||
}
|
||||
|
||||
try {
|
||||
const stores = await invoke<Record<string, unknown>>('backup_import_full', { sourcePath: path });
|
||||
for (const [key, value] of Object.entries(stores)) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
window.location.reload();
|
||||
return 'full';
|
||||
} catch {
|
||||
// Not a full backup archive, continue detection.
|
||||
}
|
||||
|
||||
await invoke('backup_import_library_db', { sourcePath: path });
|
||||
return 'databases';
|
||||
}
|
||||
|
||||
export async function exportBackup(): Promise<string | null> {
|
||||
const path = await pickBackupExportPath('config');
|
||||
if (!path) return null;
|
||||
await exportBackupToPath('config', path);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function importBackup(): Promise<void> {
|
||||
const path = await openDialog({
|
||||
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
|
||||
multiple: false,
|
||||
title: 'Import Psysonic Backup',
|
||||
});
|
||||
|
||||
if (!path || typeof path !== 'string') return;
|
||||
|
||||
const raw = await readTextFile(path);
|
||||
const manifest = JSON.parse(raw);
|
||||
|
||||
if (typeof manifest.version !== 'number' || !manifest.stores || typeof manifest.stores !== 'object') {
|
||||
throw new Error('invalid_backup');
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(manifest.stores)) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export async function exportLibraryDatabaseBackup(): Promise<string | null> {
|
||||
const path = await pickBackupExportPath('library');
|
||||
if (!path) return null;
|
||||
await exportBackupToPath('library', path);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function importLibraryDatabaseBackup(): Promise<void> {
|
||||
const path = await openDialog({
|
||||
filters: [{ name: 'Psysonic Library Databases Archive', extensions: ['psylib', 'zip'] }],
|
||||
multiple: false,
|
||||
title: 'Import Library Databases Archive',
|
||||
});
|
||||
if (!path || typeof path !== 'string') return;
|
||||
await invoke('backup_import_library_db', { sourcePath: path });
|
||||
}
|
||||
|
||||
export async function exportFullBackup(): Promise<string | null> {
|
||||
const path = await pickBackupExportPath('full');
|
||||
if (!path) return null;
|
||||
await exportBackupToPath('full', path);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function importFullBackup(): Promise<void> {
|
||||
const path = await openDialog({
|
||||
filters: [{ name: 'Psysonic Full Backup', extensions: ['psyfull', 'zip'] }],
|
||||
multiple: false,
|
||||
title: 'Import Full Backup',
|
||||
});
|
||||
if (!path || typeof path !== 'string') return;
|
||||
const stores = await invoke<Record<string, unknown>>('backup_import_full', { sourcePath: path });
|
||||
for (const [key, value] of Object.entries(stores)) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export async function importAnyBackup(): Promise<ImportedBackupKind | null> {
|
||||
const path = await pickBackupImportPath();
|
||||
if (!path) return null;
|
||||
return importAnyBackupFromPath(path);
|
||||
}
|
||||
Reference in New Issue
Block a user