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 rgParts = formatQueueReplayGainParts(currentTrack, t);
const baseLine = baseParts.join(' · '); const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · '); const rgLine = rgParts.join(' · ');
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
const liveGainLabel = normalizationNowDb != null const liveGainLabel = normalizationNowDb != null
? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB` ? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`
: '—'; : '—';
const targetLabel = normalizationEngineLive === 'loudness' const targetLabel = normalizationTargetLufs != null
? (normalizationTargetLufs != null ? `${normalizationTargetLufs} LUFS` : '-16 LUFS') ? `${normalizationTargetLufs} LUFS`
: normalizationEngineLive === 'replaygain' : '-16 LUFS';
? `ReplayGain (${replayGainMode})`
: t('queue.normOff', { defaultValue: 'Off' });
if (!baseLine && !rgLine && !playbackSource) return null; if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = expandReplayGain && !!rgLine; const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
const showLufsLine = isLoudnessActive && expandReplayGain;
return ( return (
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}> <div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
<div className="queue-current-tech-stack"> <div className="queue-current-tech-stack">
@@ -515,7 +515,7 @@ export default function QueuePanel() {
</span> </span>
)} )}
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>} {baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
{rgLine && ( {!isLoudnessActive && rgLine && (
<button <button
type="button" type="button"
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`} 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} /> <ChevronDown size={9} strokeWidth={2.5} />
</button> </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> </div>
{showRgLine && ( {showRgLine && (
<span className="queue-current-tech-rg"> <span className="queue-current-tech-rg">
@@ -535,12 +548,12 @@ export default function QueuePanel() {
{' · '}{rgLine} {' · '}{rgLine}
</span> </span>
)} )}
{(normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness') && ( {showLufsLine && (
<span className="queue-current-tech-rg"> <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} {' · '}{liveGainLabel}
{' · '} {' · '}
<span className="queue-current-tech-rg-label">{t('queue.normTarget', { defaultValue: 'Target' })}</span> <span className="queue-current-tech-rg-label">TGT</span>
{' · '}{targetLabel} {' · '}{targetLabel}
</span> </span>
)} )}
+55 -47
View File
@@ -2217,59 +2217,67 @@ export default function Settings() {
icon={<Music2 size={16} />} icon={<Music2 size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
{/* Replay Gain */} {/* Normalization */}
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div>
<div style={{ fontWeight: 500 }}>{t('settings.replayGain')}</div> <div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
</div> {t('settings.normalizationDesc', { defaultValue: 'Choose one normalization mode: ReplayGain or Loudness (LUFS).' })}
<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> </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' && ( {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' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Target LUFS:</span> <span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Target LUFS:</span>
<button <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' }} 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>
<button <button
className={`btn ${auth.loudnessTargetLufs === -14 ? 'btn-primary' : 'btn-ghost'}`} className={`btn ${auth.loudnessTargetLufs === -14 ? 'btn-primary' : 'btn-ghost'}`}
@@ -2279,11 +2287,11 @@ export default function Settings() {
-14 -14
</button> </button>
<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' }} style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-12)} onClick={() => auth.setLoudnessTargetLufs(-16)}
> >
-12 -16
</button> </button>
</div> </div>
</> </>
@@ -2321,7 +2329,7 @@ export default function Settings() {
)} )}
</div> </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={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}> <span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
+3 -3
View File
@@ -51,7 +51,7 @@ interface AuthState {
customGenreBlacklist: string[]; customGenreBlacklist: string[];
replayGainEnabled: boolean; replayGainEnabled: boolean;
normalizationEngine: NormalizationEngine; normalizationEngine: NormalizationEngine;
loudnessTargetLufs: -16 | -14 | -12; loudnessTargetLufs: -16 | -14 | -12 | -10;
replayGainMode: 'track' | 'album' | 'auto'; replayGainMode: 'track' | 'album' | 'auto';
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
@@ -209,7 +209,7 @@ interface AuthState {
setCustomGenreBlacklist: (v: string[]) => void; setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void; setReplayGainEnabled: (v: boolean) => void;
setNormalizationEngine: (v: NormalizationEngine) => void; setNormalizationEngine: (v: NormalizationEngine) => void;
setLoudnessTargetLufs: (v: -16 | -14 | -12) => void; setLoudnessTargetLufs: (v: -16 | -14 | -12 | -10) => void;
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
setReplayGainPreGainDb: (v: number) => void; setReplayGainPreGainDb: (v: number) => void;
setReplayGainFallbackDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void;
@@ -321,7 +321,7 @@ export const useAuthStore = create<AuthState>()(
customGenreBlacklist: [], customGenreBlacklist: [],
replayGainEnabled: false, replayGainEnabled: false,
normalizationEngine: 'off', normalizationEngine: 'off',
loudnessTargetLufs: -14, loudnessTargetLufs: -12,
replayGainMode: 'auto', replayGainMode: 'auto',
replayGainPreGainDb: 0, replayGainPreGainDb: 0,
replayGainFallbackDb: 0, replayGainFallbackDb: 0,