mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
committed by
GitHub
parent
fb02b62a54
commit
76ed6eca55
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -733,6 +733,8 @@ export const ruTranslation = {
|
||||
replayGain: 'Replay Gain',
|
||||
replayGainDesc: 'Выравнивание громкости по метаданным ReplayGain',
|
||||
replayGainMode: 'Режим',
|
||||
replayGainAuto: 'Авто',
|
||||
replayGainAutoDesc: 'Использует громкость альбома, когда соседние треки в очереди из того же альбома, иначе громкость трека.',
|
||||
replayGainTrack: 'По треку',
|
||||
replayGainAlbum: 'По альбому',
|
||||
replayGainPreGain: 'Предусиление (файлы с тегами)',
|
||||
|
||||
@@ -699,6 +699,8 @@ export const zhTranslation = {
|
||||
replayGain: '回放增益',
|
||||
replayGainDesc: '使用 ReplayGain 元数据标准化曲目音量',
|
||||
replayGainMode: '模式',
|
||||
replayGainAuto: '自动',
|
||||
replayGainAutoDesc: '当队列中相邻曲目来自同一专辑时使用专辑增益,否则使用曲目增益。',
|
||||
replayGainTrack: '曲目',
|
||||
replayGainAlbum: '专辑',
|
||||
replayGainPreGain: '预增益(有标签文件)',
|
||||
|
||||
+30
-16
@@ -1314,22 +1314,36 @@ export default function Settings() {
|
||||
</label>
|
||||
</div>
|
||||
{auth.replayGainEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
||||
<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 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)' }}>{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.replayGainMode === 'auto' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.replayGainAutoDesc')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{auth.replayGainEnabled && (
|
||||
|
||||
@@ -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
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user