fix(settings): make normalization modes exclusive and refine LUFS UI

Replace the mixed ReplayGain/Loudness controls with a single exclusive mode selector, hide RG-specific controls when LUFS is active, and align the queue tech badge behavior between RG and LUFS. Also update LUFS targets to -10..-16 ordering and default loudness target to -12.
This commit is contained in:
Maxim Isaev
2026-04-25 22:36:55 +03:00
parent 86704473ed
commit e009fd1823
3 changed files with 81 additions and 60 deletions
+23 -10
View File
@@ -483,16 +483,16 @@ export default function QueuePanel() {
const rgParts = formatQueueReplayGainParts(currentTrack, t);
const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · ');
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
const liveGainLabel = normalizationNowDb != null
? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`
: '—';
const targetLabel = normalizationEngineLive === 'loudness'
? (normalizationTargetLufs != null ? `${normalizationTargetLufs} LUFS` : '-16 LUFS')
: normalizationEngineLive === 'replaygain'
? `ReplayGain (${replayGainMode})`
: t('queue.normOff', { defaultValue: 'Off' });
const targetLabel = normalizationTargetLufs != null
? `${normalizationTargetLufs} LUFS`
: '-16 LUFS';
if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = expandReplayGain && !!rgLine;
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
const showLufsLine = isLoudnessActive && expandReplayGain;
return (
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
<div className="queue-current-tech-stack">
@@ -515,7 +515,7 @@ export default function QueuePanel() {
</span>
)}
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
{rgLine && (
{!isLoudnessActive && rgLine && (
<button
type="button"
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
@@ -528,6 +528,19 @@ export default function QueuePanel() {
<ChevronDown size={9} strokeWidth={2.5} />
</button>
)}
{isLoudnessActive && (
<button
type="button"
className={`queue-current-tech-rg-badge${showLufsLine ? ' queue-current-tech-rg-badge--open' : ''}`}
data-tooltip={`LUFS · ${liveGainLabel} · TGT · ${targetLabel}`}
aria-expanded={showLufsLine}
aria-label="LUFS"
onClick={() => setExpandReplayGain(!expandReplayGain)}
>
LUFS
<ChevronDown size={9} strokeWidth={2.5} />
</button>
)}
</div>
{showRgLine && (
<span className="queue-current-tech-rg">
@@ -535,12 +548,12 @@ export default function QueuePanel() {
{' · '}{rgLine}
</span>
)}
{(normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness') && (
{showLufsLine && (
<span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">{t('queue.normNow', { defaultValue: 'Now' })}</span>
<span className="queue-current-tech-rg-label">Loudness</span>
{' · '}{liveGainLabel}
{' · '}
<span className="queue-current-tech-rg-label">{t('queue.normTarget', { defaultValue: 'Target' })}</span>
<span className="queue-current-tech-rg-label">TGT</span>
{' · '}{targetLabel}
</span>
)}
+55 -47
View File
@@ -2217,59 +2217,67 @@ export default function Settings() {
icon={<Music2 size={16} />}
>
<div className="settings-card">
{/* Replay Gain */}
{/* Normalization */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.replayGain')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
<input
type="checkbox"
checked={auth.replayGainEnabled}
onChange={e => {
const on = e.target.checked;
auth.setReplayGainEnabled(on);
if (!on) auth.setNormalizationEngine('off');
else if (auth.normalizationEngine === 'off') auth.setNormalizationEngine('replaygain');
}}
id="replay-gain-toggle"
/>
<span className="toggle-track" />
</label>
</div>
{auth.replayGainEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Engine:</span>
<button
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setNormalizationEngine('replaygain')}
>
ReplayGain
</button>
<button
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setNormalizationEngine('loudness')}
>
Loudness
</button>
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.normalizationDesc', { defaultValue: 'Choose one normalization mode: ReplayGain or Loudness (LUFS).' })}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
className={`btn ${auth.normalizationEngine === 'off' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
auth.setReplayGainEnabled(false);
auth.setNormalizationEngine('off');
}}
>
Off
</button>
<button
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
auth.setReplayGainEnabled(true);
auth.setNormalizationEngine('replaygain');
}}
>
RG
</button>
<button
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
auth.setReplayGainEnabled(false);
if (auth.normalizationEngine !== 'loudness') auth.setLoudnessTargetLufs(-12);
auth.setNormalizationEngine('loudness');
}}
>
LUFS
</button>
</div>
</div>
{auth.normalizationEngine !== 'off' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{auth.normalizationEngine === 'loudness' && (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Apple-like automatic normalization. ReplayGain tags are ignored in this mode.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Target LUFS:</span>
<button
className={`btn ${auth.loudnessTargetLufs === -16 ? 'btn-primary' : 'btn-ghost'}`}
className={`btn ${auth.loudnessTargetLufs === -10 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-16)}
onClick={() => auth.setLoudnessTargetLufs(-10)}
>
-16
-10
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -12 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-12)}
>
-12
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -14 ? 'btn-primary' : 'btn-ghost'}`}
@@ -2279,11 +2287,11 @@ export default function Settings() {
-14
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -12 ? 'btn-primary' : 'btn-ghost'}`}
className={`btn ${auth.loudnessTargetLufs === -16 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-12)}
onClick={() => auth.setLoudnessTargetLufs(-16)}
>
-12
-16
</button>
</div>
</>
@@ -2321,7 +2329,7 @@ export default function Settings() {
)}
</div>
)}
{auth.replayGainEnabled && auth.normalizationEngine === 'replaygain' && (
{auth.normalizationEngine === 'replaygain' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
+3 -3
View File
@@ -51,7 +51,7 @@ interface AuthState {
customGenreBlacklist: string[];
replayGainEnabled: boolean;
normalizationEngine: NormalizationEngine;
loudnessTargetLufs: -16 | -14 | -12;
loudnessTargetLufs: -16 | -14 | -12 | -10;
replayGainMode: 'track' | 'album' | 'auto';
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
@@ -209,7 +209,7 @@ interface AuthState {
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
setNormalizationEngine: (v: NormalizationEngine) => void;
setLoudnessTargetLufs: (v: -16 | -14 | -12) => void;
setLoudnessTargetLufs: (v: -16 | -14 | -12 | -10) => void;
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
setReplayGainPreGainDb: (v: number) => void;
setReplayGainFallbackDb: (v: number) => void;
@@ -321,7 +321,7 @@ export const useAuthStore = create<AuthState>()(
customGenreBlacklist: [],
replayGainEnabled: false,
normalizationEngine: 'off',
loudnessTargetLufs: -14,
loudnessTargetLufs: -12,
replayGainMode: 'auto',
replayGainPreGainDb: 0,
replayGainFallbackDb: 0,