mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
ui(settings): restructure Normalization section for clarity and breathing room
The mode picker (Off / ReplayGain / LUFS) used to live in the right-hand action slot of a settings-toggle-row and the per-mode controls were stacked into the same parent with footer-style help text. Hard to scan, visually cramped, and odd compared to the rest of the Settings page. Refactor: - Mode picker becomes a full-width segmented row with even-flex buttons, using the new .settings-segmented utility. - Each mode renders its own .settings-norm-block sub-section with a subtle accent tint and border so the active configuration reads as one coherent group. - Inside the block, every setting is its own .settings-norm-field (control row + per-control help text immediately below). 1.1 rem gap between fields, 0.45 rem between row and help — clearly groups related text without crowding. - Sliders no longer max-cap at 200 px and instead flex to fill the row. - Inactive ghost buttons (Off, ReplayGain, RG mode, LUFS targets) get a visible border and a faint surface tint so they read as selectable slots in dark themes too. - LUFS mode gets a dedicated note-box explaining that brief volume drift on the very first play of a new track is the analysis pass at work, not a bug — subsequent plays use the cached measurement, and queued tracks are usually pre-analysed during the previous song. - "Trim before measurement (dB)" renamed to "Pre-analysis attenuation" (and equivalents in 8 locales). - New i18n keys: normalizationDesc, normalizationOff/ReplayGain/Lufs, loudnessTargetLufsDesc, loudnessFirstPlayNote, replayGainPreGainDesc, replayGainFallbackDesc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-1
@@ -846,13 +846,22 @@ export const deTranslation = {
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (getaggte Dateien)',
|
||||
replayGainPreGainDesc: 'Pauschaler Boost, der auf jede ReplayGain-Berechnung aufaddiert wird. Hilft, wenn deine Bibliothek insgesamt zu leise wirkt.',
|
||||
replayGainFallback: 'Fallback (ohne Tags / Radio)',
|
||||
replayGainFallbackDesc: 'Wird auf Titel (und Radio-Streams) ohne ReplayGain-Tags angewendet, damit Untagged-Inhalt nicht lauter aus dem Mix springt.',
|
||||
normalization: 'Normalisierung',
|
||||
normalizationDesc: 'Empfundene Lautstärke über Titel, Alben und Radio angleichen.',
|
||||
normalizationOff: 'Aus',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: 'Ziel-LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung (dB)',
|
||||
loudnessTargetLufsDesc: 'Referenz-Lautstärke, an die alle Titel angepasst werden. Niedrigere Werte = lautere Ausgabe. Streaming-Dienste liegen meist bei etwa -14 LUFS.',
|
||||
loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Zusätzliche Dämpfung, bis die Loudness des Titels gespeichert ist. Beim Streamen folgen nur grobe Schätzungen, keine volle Messung. 0 dB = aus; negativer = leiser.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Standard',
|
||||
loudnessFirstPlayNote:
|
||||
'Beim ersten Abspielen eines neuen Titels kann die Lautstärke kurz schwanken, während gemessen wird. Beim nächsten Mal greift die gespeicherte Messung und alles bleibt stabil. Kommende Queue-Titel werden meist schon während des vorherigen Songs analysiert — in der Praxis tritt das daher selten auf.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
+10
-1
@@ -852,13 +852,22 @@ export const enTranslation = {
|
||||
replayGainTrack: 'Track',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (tagged files)',
|
||||
replayGainPreGainDesc: 'Universal boost added on top of every ReplayGain calculation. Use to compensate if your library feels too quiet overall.',
|
||||
replayGainFallback: 'Fallback (untagged / radio)',
|
||||
replayGainFallbackDesc: 'Applied to tracks (and radio streams) that have no ReplayGain tags at all, so untagged content does not pop louder than the rest.',
|
||||
normalization: 'Normalization',
|
||||
normalizationDesc: 'Even out perceived loudness across tracks, albums and radio.',
|
||||
normalizationOff: 'Off',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: 'Target LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Trim before measurement (dB)',
|
||||
loudnessTargetLufsDesc: 'Reference loudness all tracks are matched to. Lower numbers = louder output. Streaming services typically sit around -14 LUFS.',
|
||||
loudnessPreAnalysisAttenuation: 'Pre-analysis attenuation',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Reset to default',
|
||||
loudnessFirstPlayNote:
|
||||
'First play of a brand-new track may briefly drift in volume while it is being measured. The next play uses the cached measurement and is rock-solid. Upcoming queue tracks are usually pre-analysed during the previous song, so this rarely happens in practice.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
+10
-1
@@ -839,13 +839,22 @@ export const esTranslation = {
|
||||
replayGainTrack: 'Pista',
|
||||
replayGainAlbum: 'Álbum',
|
||||
replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)',
|
||||
replayGainPreGainDesc: 'Aumento universal añadido a cada cálculo de ReplayGain. Útil si tu biblioteca te parece globalmente demasiado baja.',
|
||||
replayGainFallback: 'Respaldo (sin etiquetar / radio)',
|
||||
replayGainFallbackDesc: 'Se aplica a pistas (y emisoras de radio) sin etiquetas ReplayGain, para que el contenido sin etiquetar no salte más fuerte que el resto.',
|
||||
normalization: 'Normalización',
|
||||
normalizationDesc: 'Iguala el volumen percibido entre pistas, álbumes y radio.',
|
||||
normalizationOff: 'Apagado',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: 'LUFS objetivo',
|
||||
loudnessPreAnalysisAttenuation: 'Atenuación antes de medir (dB)',
|
||||
loudnessTargetLufsDesc: 'Volumen de referencia al que se ajustan todas las pistas. Valor más bajo = salida más fuerte. Los servicios de streaming suelen rondar los -14 LUFS.',
|
||||
loudnessPreAnalysisAttenuation: 'Atenuación antes de medir',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Predeterminado',
|
||||
loudnessFirstPlayNote:
|
||||
'En la primera reproducción de un tema nuevo el volumen puede oscilar brevemente mientras se mide. La siguiente vez se usa la medición guardada y todo permanece estable. Las pistas próximas en la cola suelen analizarse durante la canción anterior, por lo que en la práctica esto rara vez ocurre.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Transición entre pistas',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
+10
-1
@@ -834,13 +834,22 @@ export const frTranslation = {
|
||||
replayGainTrack: 'Piste',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pré-Gain (fichiers taggés)',
|
||||
replayGainPreGainDesc: 'Boost universel ajouté à chaque calcul ReplayGain. Utile si votre bibliothèque semble globalement trop discrète.',
|
||||
replayGainFallback: 'Repli (sans tags / radio)',
|
||||
replayGainFallbackDesc: 'Appliqué aux pistes (et flux radio) sans tags ReplayGain, pour que le contenu non taggé ne sorte pas plus fort que le reste.',
|
||||
normalization: 'Normalisation',
|
||||
normalizationDesc: 'Égalise la sonie perçue entre pistes, albums et radio.',
|
||||
normalizationOff: 'Désactivé',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: 'LUFS cible',
|
||||
loudnessPreAnalysisAttenuation: 'Atténuation avant mesure (dB)',
|
||||
loudnessTargetLufsDesc: 'Sonie de référence sur laquelle toutes les pistes sont calées. Valeur plus basse = sortie plus forte. Les services de streaming tournent autour de -14 LUFS.',
|
||||
loudnessPreAnalysisAttenuation: 'Atténuation avant mesure',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Réduit le volume tant que la loudness du morceau n’est pas enregistrée. En streaming, des estimations grossières suivent, pas une mesure complète. 0 dB = rien ; plus négatif = plus discret.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Valeur par défaut',
|
||||
loudnessFirstPlayNote:
|
||||
'À la première lecture d’un nouveau morceau, le volume peut brièvement varier pendant la mesure. La fois suivante, la mesure mise en cache prend le relais et tout reste stable. Les pistes à venir dans la file sont en général pré-analysées pendant la lecture précédente, ce cas est donc rare en pratique.',
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
+10
-1
@@ -833,13 +833,22 @@ export const nbTranslation = {
|
||||
replayGainTrack: 'Spor',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (taggede filer)',
|
||||
replayGainPreGainDesc: 'Universelt løft som legges på hver ReplayGain-beregning. Nyttig hvis biblioteket ditt totalt sett virker for stille.',
|
||||
replayGainFallback: 'Reserveverdi (uten tagger / radio)',
|
||||
replayGainFallbackDesc: 'Brukes for spor (og radiostrømmer) uten ReplayGain-tagger, slik at utagget innhold ikke spretter høyere enn resten.',
|
||||
normalization: 'Normalisering',
|
||||
normalizationDesc: 'Jevn ut opplevd loudness mellom spor, album og radio.',
|
||||
normalizationOff: 'Av',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: 'Mål-LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Demping før måling (dB)',
|
||||
loudnessTargetLufsDesc: 'Referanse-loudness alle spor matches mot. Lavere tall = høyere utgang. Strømmetjenester ligger vanligvis rundt -14 LUFS.',
|
||||
loudnessPreAnalysisAttenuation: 'Demping før måling',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Standard',
|
||||
loudnessFirstPlayNote:
|
||||
'Første avspilling av et helt nytt spor kan drive litt i volum mens målingen skjer. Neste gang brukes den lagrede målingen, og alt blir stabilt. Kommende spor i køen blir vanligvis pre-analysert mens det forrige spilles, så dette skjer sjelden i praksis.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Tone mellom spor',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
|
||||
+10
-1
@@ -833,13 +833,22 @@ export const nlTranslation = {
|
||||
replayGainTrack: 'Nummer',
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (getagde bestanden)',
|
||||
replayGainPreGainDesc: 'Universele boost bovenop elke ReplayGain-berekening. Handig als je bibliotheek over het geheel te zacht aanvoelt.',
|
||||
replayGainFallback: 'Terugval (zonder tags / radio)',
|
||||
replayGainFallbackDesc: 'Toegepast op nummers (en radiostreams) zonder ReplayGain-tags, zodat ongetagde inhoud er niet harder uitspringt dan de rest.',
|
||||
normalization: 'Normalisatie',
|
||||
normalizationDesc: 'Egaliseer de waargenomen luidheid tussen nummers, albums en radio.',
|
||||
normalizationOff: 'Uit',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: 'Doel-LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Demping vóór meting (dB)',
|
||||
loudnessTargetLufsDesc: 'Referentie-luidheid waarop alle nummers worden afgestemd. Lagere waarde = luidere uitvoer. Streamingdiensten zitten meestal rond -14 LUFS.',
|
||||
loudnessPreAnalysisAttenuation: 'Demping vóór meting',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Standaard',
|
||||
loudnessFirstPlayNote:
|
||||
'Bij de eerste keer afspelen van een nieuw nummer kan het volume even fluctueren terwijl er gemeten wordt. De volgende keer gebruikt de speler de opgeslagen meting en blijft alles rotsvast. Komende nummers in de wachtrij worden meestal al tijdens het vorige nummer geanalyseerd, dus dit komt in de praktijk zelden voor.',
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
+10
-1
@@ -885,13 +885,22 @@ export const ruTranslation = {
|
||||
replayGainTrack: 'По треку',
|
||||
replayGainAlbum: 'По альбому',
|
||||
replayGainPreGain: 'Предусиление (файлы с тегами)',
|
||||
replayGainPreGainDesc: 'Общая прибавка, добавляемая к каждому расчёту ReplayGain. Удобно, если библиотека в целом кажется тихой.',
|
||||
replayGainFallback: 'Резерв (без тегов / радио)',
|
||||
replayGainFallbackDesc: 'Применяется к трекам (и радиопотокам) без тегов ReplayGain, чтобы непомеченный контент не звучал громче остального.',
|
||||
normalization: 'Нормализация',
|
||||
normalizationDesc: 'Выровнять воспринимаемую громкость между треками, альбомами и радио.',
|
||||
normalizationOff: 'Выкл.',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: 'Целевой LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Ослабление до измерения (dB)',
|
||||
loudnessTargetLufsDesc: 'Опорная громкость, к которой подгоняются все треки. Меньше число = громче на выходе. Стриминг-сервисы обычно держат около -14 LUFS.',
|
||||
loudnessPreAnalysisAttenuation: 'Ослабление до измерения',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.',
|
||||
loudnessPreAnalysisAttenuationReset: 'По умолчанию',
|
||||
loudnessFirstPlayNote:
|
||||
'При первом прослушивании нового трека громкость может ненадолго колебаться, пока идёт измерение. Со второго раза применяется уже сохранённое значение и всё стабильно. Следующие треки в очереди обычно анализируются во время предыдущей композиции, поэтому на практике это случается редко.',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
|
||||
+10
-1
@@ -828,13 +828,22 @@ export const zhTranslation = {
|
||||
replayGainTrack: '曲目',
|
||||
replayGainAlbum: '专辑',
|
||||
replayGainPreGain: '预增益(有标签文件)',
|
||||
replayGainPreGainDesc: '对所有 ReplayGain 计算结果叠加的统一增益。适合在曲库整体偏轻时用作整体补偿。',
|
||||
replayGainFallback: '回退增益(无标签 / 收音机)',
|
||||
replayGainFallbackDesc: '应用于没有 ReplayGain 标签的曲目(以及电台流),避免未打标签的内容比其余素材更响。',
|
||||
normalization: '响度归一化',
|
||||
normalizationDesc: '在曲目、专辑和电台之间统一可感知的响度。',
|
||||
normalizationOff: '关闭',
|
||||
normalizationReplayGain: 'ReplayGain',
|
||||
normalizationLufs: 'LUFS',
|
||||
loudnessTargetLufs: '目标 LUFS',
|
||||
loudnessPreAnalysisAttenuation: '测量前衰减 (dB)',
|
||||
loudnessTargetLufsDesc: '所有曲目对齐的参考响度。数值越小,输出越响。流媒体服务通常在 -14 LUFS 左右。',
|
||||
loudnessPreAnalysisAttenuation: '测量前衰减',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。',
|
||||
loudnessPreAnalysisAttenuationReset: '恢复默认',
|
||||
loudnessFirstPlayNote:
|
||||
'首次播放新曲目时,音量可能在测量过程中略有波动。下一次播放将使用缓存的测量结果,保持稳定。队列中即将播放的曲目通常在上一首播放期间已完成预分析,所以这种情况在实际使用中很少出现。',
|
||||
crossfade: '交叉淡入淡出',
|
||||
crossfadeDesc: '曲目间淡入淡出',
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
|
||||
+136
-138
@@ -2272,150 +2272,148 @@ export default function Settings() {
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Normalization */}
|
||||
<div className="settings-toggle-row">
|
||||
<div style={{ marginBottom: '0.6rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</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 style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{t('settings.normalizationDesc')}
|
||||
</div>
|
||||
</div>
|
||||
{auth.normalizationEngine === 'loudness' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.55rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
|
||||
{t('settings.loudnessTargetLufs')}
|
||||
</span>
|
||||
<LoudnessLufsButtonGroup value={auth.loudnessTargetLufs} onSelect={auth.setLoudnessTargetLufs} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
|
||||
{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))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 44, textAlign: 'right' }}>
|
||||
{auth.loudnessPreAnalysisAttenuationDb} dB
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.35, maxWidth: 520 }}>
|
||||
{t('settings.loudnessPreAnalysisAttenuationDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auth.normalizationEngine !== 'off' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{auth.normalizationEngine === 'replaygain' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'auto' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setReplayGainMode('auto')}
|
||||
>
|
||||
{t('settings.replayGainAuto')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'track' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setReplayGainMode('track')}
|
||||
>
|
||||
{t('settings.replayGainTrack')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.replayGainMode === 'album' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setReplayGainMode('album')}
|
||||
>
|
||||
{t('settings.replayGainAlbum')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{auth.normalizationEngine === 'replaygain' && auth.replayGainMode === 'auto' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.replayGainAutoDesc')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.normalizationEngine === 'off' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(false);
|
||||
auth.setNormalizationEngine('off');
|
||||
}}
|
||||
>
|
||||
{t('settings.normalizationOff')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(true);
|
||||
auth.setNormalizationEngine('replaygain');
|
||||
}}
|
||||
>
|
||||
{t('settings.normalizationReplayGain')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(false);
|
||||
if (auth.normalizationEngine !== 'loudness') auth.setLoudnessTargetLufs(-12);
|
||||
auth.setNormalizationEngine('loudness');
|
||||
}}
|
||||
>
|
||||
{t('settings.normalizationLufs')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.normalizationEngine === 'replaygain' && (
|
||||
<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 }}>
|
||||
{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))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 160 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 40, textAlign: 'right' }}>
|
||||
{auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
|
||||
</span>
|
||||
<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 style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
||||
{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))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 160 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 40, textAlign: 'right' }}>
|
||||
{auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
|
||||
</span>
|
||||
<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">
|
||||
{auth.loudnessPreAnalysisAttenuationDb} dB
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<div className="settings-norm-help">{t('settings.loudnessPreAnalysisAttenuationDesc')}</div>
|
||||
</div>
|
||||
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3707,6 +3707,104 @@
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
/* ─ Normalization block (per-mode sub-section) ─ */
|
||||
.settings-segmented {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.settings-segmented > .btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.settings-segmented > .btn-ghost {
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
|
||||
}
|
||||
.settings-segmented > .btn-ghost:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.settings-norm-block .btn-ghost {
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
|
||||
}
|
||||
.settings-norm-block .btn-ghost:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.settings-norm-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding: 1.2rem 1.25rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.settings-norm-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.settings-norm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-norm-row > input[type="range"] {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.settings-norm-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.settings-norm-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.settings-norm-help {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.settings-norm-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-left: 2px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-norm-label {
|
||||
min-width: 0;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user