From 76ed6eca5550a3935df1cb423e55fb5f81f0f535 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher Date: Tue, 21 Apr 2026 19:12:19 +0200 Subject: [PATCH] feat(replaygain): add Auto mode that picks track vs album gain from queue context (#242) Auto mode is the new default for ReplayGain. It picks album gain when an adjacent queue neighbour shares the same albumId (so "Play All" on an album, or any contiguous album block, gets album gain), otherwise track gain. Falls back to track gain when album gain is missing. - authStore: replayGainMode widened to 'track' | 'album' | 'auto', default 'auto' - playerStore: new resolveReplayGainDb(track, prev, next, enabled, mode) helper replaces five inline ternaries (audio_play hot path, gapless preload, cold-resume success + fallback, live update_replay_gain) - Settings: third "Auto" button + contextual hint when active - i18n: replayGainAuto + replayGainAutoDesc added to all 8 locales Co-authored-by: Psychotoxical Co-authored-by: Claude Opus 4.7 (1M context) --- src/locales/de.ts | 2 + src/locales/en.ts | 2 + src/locales/es.ts | 2 + src/locales/fr.ts | 2 + src/locales/nb.ts | 2 + src/locales/nl.ts | 2 + src/locales/ru.ts | 2 + src/locales/zh.ts | 2 + src/pages/Settings.tsx | 46 +++++++++++++-------- src/store/authStore.ts | 6 +-- src/store/playerStore.ts | 89 +++++++++++++++++++++++++++++----------- 11 files changed, 115 insertions(+), 42 deletions(-) diff --git a/src/locales/de.ts b/src/locales/de.ts index 904be52f..80c9aaa1 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -716,6 +716,8 @@ export const deTranslation = { replayGain: 'Replay Gain', replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren', replayGainMode: 'Modus', + replayGainAuto: 'Auto', + replayGainAutoDesc: 'Wählt Album-Gain, wenn benachbarte Tracks in der Warteschlange vom selben Album sind, sonst Track-Gain.', replayGainTrack: 'Track', replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getaggte Dateien)', diff --git a/src/locales/en.ts b/src/locales/en.ts index 3ac27695..824a7185 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -718,6 +718,8 @@ export const enTranslation = { replayGain: 'Replay Gain', replayGainDesc: 'Normalize track volume using ReplayGain metadata', replayGainMode: 'Mode', + replayGainAuto: 'Auto', + replayGainAutoDesc: 'Picks album gain when neighbouring queue tracks are from the same album, otherwise track gain.', replayGainTrack: 'Track', replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (tagged files)', diff --git a/src/locales/es.ts b/src/locales/es.ts index c07cc962..57c59e77 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -709,6 +709,8 @@ export const esTranslation = { replayGain: 'Replay Gain', replayGainDesc: 'Normalizar volumen de pistas usando metadatos ReplayGain', replayGainMode: 'Modo', + replayGainAuto: 'Auto', + replayGainAutoDesc: 'Usa la ganancia del álbum cuando las pistas adyacentes en la cola son del mismo álbum, si no la ganancia de pista.', replayGainTrack: 'Pista', replayGainAlbum: 'Álbum', replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 375b08a8..582696ee 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -704,6 +704,8 @@ export const frTranslation = { replayGain: 'Replay Gain', replayGainDesc: 'Normaliser le volume des pistes avec les métadonnées ReplayGain', replayGainMode: 'Mode', + replayGainAuto: 'Auto', + replayGainAutoDesc: 'Utilise le gain d\'album quand les pistes voisines de la file proviennent du même album, sinon le gain de piste.', replayGainTrack: 'Piste', replayGainAlbum: 'Album', replayGainPreGain: 'Pré-Gain (fichiers taggés)', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 22fff46a..21c03df5 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -703,6 +703,8 @@ export const nbTranslation = { replayGain: 'Replay Gain', replayGainDesc: 'Normaliser sporvolumet ved hjelp av ReplayGain-metadata', replayGainMode: 'Modus', + replayGainAuto: 'Auto', + replayGainAutoDesc: 'Bruker albumforsterkning når nabospor i køen er fra samme album, ellers sporforsterkning.', replayGainTrack: 'Spor', replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (taggede filer)', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 9d857322..c4ad696a 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -703,6 +703,8 @@ export const nlTranslation = { replayGain: 'Replay Gain', replayGainDesc: 'Nummervolume normaliseren met ReplayGain-metadata', replayGainMode: 'Modus', + replayGainAuto: 'Auto', + replayGainAutoDesc: 'Kiest album-gain wanneer aangrenzende nummers in de wachtrij van hetzelfde album zijn, anders nummer-gain.', replayGainTrack: 'Nummer', replayGainAlbum: 'Album', replayGainPreGain: 'Pre-Gain (getagde bestanden)', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 633dcb14..886407eb 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -733,6 +733,8 @@ export const ruTranslation = { replayGain: 'Replay Gain', replayGainDesc: 'Выравнивание громкости по метаданным ReplayGain', replayGainMode: 'Режим', + replayGainAuto: 'Авто', + replayGainAutoDesc: 'Использует громкость альбома, когда соседние треки в очереди из того же альбома, иначе громкость трека.', replayGainTrack: 'По треку', replayGainAlbum: 'По альбому', replayGainPreGain: 'Предусиление (файлы с тегами)', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index fa0d3009..f8d87dc2 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -699,6 +699,8 @@ export const zhTranslation = { replayGain: '回放增益', replayGainDesc: '使用 ReplayGain 元数据标准化曲目音量', replayGainMode: '模式', + replayGainAuto: '自动', + replayGainAutoDesc: '当队列中相邻曲目来自同一专辑时使用专辑增益,否则使用曲目增益。', replayGainTrack: '曲目', replayGainAlbum: '专辑', replayGainPreGain: '预增益(有标签文件)', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ed2a6f2c..7ba7c4a2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1314,22 +1314,36 @@ export default function Settings() { {auth.replayGainEnabled && ( -
- {t('settings.replayGainMode')}: - - +
+
+ {t('settings.replayGainMode')}: + + + +
+ {auth.replayGainMode === 'auto' && ( +
+ {t('settings.replayGainAutoDesc')} +
+ )}
)} {auth.replayGainEnabled && ( diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 9484138e..89dcea11 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -49,7 +49,7 @@ interface AuthState { excludeAudiobooks: boolean; customGenreBlacklist: string[]; replayGainEnabled: boolean; - replayGainMode: 'track' | 'album'; + 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) crossfadeEnabled: boolean; @@ -200,7 +200,7 @@ interface AuthState { setExcludeAudiobooks: (v: boolean) => void; setCustomGenreBlacklist: (v: string[]) => void; setReplayGainEnabled: (v: boolean) => void; - setReplayGainMode: (v: 'track' | 'album') => void; + setReplayGainMode: (v: 'track' | 'album' | 'auto') => void; setReplayGainPreGainDb: (v: number) => void; setReplayGainFallbackDb: (v: number) => void; setCrossfadeEnabled: (v: boolean) => void; @@ -308,7 +308,7 @@ export const useAuthStore = create()( excludeAudiobooks: false, customGenreBlacklist: [], replayGainEnabled: false, - replayGainMode: 'track', + replayGainMode: 'auto', replayGainPreGainDb: 0, replayGainFallbackDb: 0, crossfadeEnabled: false, diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index e01903af..27757c63 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -64,6 +64,37 @@ export function songToTrack(song: SubsonicSong): Track { }; } +/** + * Resolve the ReplayGain dB value for a track based on the configured mode. + * In 'auto' mode, picks album-gain when an adjacent queue neighbour shares the + * same albumId (i.e. the track is being played as part of an album), otherwise + * track-gain. Falls back to track-gain when album-gain is missing. + */ +export function resolveReplayGainDb( + track: Track, + prevTrack: Track | null | undefined, + nextTrack: Track | null | undefined, + enabled: boolean, + mode: 'track' | 'album' | 'auto', +): number | null { + if (!enabled) return null; + let useAlbum: boolean; + if (mode === 'album') { + useAlbum = true; + } else if (mode === 'track') { + useAlbum = false; + } else { + const albumId = track.albumId; + useAlbum = !!albumId && ( + prevTrack?.albumId === albumId || nextTrack?.albumId === albumId + ); + } + const value = useAlbum + ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) + : track.replayGainTrackDb; + return value ?? null; +} + export function shuffleArray(items: T[]): T[] { const arr = [...items]; for (let i = arr.length - 1; i > 0; i--) { @@ -590,11 +621,15 @@ function handleAudioProgress(current_time: number, duration: number) { if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) { gaplessPreloadingId = nextTrack.id; const authState = useAuthStore.getState(); - const replayGainDb = authState.replayGainEnabled - ? (authState.replayGainMode === 'album' - ? (nextTrack.replayGainAlbumDb ?? nextTrack.replayGainTrackDb) - : nextTrack.replayGainTrackDb) ?? null - : null; + // Auto-mode neighbours for the *next* track: current track on its left, + // queue[nextIdx+1] on its right. + const nextNeighbour = nextIdx + 1 < queue.length + ? queue[nextIdx + 1] + : (repeatMode === 'all' && queue.length > 0 ? queue[0] : null); + const replayGainDb = resolveReplayGainDb( + nextTrack, track, nextNeighbour, + authState.replayGainEnabled, authState.replayGainMode, + ); const replayGainPeak = authState.replayGainEnabled ? (nextTrack.replayGainPeak ?? null) : null; @@ -1210,9 +1245,12 @@ export const usePlayerStore = create()( ); } setDeferHotCachePrefetch(true); - const replayGainDb = authState.replayGainEnabled - ? (authState.replayGainMode === 'album' ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) : track.replayGainTrackDb) ?? null - : null; + const playIdx = idx >= 0 ? idx : 0; + const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null; + const replayGainDb = resolveReplayGainDb( + track, prevTrack, nextNeighbour, + authState.replayGainEnabled, authState.replayGainMode, + ); const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null; invoke('audio_play', { url, @@ -1296,8 +1334,10 @@ export const usePlayerStore = create()( set({ isPlaying: true }); return; } - const { currentTrack, queue, currentTime } = get(); + const { currentTrack, queue, queueIndex, currentTime } = get(); if (!currentTrack) return; + const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; + const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; if (isAudioPaused) { // Rust engine has audio loaded but paused — just resume it. @@ -1317,9 +1357,10 @@ export const usePlayerStore = create()( // Update store with fresh track data if available if (freshSong) set({ currentTrack: trackToPlay }); const authStateCold = useAuthStore.getState(); - const replayGainDbCold = authStateCold.replayGainEnabled - ? (authStateCold.replayGainMode === 'album' ? (trackToPlay.replayGainAlbumDb ?? trackToPlay.replayGainTrackDb) : trackToPlay.replayGainTrackDb) ?? null - : null; + const replayGainDbCold = resolveReplayGainDb( + trackToPlay, coldPrev, coldNext, + authStateCold.replayGainEnabled, authStateCold.replayGainMode, + ); const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null; const coldServerId = useAuthStore.getState().activeServerId ?? ''; setDeferHotCachePrefetch(true); @@ -1350,9 +1391,10 @@ export const usePlayerStore = create()( if (playGeneration !== gen) return; // Fallback to currentTrack if fetch fails const authStateCold = useAuthStore.getState(); - const replayGainDbCold = authStateCold.replayGainEnabled - ? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null - : null; + const replayGainDbCold = resolveReplayGainDb( + currentTrack, coldPrev, coldNext, + authStateCold.replayGainEnabled, authStateCold.replayGainMode, + ); const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null; const coldServerId = useAuthStore.getState().activeServerId ?? ''; setDeferHotCachePrefetch(true); @@ -1732,16 +1774,17 @@ export const usePlayerStore = create()( }, updateReplayGainForCurrentTrack: () => { - const { currentTrack, volume } = get(); + const { currentTrack, queue, queueIndex, volume } = get(); if (!currentTrack || !currentTrack.id) return; const authState = useAuthStore.getState(); - const replayGainDb = authState.replayGainEnabled - ? (authState.replayGainMode === 'album' - ? (currentTrack.replayGainAlbumDb ?? currentTrack.replayGainTrackDb) - : currentTrack.replayGainTrackDb) ?? null - : null; - const replayGainPeak = authState.replayGainEnabled - ? (currentTrack.replayGainPeak ?? null) + const prev = queueIndex > 0 ? queue[queueIndex - 1] : null; + const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; + const replayGainDb = resolveReplayGainDb( + currentTrack, prev, next, + authState.replayGainEnabled, authState.replayGainMode, + ); + const replayGainPeak = authState.replayGainEnabled + ? (currentTrack.replayGainPeak ?? null) : null; invoke('audio_update_replay_gain', {