From 95cdbc7fc75e6e9e4105728459de2eb53c5309f0 Mon Sep 17 00:00:00 2001 From: trbn <12391889+trbn1@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:49:23 +0200 Subject: [PATCH] fix: replay gain not applying to tracks Replay gain was not working because track objects were created manually without including replay gain metadata from the Subsonic API response. Changes: - Add songToTrack() helper function to properly map SubsonicSong to Track with replayGainTrackDb, replayGainAlbumDb, and replayGainPeak fields - Add audio_update_replay_gain Tauri command for dynamic volume recalculation when replay gain settings change mid-playback - Add updateReplayGainForCurrentTrack() to recalculate volume when toggling replay gain setting - Fetch fresh track data on cold resume (app relaunch) to ensure replay gain values are current from server - Update all files that create track objects to use songToTrack() Fixes issue where toggling replay gain ON/OFF or changing between track/album mode had no effect on currently playing or newly played tracks. --- package-lock.json | 14 +-- src-tauri/src/audio.rs | 16 +++ src-tauri/src/lib.rs | 1 + src/components/AlbumTrackList.tsx | 16 +-- src/components/ContextMenu.tsx | 32 +++--- src/components/Hero.tsx | 22 ++-- src/components/LiveSearch.tsx | 18 ++-- src/components/QueuePanel.tsx | 40 +++----- src/pages/AlbumDetail.tsx | 59 +++++------ src/pages/ArtistDetail.tsx | 46 ++++----- src/pages/Favorites.tsx | 52 ++++------ src/pages/OfflineLibrary.tsx | 31 +++--- src/pages/RandomMix.tsx | 112 ++++++++++----------- src/pages/SearchResults.tsx | 29 ++---- src/store/authStore.ts | 12 ++- src/store/playerStore.ts | 161 +++++++++++++++++++----------- 16 files changed, 332 insertions(+), 329 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30d5e621..ca8359c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "psysonic", - "version": "1.18.0", + "version": "1.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.18.0", + "version": "1.21.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-global-shortcut": "^2", - "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-window-state": "^2.4.1", @@ -1494,15 +1493,6 @@ "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@tauri-apps/plugin-notification": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", - "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, "node_modules/@tauri-apps/plugin-shell": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 00c71d08..1b67d4d0 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1623,6 +1623,22 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) { } } +#[tauri::command] +pub fn audio_update_replay_gain( + volume: f32, + replay_gain_db: Option, + replay_gain_peak: Option, + state: State<'_, AudioEngine>, +) { + let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, volume); + let mut cur = state.current.lock().unwrap(); + cur.replay_gain_linear = gain_linear; + cur.base_volume = volume.clamp(0.0, 1.0); + if let Some(sink) = &cur.sink { + sink.set_volume(effective); + } +} + #[tauri::command] pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) { state.eq_enabled.store(enabled, Ordering::Relaxed); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d523519c..c411c00f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -405,6 +405,7 @@ pub fn run() { audio::audio_stop, audio::audio_seek, audio::audio_set_volume, + audio::audio_update_replay_gain, audio::audio_set_eq, audio::audio_preload, audio::audio_set_crossfade, diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 5c3f9318..5f2a4dda 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Play, Star } from 'lucide-react'; import { SubsonicSong } from '../api/subsonic'; -import { Track, usePlayerStore } from '../store/playerStore'; +import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; function formatDuration(seconds: number): string { @@ -81,15 +81,7 @@ export default function AlbumTrackList({ discs.get(disc)!.push(song); }); const discNums = Array.from(discs.keys()).sort((a, b) => a - b); - const isMultiDisc = discNums.length > 1; - - const makeTrack = (song: SubsonicSong): Track => ({ - id: song.id, title: song.title, artist: song.artist, album: song.album, - albumId: song.albumId, artistId: song.artistId, duration: song.duration, - coverArt: song.coverArt, track: song.track, year: song.year, - bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, - starred: song.starred, genre: song.genre, - }); + const isMultiDisc = discNums.length > 1; return (
@@ -121,13 +113,13 @@ export default function AlbumTrackList({ onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); - onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song'); + onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); }} role="row" draggable onDragStart={e => { e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) })); + e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: songToTrack(song) })); }} >
{ try { const similar = await getSimilarSongs2(artistId); - if (similar.length > 0) { - const top = await getTopSongs(artistName); - const radioTracks = [...top, ...similar].map(s => ({ - id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, - year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre, - })); - playTrack(radioTracks[0], radioTracks); - } + if (similar.length > 0) { + const top = await getTopSongs(artistName); + const radioTracks = [...top, ...similar].map(songToTrack); + playTrack(radioTracks[0], radioTracks); + } } catch (e) { console.error('Failed to start radio', e); } @@ -129,16 +125,12 @@ export default function ContextMenu() {
handleAction(() => enqueue([song]))}> {t('contextMenu.addToQueue')}
- {type === 'album-song' && ( -
handleAction(async () => { - const albumData = await getAlbum(song.albumId); - const tracks = albumData.songs.map(s => ({ - id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, - year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre, - })); - enqueue(tracks); - })}> + {type === 'album-song' && ( +
handleAction(async () => { + const albumData = await getAlbum(song.albumId); + const tracks = albumData.songs.map(songToTrack); + enqueue(tracks); + })}> {t('contextMenu.enqueueAlbum')}
)} diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 4ae0e23f..63d52b37 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Play, ListPlus } from 'lucide-react'; import { getRandomAlbums, SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; import CachedImage, { useCachedUrl } from './CachedImage'; -import { usePlayerStore } from '../store/playerStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; import { playAlbum } from '../utils/playAlbum'; @@ -151,18 +151,14 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
+
+ ); + })} +
+ + )} {/* Similar Artists (Last.fm) */} {lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && ( diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 757d597d..81882b96 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic'; -import { usePlayerStore } from '../store/playerStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; import { ListPlus, X } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -69,14 +69,10 @@ export default function Favorites() {

{t('favorites.songs')}

{song.title}
{song.artist}
{song.album}
{song.genre ?? '—'}
- {formatDuration(song.duration)} - - ))} - - )} - - )} + {formatDuration(song.duration)} + + ); + })} + + )} + + )} {!selectedSuperGenre && (loading && songs.length === 0 ? (
@@ -383,34 +386,30 @@ export default function RandomMix() { {t('randomMix.trackDuration')}
- {filteredSongs.map((song) => ( -
playTrack(song, filteredSongs)} - role="row" - draggable - onContextMenu={e => { - e.preventDefault(); - const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred, genre: song.genre }; - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, track, 'song'); - }} - onDragStart={e => { - e.dataTransfer.effectAllowed = 'copy'; - const track = { - id: song.id, title: song.title, artist: song.artist, album: song.album, - albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, - year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, genre: song.genre, - }; - e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); - }} - > + {filteredSongs.map((song) => { + const track = songToTrack(song); + return ( +
playTrack(songToTrack(song), filteredSongs.map(songToTrack))} + role="row" + draggable + onContextMenu={e => { + e.preventDefault(); + setContextMenuSongId(song.id); + openContextMenu(e.clientX, e.clientY, track, 'song'); + }} + onDragStart={e => { + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); + }} + >
- {formatDuration(song.duration)} - -
- ))} - - ))} + {formatDuration(song.duration)} + + + ); + })} + + ))} ); diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index ae5296a1..baaf1f3d 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Play, Search } from 'lucide-react'; import { search, SearchResults as ISearchResults, SubsonicSong } from '../api/subsonic'; -import { usePlayerStore } from '../store/playerStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import { useTranslation } from 'react-i18next'; @@ -31,17 +31,7 @@ export default function SearchResults() { const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); const playSong = (song: SubsonicSong, list: SubsonicSong[]) => { - playTrack({ - id: song.id, title: song.title, artist: song.artist, album: song.album, - albumId: song.albumId, artistId: song.artistId, duration: song.duration, - coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, - suffix: song.suffix, userRating: song.userRating, genre: song.genre, - }, list.map(s => ({ - id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, artistId: s.artistId, duration: s.duration, - coverArt: s.coverArt, year: s.year, bitRate: s.bitRate, - suffix: s.suffix, userRating: s.userRating, genre: s.genre, - }))); + playTrack(songToTrack(song), list.map(songToTrack)); }; return ( @@ -93,16 +83,11 @@ export default function SearchResults() { onDoubleClick={() => playSong(song, results.songs)} role="row" draggable - onDragStart={e => { - e.dataTransfer.effectAllowed = 'copy'; - const track = { - id: song.id, title: song.title, artist: song.artist, album: song.album, - albumId: song.albumId, artistId: song.artistId, duration: song.duration, - coverArt: song.coverArt, year: song.year, bitRate: song.bitRate, - suffix: song.suffix, userRating: song.userRating, genre: song.genre, - }; - e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); - }} + onDragStart={e => { + e.dataTransfer.effectAllowed = 'copy'; + const track = songToTrack(song); + e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track })); + }} >