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:
Psychotoxical
2026-06-24 23:13:10 +02:00
committed by GitHub
parent 4e8f16b8ae
commit b6ac879b14
16 changed files with 700 additions and 624 deletions
@@ -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>
+95 -97
View File
@@ -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>
+25 -27
View File
@@ -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>
+5
View File
@@ -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>
+66 -65
View File
@@ -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>
+74 -75
View File
@@ -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>
+104
View File
@@ -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>;
}
+80 -83
View File
@@ -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>
+65 -65
View File
@@ -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>
+6 -6
View File
@@ -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>
);