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/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index b9498e9d..093e8329 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.21.0 +pkgver=1.22.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') 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 0e23692c..8964afdd 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'; import { useDragDrop } from '../contexts/DragDropContext'; @@ -83,15 +83,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 (
@@ -123,7 +115,7 @@ 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" onMouseDown={e => { @@ -134,7 +126,7 @@ export default function AlbumTrackList({ if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: makeTrack(song) }), label: song.title }, me.clientX, me.clientY); + psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title }, me.clientX, me.clientY); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 24cc05fe..f71a98d9 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3, Heart } from 'lucide-react'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; -import { usePlayerStore, Track } from '../store/playerStore'; +import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; @@ -64,15 +64,11 @@ export default function ContextMenu() { const startRadio = async (artistId: string, artistName: string) => { 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 85f8b259..286e80a8 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'; @@ -71,14 +71,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 ? (
@@ -396,40 +399,40 @@ export default function RandomMix() { {t('randomMix.trackDuration')}
- {filteredSongs.map((song) => ( -
playTrack(song, filteredSongs)} - role="row" - 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'); - }} - onMouseDown={e => { - if (e.button !== 0) return; - e.preventDefault(); - const sx = e.clientX, sy = e.clientY; - const onMove = (me: MouseEvent) => { - if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - 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 }; - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); - } - }; - const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }} - > + {filteredSongs.map((song) => { + const track = songToTrack(song); + return ( +
playTrack(track, filteredSongs.map(songToTrack))} + role="row" + onContextMenu={e => { + e.preventDefault(); + setContextMenuSongId(song.id); + openContextMenu(e.clientX, e.clientY, track, 'song'); + }} + onMouseDown={e => { + if (e.button !== 0) return; + e.preventDefault(); + const sx = e.clientX, sy = e.clientY; + const onMove = (me: MouseEvent) => { + if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); + } + }; + const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }} + >
- {formatDuration(song.duration)} - -
- ))} - - ))} + {formatDuration(song.duration)} + + + ); + })} + + ))} ); diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 10199883..51a7705a 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'; @@ -33,17 +33,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 ( @@ -94,16 +84,15 @@ export default function SearchResults() { style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }} onDoubleClick={() => playSong(song, results.songs)} role="row" - draggable={false} onMouseDown={e => { if (e.button !== 0) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; + const track = songToTrack(song); const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); - 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 }; psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); } }; diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 8226e417..25237008 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,5 +1,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; +import { invoke } from '@tauri-apps/api/core'; +import { usePlayerStore } from './playerStore'; export interface ServerProfile { id: string; @@ -147,8 +149,14 @@ export const useAuthStore = create()( setDownloadFolder: (v) => set({ downloadFolder: v }), setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }), setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }), - setReplayGainEnabled: (v) => set({ replayGainEnabled: v }), - setReplayGainMode: (v) => set({ replayGainMode: v }), + setReplayGainEnabled: (v) => { + set({ replayGainEnabled: v }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, + setReplayGainMode: (v) => { + set({ replayGainMode: v }); + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + }, setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), setGaplessEnabled: (v) => set({ gaplessEnabled: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 430acf18..adea1df1 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic'; +import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong } from '../api/subsonic'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; import { useOfflineStore } from './offlineStore'; @@ -75,7 +75,8 @@ interface PlayerState { previous: () => void; seek: (progress: number) => void; setVolume: (v: number) => void; - setProgress: (t: number, duration: number) => void; + updateReplayGainForCurrentTrack: () => void; + setProgress: (t: number, duration: number) => void; enqueue: (tracks: Track[]) => void; enqueueAt: (tracks: Track[], insertIndex: number) => void; clearQueue: () => void; @@ -589,33 +590,62 @@ export const usePlayerStore = create()( isAudioPaused = false; set({ isPlaying: true }); } else { - // Cold start (app relaunch) — audio is not loaded in Rust; re-download. + // Cold start (app relaunch) — fetch fresh track data for replay gain, then play. const gen = ++playGeneration; const vol = get().volume; set({ isPlaying: true }); - const authStateCold = useAuthStore.getState(); - const replayGainDbCold = authStateCold.replayGainEnabled - ? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null - : null; - const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null; - const coldServerId = useAuthStore.getState().activeServerId ?? ''; - const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id); - invoke('audio_play', { - url: coldUrl, - volume: vol, - durationHint: currentTrack.duration, - replayGainDb: replayGainDbCold, - replayGainPeak: replayGainPeakCold, - }).then(() => { - if (playGeneration === gen && currentTime > 1) { - invoke('audio_seek', { seconds: currentTime }).catch(console.error); - } - }).catch((err: unknown) => { - if (playGeneration !== gen) return; - console.error('[psysonic] audio_play (cold resume) failed:', err); - set({ isPlaying: false }); - }); - syncQueueToServer(queue, currentTrack, currentTime); + + // Fetch fresh track data from server to get replay gain metadata + getSong(currentTrack.id).then(freshSong => { + const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack; + // 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) ?? null + : null; + const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null; + const coldServerId = useAuthStore.getState().activeServerId ?? ''; + const coldUrl = useOfflineStore.getState().getLocalUrl(trackToPlay.id, coldServerId) ?? buildStreamUrl(trackToPlay.id); + invoke('audio_play', { + url: coldUrl, + volume: vol, + durationHint: trackToPlay.duration, + replayGainDb: replayGainDbCold, + replayGainPeak: replayGainPeakCold, + }).then(() => { + if (playGeneration === gen && currentTime > 1) { + invoke('audio_seek', { seconds: currentTime }).catch(console.error); + } + }).catch((err: unknown) => { + if (playGeneration !== gen) return; + console.error('[psysonic] audio_play (cold resume) failed:', err); + set({ isPlaying: false }); + }); + syncQueueToServer(queue, trackToPlay, currentTime); + }).catch(() => { + 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 replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null; + const coldServerId = useAuthStore.getState().activeServerId ?? ''; + const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id); + invoke('audio_play', { + url: coldUrl, + volume: vol, + durationHint: currentTrack.duration, + replayGainDb: replayGainDbCold, + replayGainPeak: replayGainPeakCold, + }).catch((err: unknown) => { + if (playGeneration !== gen) return; + console.error('[psysonic] audio_play (cold resume) failed:', err); + set({ isPlaying: false }); + }); + syncQueueToServer(queue, currentTrack, currentTime); + }); } }, @@ -753,40 +783,55 @@ export const usePlayerStore = create()( // ── server queue restore ───────────────────────────────────────────────── initializeFromServerQueue: async () => { - try { - const q = await getPlayQueue(); - if (q.songs.length > 0) { - const mappedTracks: Track[] = q.songs.map((s: SubsonicSong) => ({ - 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, - })); + try { + const q = await getPlayQueue(); + if (q.songs.length > 0) { + const mappedTracks: Track[] = q.songs.map(songToTrack); - let currentTrack = mappedTracks[0]; - let queueIndex = 0; + let currentTrack = mappedTracks[0]; + let queueIndex = 0; - if (q.current) { - const idx = mappedTracks.findIndex(t => t.id === q.current); - if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; } - } + if (q.current) { + const idx = mappedTracks.findIndex(t => t.id === q.current); + if (idx >= 0) { currentTrack = mappedTracks[idx]; queueIndex = idx; } + } - // Prefer the server position if available; otherwise keep the - // localStorage-persisted currentTime (more reliable than server - // queue position, which may not flush before app close). - const serverTime = q.position ? q.position / 1000 : 0; - const localTime = get().currentTime; - set({ - queue: mappedTracks, - queueIndex, - currentTrack, - currentTime: serverTime > 0 ? serverTime : localTime, - }); - } - } catch (e) { - console.error('Failed to initialize queue from server', e); - } - }, + // Prefer the server position if available; otherwise keep the + // localStorage-persisted currentTime (more reliable than server + // queue position, which may not flush before app close). + const serverTime = q.position ? q.position / 1000 : 0; + const localTime = get().currentTime; + set({ + queue: mappedTracks, + queueIndex, + currentTrack, + currentTime: serverTime > 0 ? serverTime : localTime, + }); + } + } catch (e) { + console.error('Failed to initialize queue from server', e); + } + }, + + updateReplayGainForCurrentTrack: () => { + const { currentTrack, volume } = get(); + if (!currentTrack || !currentTrack.id) return; + const authState = useAuthStore.getState(); + const replayGainDb = authState.replayGainEnabled + ? (authState.replayGainMode === 'album' + ? currentTrack.replayGainAlbumDb + : currentTrack.replayGainTrackDb) ?? null + : null; + const replayGainPeak = authState.replayGainEnabled + ? (currentTrack.replayGainPeak ?? null) + : null; + + invoke('audio_update_replay_gain', { + volume, + replayGainDb, + replayGainPeak + }).catch(console.error); + }, }), { name: 'psysonic-player', @@ -799,7 +844,7 @@ export const usePlayerStore = create()( queueIndex: state.queueIndex, currentTime: state.currentTime, lastfmLovedCache: state.lastfmLovedCache, - } as Partial), + }), } ) );