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 <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-21 19:12:19 +02:00
committed by GitHub
parent fb02b62a54
commit 76ed6eca55
11 changed files with 115 additions and 42 deletions
+3 -3
View File
@@ -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<AuthState>()(
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
replayGainMode: 'track',
replayGainMode: 'auto',
replayGainPreGainDb: 0,
replayGainFallbackDb: 0,
crossfadeEnabled: false,
+66 -23
View File
@@ -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<T>(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<PlayerState>()(
);
}
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<PlayerState>()(
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<PlayerState>()(
// 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<PlayerState>()(
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<PlayerState>()(
},
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', {