mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(settings): SettingsSubCard primitives across all of Settings (#1182)
* feat(settings): add SettingsSubCard primitives for sub-section controls Reusable wrappers (SettingsSubCard / SettingsField / SettingsRow / SettingsValue / SettingsCallout) that encapsulate the settings-norm-* sub-card styles. Per-mode and detail controls inside a settings section should use these instead of hand-rolling the box with inline padding or one-off classes — the lack of a shared primitive is what let recent additions drift off the pattern. * refactor(settings): move the Audio tab onto the SettingsSubCard primitives Normalization, Track transitions and Hi-Res now build their sub-cards from the shared primitives instead of raw settings-norm-* markup. Track Previews, which rendered its sub-options bare with inline styles and dividers, now uses the same sub-card so the whole Audio tab is consistent. Behaviour and styling unchanged; Track Previews' start/duration become labelled slider rows like Normalization. * refactor(settings): move the remaining tabs onto SettingsSubCard Apply the shared sub-card primitives across the rest of Settings so every tab matches the Audio-tab reference look: - Appearance: grid columns, window buttons, UI scale, font, seekbar - Library: random-mix blacklist, rating-filter detail - Input: in-app + global keybind lists - Integrations: Discord templates, external-artwork BYOK - System: language, Linux Wayland text render, clock format, logging - Storage: media dir, hot-cache detail, downloads folder - Backup: mode picker + per-mode export/import Detail/per-mode controls now sit in the bordered sub-card; toggle groups stay on SettingsToggle. Behaviour unchanged. * refactor(settings): box the data-heavy and shared sections in SettingsSubCard Bring the previously-skipped 'borderline' sections onto the sub-card too: - Playback speed: wrap the settings-mode controls in a sub-card; the compact player-popup path is left untouched (shares the component via the compact flag) - Theme scheduler: mode picker + theme/time selects move into the sub-card - Cover-cache + analytics strategy: the per-server tables sit in a sub-card Behaviour unchanged; the table layouts and the playback-rate-* internals stay as they are, only the surrounding sub-card is added for visual consistency.
This commit is contained in:
@@ -6,6 +6,7 @@ import { save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsSubCard } from './SettingsSubCard';
|
||||
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
@@ -255,6 +256,7 @@ export default function AnalyticsStrategySection() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<SettingsSubCard>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
|
||||
<thead>
|
||||
@@ -434,6 +436,7 @@ export default function AnalyticsStrategySection() {
|
||||
{t('settings.analyticsStrategyAdvancedWarning')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { IS_LINUX, IS_WINDOWS } from '../../utils/platform';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { SettingsSubCard, SettingsField, SettingsRow, SettingsValue } from './SettingsSubCard';
|
||||
import { SeekbarPreview } from '../WaveformSeekPreview';
|
||||
import WindowButtonPreview from '../WindowButtonPreview';
|
||||
|
||||
@@ -40,14 +41,15 @@ export function AppearanceTab() {
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.libraryGridMaxColumnsPerfHint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" htmlFor="library-grid-max-cols">
|
||||
{t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||
<SettingsSubCard>
|
||||
<SettingsField
|
||||
label={t('settings.libraryGridMaxColumnsRangeLabel', {
|
||||
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
|
||||
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
|
||||
})}
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginTop: 8 }}>
|
||||
desc={t('settings.libraryGridMaxColumnsDesc')}
|
||||
row
|
||||
>
|
||||
<input
|
||||
id="library-grid-max-cols"
|
||||
type="range"
|
||||
@@ -56,19 +58,13 @@ export function AppearanceTab() {
|
||||
step={1}
|
||||
value={auth.libraryGridMaxColumns}
|
||||
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
|
||||
style={{ flex: 1, maxWidth: 360 }}
|
||||
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
|
||||
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
|
||||
aria-valuenow={auth.libraryGridMaxColumns}
|
||||
/>
|
||||
<span style={{ minWidth: 28, fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{auth.libraryGridMaxColumns}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
|
||||
{t('settings.libraryGridMaxColumnsDesc')}
|
||||
</p>
|
||||
<SettingsValue>{auth.libraryGridMaxColumns}</SettingsValue>
|
||||
</SettingsField>
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
@@ -143,24 +139,24 @@ export function AppearanceTab() {
|
||||
/>
|
||||
{auth.useCustomTitlebar && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.windowButtonStyle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.windowButtonStyleDesc')}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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')}
|
||||
@@ -181,57 +177,56 @@ export function AppearanceTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
{Math.round(fontStore.uiScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const presets = [80, 90, 100, 110, 125, 150];
|
||||
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||
let idx = presets.indexOf(currentPct);
|
||||
if (idx < 0) {
|
||||
// Snap legacy off-preset values to the closest preset.
|
||||
idx = presets.reduce((best, p, i) =>
|
||||
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={presets.length - 1}
|
||||
step={1}
|
||||
value={idx}
|
||||
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
{presets.map(p => {
|
||||
const active = currentPct === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<SettingsSubCard>
|
||||
<SettingsField label={t('settings.uiScaleLabel')}>
|
||||
{(() => {
|
||||
const presets = [80, 90, 100, 110, 125, 150];
|
||||
const currentPct = Math.round(fontStore.uiScale * 100);
|
||||
let idx = presets.indexOf(currentPct);
|
||||
if (idx < 0) {
|
||||
// Snap legacy off-preset values to the closest preset.
|
||||
idx = presets.reduce((best, p, i) =>
|
||||
Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SettingsRow>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={presets.length - 1}
|
||||
step={1}
|
||||
value={idx}
|
||||
onChange={e => fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<SettingsValue>{currentPct}%</SettingsValue>
|
||||
</SettingsRow>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
{presets.map(p => {
|
||||
const active = currentPct === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</SettingsField>
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
@@ -242,6 +237,7 @@ export function AppearanceTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<SettingsSubCard>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{(
|
||||
[
|
||||
@@ -283,6 +279,7 @@ export function AppearanceTab() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
@@ -293,20 +290,21 @@ export function AppearanceTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.seekbarStyleDesc')}
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Clock3, Download, Upload } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
|
||||
import {
|
||||
exportBackupToPath,
|
||||
importAnyBackupFromPath,
|
||||
@@ -151,12 +152,12 @@ export function BackupSection() {
|
||||
return (
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.85rem' }}>
|
||||
<div className="settings-segmented" style={{ marginBottom: '0.85rem' }}>
|
||||
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
className={`btn btn-sm ${mode === candidate ? 'btn-primary' : 'btn-surface'}`}
|
||||
className={`btn ${mode === candidate ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setMode(candidate)}
|
||||
>
|
||||
{candidate === 'full'
|
||||
@@ -168,31 +169,28 @@ export function BackupSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem', marginBottom: '0.9rem' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{modeTitle}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{modeDesc}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsSubCard } from './SettingsSubCard';
|
||||
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
@@ -234,6 +235,7 @@ export default function CoverCacheStrategySection() {
|
||||
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<SettingsSubCard>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={TABLE_STYLE}>
|
||||
<CoverCacheColGroup />
|
||||
@@ -416,6 +418,7 @@ export default function CoverCacheStrategySection() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useGlobalShortcutsStore, type GlobalAction, buildGlobalShortcut, format
|
||||
import { useKeybindingsStore, type KeyAction, buildInAppBinding, formatBinding } from '../../store/keybindingsStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsSubCard } from './SettingsSubCard';
|
||||
|
||||
export function InputTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,6 +35,7 @@ export function InputTab() {
|
||||
>
|
||||
<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);
|
||||
@@ -97,6 +99,7 @@ export function InputTab() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
@@ -120,6 +123,7 @@ export function InputTab() {
|
||||
>
|
||||
<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);
|
||||
@@ -181,6 +185,7 @@ export function InputTab() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useThemeStore } from '../../store/themeStore';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
|
||||
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
|
||||
import { purgeExternalArtworkAllServers } from '../../api/coverCache';
|
||||
|
||||
@@ -77,46 +78,44 @@ export function IntegrationsTab() {
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title={t('settings.discordTemplates')} desc={t('settings.discordTemplatesDesc')}>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateName')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateName}
|
||||
onChange={e => auth.setDiscordTemplateName(e.target.value)}
|
||||
placeholder="{title}"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateDetails')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateDetails}
|
||||
onChange={e => auth.setDiscordTemplateDetails(e.target.value)}
|
||||
placeholder="{artist}"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateState')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateState}
|
||||
onChange={e => auth.setDiscordTemplateState(e.target.value)}
|
||||
placeholder="{title}"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateLargeText')}</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={auth.discordTemplateLargeText}
|
||||
onChange={e => auth.setDiscordTemplateLargeText(e.target.value)}
|
||||
placeholder="{album}"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -166,31 +165,33 @@ export function IntegrationsTab() {
|
||||
title={t('settings.externalArtworkByokTitle')}
|
||||
desc={t('settings.externalArtworkByokDesc')}
|
||||
>
|
||||
<div className="form-group">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
|
||||
import StarRating from '../StarRating';
|
||||
import AnalyticsStrategySection from './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'];
|
||||
@@ -26,78 +27,77 @@ export function LibraryTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
{t('settings.randomMixBlacklistDesc')}
|
||||
</p>
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem' }}>{t('settings.randomMixBlacklistTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.75rem', minHeight: 32 }}>
|
||||
{auth.customGenreBlacklist.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', alignSelf: 'center' }}>{t('settings.randomMixBlacklistEmpty')}</span>
|
||||
) : (
|
||||
auth.customGenreBlacklist.map(genre => (
|
||||
<span key={genre} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: 'color-mix(in srgb, var(--accent) 15%, transparent)',
|
||||
color: 'var(--accent)', borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px 8px', fontSize: 12, fontWeight: 500,
|
||||
}}>
|
||||
{genre}
|
||||
<button
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, fontSize: 14 }}
|
||||
onClick={() => auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
|
||||
aria-label={`Remove ${genre}`}
|
||||
>×</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={newGenre}
|
||||
onChange={e => setNewGenre(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newGenre.trim()) {
|
||||
const trimmed = newGenre.trim();
|
||||
if (!auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.randomMixBlacklistPlaceholder')}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
const trimmed = newGenre.trim();
|
||||
if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
|
||||
auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
|
||||
}
|
||||
setNewGenre('');
|
||||
}}
|
||||
disabled={!newGenre.trim()}
|
||||
>
|
||||
{t('settings.randomMixBlacklistAdd')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, fontWeight: 500, margin: '1rem 0 0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
|
||||
<span key={genre} className="genre-keyword-badge" style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
borderRadius: 'var(--radius-sm)', padding: '2px 8px', fontSize: 12,
|
||||
}}>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
@@ -156,8 +156,7 @@ export function LibraryTab() {
|
||||
onChange={auth.setMixMinRatingFilterEnabled}
|
||||
/>
|
||||
{auth.mixMinRatingFilterEnabled && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -192,7 +191,7 @@ export function LibraryTab() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</SettingsSubCard>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</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>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { SettingsSubCard, SettingsField, SettingsValue } from './SettingsSubCard';
|
||||
import CoverCacheStrategySection from './CoverCacheStrategySection';
|
||||
|
||||
export function StorageTab() {
|
||||
@@ -77,33 +78,32 @@ export function StorageTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup desc={t('settings.mediaDirDesc')}>
|
||||
<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>
|
||||
{auth.mediaDir && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
|
||||
{t('settings.mediaDirHint')}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
@@ -135,42 +135,35 @@ export function StorageTab() {
|
||||
/>
|
||||
|
||||
{auth.hotCacheEnabled && (
|
||||
<div style={{ marginTop: '1.25rem' }}>
|
||||
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedHot')}</span>
|
||||
{hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
|
||||
<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>
|
||||
<div style={{ color: 'var(--text-secondary)' }}>
|
||||
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.hotCacheTrackCount')}</span>
|
||||
{hotCacheTrackCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheMaxMb')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={32} max={20000} step={32} value={snapHotCacheMb(auth.hotCacheMaxMb)} onChange={e => auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 60 }}>{snapHotCacheMb(auth.hotCacheMaxMb)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('settings.hotCacheDebounce')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input type="range" min={0} max={600} step={1} value={Math.min(600, Math.max(0, auth.hotCacheDebounceSec))} onChange={e => auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 80 }}>
|
||||
{Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
|
||||
? t('settings.hotCacheDebounceImmediate')
|
||||
: t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
||||
</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 }}
|
||||
style={{ fontSize: 13, alignSelf: 'flex-start' }}
|
||||
onClick={async () => {
|
||||
await clearHotCacheDisk(mediaDir);
|
||||
refreshHotCacheSize();
|
||||
@@ -178,7 +171,7 @@ export function StorageTab() {
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.hotCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSubCard>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
@@ -190,29 +183,33 @@ export function StorageTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup desc={t('settings.downloadsFolderDesc')}>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -17,6 +17,7 @@ import LicensesPanel from '../LicensesPanel';
|
||||
import SettingsSubSection from '../SettingsSubSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsToggle } from './SettingsToggle';
|
||||
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
|
||||
import { BackupSection } from './BackupSection';
|
||||
import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits';
|
||||
|
||||
@@ -58,25 +59,27 @@ export function SystemTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div className="form-group" style={{ maxWidth: '300px' }}>
|
||||
<CustomSelect
|
||||
value={i18n.language}
|
||||
onChange={v => i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: 'en', label: t('settings.languageEn') },
|
||||
{ value: 'de', label: t('settings.languageDe') },
|
||||
{ value: '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') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<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') },
|
||||
]}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSubCard>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
@@ -119,13 +122,11 @@ export function SystemTab() {
|
||||
onChange={auth.setLinuxWebkitInputForceRepaint}
|
||||
/>
|
||||
{waylandTextRenderAvailable && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="form-group" style={{ maxWidth: '420px' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.linuxWaylandTextRender')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{t('settings.linuxWaylandTextRenderDesc')}
|
||||
</div>
|
||||
<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)}
|
||||
@@ -136,30 +137,28 @@ export function SystemTab() {
|
||||
{ value: 'minimal', label: t('settings.linuxWaylandTextRenderMinimal') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</SettingsField>
|
||||
</SettingsSubCard>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
<SettingsGroup title={t('settings.groupClock')}>
|
||||
<div className="settings-toggle-row">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.clockFormatDesc')}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
@@ -177,26 +176,27 @@ export function SystemTab() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
<SettingsGroup>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||
{t('settings.loggingModeDesc')}
|
||||
</div>
|
||||
<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 style={{ marginTop: '0.75rem' }}>
|
||||
<button className="btn btn-surface" onClick={exportRuntimeLogs}>
|
||||
<Download size={14} />
|
||||
{t('settings.loggingExport')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { InstalledThemes } from './InstalledThemes';
|
||||
import { ThemeImportSection } from './ThemeImportSection';
|
||||
import { ThemeStoreSection } from './ThemeStoreSection';
|
||||
import { SettingsGroup } from './SettingsGroup';
|
||||
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
|
||||
|
||||
/**
|
||||
* A flat, always-visible section. The Themes tab has a single purpose, so its
|
||||
@@ -94,9 +95,8 @@ export function ThemesTab() {
|
||||
const nightM = theme.timeNightStart.split(':')[1];
|
||||
const isSystem = theme.schedulerMode === 'system';
|
||||
return (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerModeLabel')}</label>
|
||||
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
|
||||
<SettingsField label={t('settings.themeSchedulerModeLabel')}>
|
||||
<div className="settings-segmented">
|
||||
<button
|
||||
type="button"
|
||||
@@ -113,9 +113,9 @@ export function ThemesTab() {
|
||||
{t('settings.themeSchedulerModeSystem')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsField>
|
||||
{isSystem && (
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '1rem' }}>
|
||||
<div className="settings-hint settings-hint-info">
|
||||
{t('settings.themeSchedulerSystemRestartHint')}
|
||||
</div>
|
||||
)}
|
||||
@@ -153,7 +153,7 @@ export function ThemesTab() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubCard>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
sanitizeHiResCrossfadeResampleHz,
|
||||
} from '../../../utils/audio/hiResCrossfadeResample';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { SettingsSubCard, SettingsField } from '../SettingsSubCard';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
@@ -28,12 +29,12 @@ export function HiResCrossfadeResampleBlock({
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-norm-block" style={{ marginTop: '0.85rem' }}>
|
||||
<div className="settings-norm-field">
|
||||
<span className="settings-norm-label" style={{ minWidth: 0 }}>
|
||||
{t('settings.hiResCrossfadeResampleTitle')}
|
||||
</span>
|
||||
<div className="settings-norm-help">{t('settings.hiResCrossfadeResampleDesc')}</div>
|
||||
<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
|
||||
@@ -46,10 +47,7 @@ export function HiResCrossfadeResampleBlock({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-norm-help" role="note">
|
||||
{t('settings.hiResCrossfadeResampleWarning')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSubCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../../store/authStore';
|
||||
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '../../../store/authStoreDefaults';
|
||||
import { LoudnessLufsButtonGroup } from '../LoudnessLufsButtonGroup';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { SettingsSubCard, SettingsField, SettingsValue, SettingsCallout } from '../SettingsSubCard';
|
||||
|
||||
interface Props {
|
||||
preAnalysisEffectiveDb: number;
|
||||
@@ -68,116 +69,102 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{auth.normalizationEngine === 'replaygain' && (
|
||||
<div className="settings-norm-block">
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.replayGainMode')}</span>
|
||||
<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>
|
||||
</div>
|
||||
{auth.replayGainMode === 'auto' && (
|
||||
<div className="settings-norm-help">{t('settings.replayGainAutoDesc')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.replayGainPreGain')}</span>
|
||||
<input
|
||||
type="range" min={0} max={6} step={0.5}
|
||||
value={auth.replayGainPreGainDb}
|
||||
onChange={e => auth.setReplayGainPreGainDb(Number(e.target.value))}
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
{auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-norm-help">{t('settings.replayGainPreGainDesc')}</div>
|
||||
</div>
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.replayGainFallback')}</span>
|
||||
<input
|
||||
type="range" min={-6} max={0} step={0.5}
|
||||
value={auth.replayGainFallbackDb}
|
||||
onChange={e => auth.setReplayGainFallbackDb(Number(e.target.value))}
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
{auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-norm-help">{t('settings.replayGainFallbackDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auth.normalizationEngine === 'loudness' && (
|
||||
<div className="settings-norm-block">
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.loudnessTargetLufs')}</span>
|
||||
<LoudnessLufsButtonGroup value={auth.loudnessTargetLufs} onSelect={auth.setLoudnessTargetLufs} />
|
||||
</div>
|
||||
<div className="settings-norm-help">{t('settings.loudnessTargetLufsDesc')}</div>
|
||||
</div>
|
||||
<div className="settings-norm-field">
|
||||
<div className="settings-norm-row">
|
||||
<span className="settings-norm-label">{t('settings.loudnessPreAnalysisAttenuation')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-24}
|
||||
max={0}
|
||||
step={0.5}
|
||||
value={auth.loudnessPreAnalysisAttenuationDb}
|
||||
onChange={e => auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
{preAnalysisEffectiveDb} dB
|
||||
</span>
|
||||
<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
|
||||
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')}
|
||||
className={`btn ${auth.replayGainMode === 'auto' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '4px 14px' }}
|
||||
onClick={() => auth.setReplayGainMode('auto')}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
{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>
|
||||
<div className="settings-norm-help">
|
||||
{t('settings.loudnessPreAnalysisAttenuationDesc')}{' '}
|
||||
{t('settings.loudnessPreAnalysisAttenuationRef', {
|
||||
ref: auth.loudnessPreAnalysisAttenuationDb,
|
||||
eff: preAnalysisEffectiveDb,
|
||||
tgt: auth.loudnessTargetLufs,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useOrbitStore } from '../../../store/orbitStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
import { SettingsSubCard } from '../SettingsSubCard';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -118,10 +119,9 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!showEnable || enabled) && (
|
||||
<>
|
||||
{showEnable && <div className="divider" />}
|
||||
|
||||
{(!showEnable || enabled) && (() => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="playback-rate-strategy-row">
|
||||
{!compact && (
|
||||
<span className="playback-rate-label">{t('settings.playbackRateStrategy')}</span>
|
||||
@@ -281,8 +281,12 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
|
||||
{t('settings.playbackRateNeutral')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return compact
|
||||
? body
|
||||
: <SettingsSubCard style={{ marginTop: '0.85rem' }}>{body}</SettingsSubCard>;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { TrackPreviewLocation } from '../../../store/authStoreTypes';
|
||||
import SettingsSubSection from '../../SettingsSubSection';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
import { SettingsSubCard, SettingsField, SettingsValue } from '../SettingsSubCard';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -37,20 +38,12 @@ export function TrackPreviewsSection({ t }: Props) {
|
||||
/>
|
||||
|
||||
{auth.trackPreviewsEnabled && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewLocationsTitle')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
{t('settings.trackPreviewLocationsDesc')}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}>
|
||||
<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)' }}>
|
||||
@@ -64,58 +57,44 @@ export function TrackPreviewsSection({ t }: Props) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewStart')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewStartDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
value={auth.trackPreviewStartRatio}
|
||||
onChange={e => auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewStart')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{Math.round(auth.trackPreviewStartRatio * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="divider" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>
|
||||
{t('settings.trackPreviewDuration')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
{t('settings.trackPreviewDurationDesc')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={1}
|
||||
value={auth.trackPreviewDurationSec}
|
||||
onChange={e => auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
|
||||
aria-label={t('settings.trackPreviewDuration')}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 44 }}>
|
||||
{t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<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>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../../utils/playback/playbackTransition';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
import { SettingsToggle } from '../SettingsToggle';
|
||||
import { SettingsSubCard, SettingsField, SettingsRow, SettingsValue } from '../SettingsSubCard';
|
||||
|
||||
interface Props {
|
||||
t: TFunction;
|
||||
@@ -70,8 +71,8 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
</div>
|
||||
|
||||
{mode === 'crossfade' && (
|
||||
<div className="settings-norm-block" style={{ marginTop: '0.85rem' }}>
|
||||
<div className="settings-norm-row">
|
||||
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
|
||||
<SettingsRow>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
@@ -82,20 +83,19 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
<SettingsValue>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsValue>
|
||||
</SettingsRow>
|
||||
</SettingsSubCard>
|
||||
)}
|
||||
{mode === 'autodj' && (
|
||||
<div className="settings-norm-block" style={{ marginTop: '0.85rem' }}>
|
||||
<div className="settings-norm-help">{t('settings.autoDjDesc')}</div>
|
||||
<div className="settings-norm-field">
|
||||
<span className="settings-norm-label" style={{ minWidth: 0 }}>
|
||||
{t('settings.autodjOverlapCapTitle')}
|
||||
</span>
|
||||
<div className="settings-norm-help">{t('settings.autodjOverlapCapDesc')}</div>
|
||||
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
|
||||
<SettingsField desc={t('settings.autoDjDesc')} />
|
||||
<SettingsField
|
||||
label={t('settings.autodjOverlapCapTitle')}
|
||||
desc={t('settings.autodjOverlapCapDesc')}
|
||||
>
|
||||
<div className="settings-segmented">
|
||||
<button
|
||||
type="button"
|
||||
@@ -115,7 +115,7 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{auth.autodjOverlapCapMode === 'limit' && (
|
||||
<div className="settings-norm-row">
|
||||
<SettingsRow>
|
||||
<input
|
||||
type="range"
|
||||
min={AUTODJ_OVERLAP_CAP_MIN_SEC}
|
||||
@@ -126,12 +126,12 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
onChange={e => auth.setAutodjOverlapCapSec(parseInt(e.target.value, 10))}
|
||||
id="autodj-overlap-cap-slider"
|
||||
/>
|
||||
<span className="settings-norm-value">
|
||||
<SettingsValue>
|
||||
{t('settings.autodjOverlapCapSecs', { n: auth.autodjOverlapCapSec })}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsValue>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</div>
|
||||
</SettingsField>
|
||||
<SettingsToggle
|
||||
label={t('settings.autodjSmoothSkip')}
|
||||
desc={t('settings.autodjSmoothSkipDesc')}
|
||||
@@ -139,7 +139,7 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
disabled={hostControlled}
|
||||
onChange={auth.setAutodjSmoothSkip}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSubCard>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user