fix(playback): ReplayGain prefetch, gapless UI sync, library peak column (#1231)

This commit is contained in:
cucadmuh
2026-07-04 16:00:03 +03:00
committed by GitHub
parent 9183c3d657
commit fdbb9deac6
52 changed files with 2564 additions and 488 deletions
@@ -16,9 +16,10 @@
* `artistInfo` (bio / similar) has no index source and stays network-only — it
* is intentionally absent here.
*/
import { libraryGetTrack, libraryGetTracksByAlbum } from '@/lib/api/library';
import { libraryGetTracksByAlbum } from '@/lib/api/library';
import { getArtistForServer, getTopSongsForServer } from '@/lib/api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '@/lib/api/subsonicLibrary';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { resolveSongMetaIndexFirst } from '@/lib/library/resolveSongMetaIndexFirst';
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import { shouldAttemptSubsonicForServer } from '@/lib/network/subsonicNetworkGuard';
import { loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex } from '@/features/offline';
@@ -96,13 +97,5 @@ export async function resolveNpSongMeta(
serverId: string,
songId: string,
): Promise<SubsonicSong | null> {
if (await libraryIsReady(serverId)) {
try {
const dto = await libraryGetTrack(serverId, songId);
if (dto) return trackToSong(dto);
} catch { /* index error → network fallback */ }
}
// Network arm keeps its own byte-style guard (`shouldAttemptSubsonicForServer`
// with the trackId + psysonic-local:// skip) — unchanged from #1042.
return getSongForServer(serverId, songId);
return resolveSongMetaIndexFirst(serverId, songId);
}
@@ -105,6 +105,7 @@ export function libraryDtoToTrack(dto: LibraryTrackDto): Track {
genre: song.genre,
replayGainTrackDb: dto.replayGainTrackDb ?? undefined,
replayGainAlbumDb: dto.replayGainAlbumDb ?? undefined,
replayGainPeak: dto.replayGainPeak ?? undefined,
size: song.size,
serverId: dto.serverId,
};
+77 -187
View File
@@ -1,8 +1,6 @@
import { scrobbleSong } from '@/lib/api/subsonicScrobble';
import type { Track } from '@/lib/media/trackTypes';
import {
playbackReportPlaying,
playbackReportStart,
playbackReportStopped,
} from '@/features/playback/store/playbackReportSession';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
@@ -13,7 +11,6 @@ import { notifyLibraryPlaybackHint } from '@/features/playback/store/libraryPlay
import {
playListenSessionFinalize,
playListenSessionOnProgress,
playListenSessionOnTrackSwitched,
playListenSessionOpen,
} from '@/features/playback/store/playListenSession';
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
@@ -27,8 +24,12 @@ import {
playbackProfileIdForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
import { requestGaplessChainPreload } from '@/features/playback/store/gaplessChainPreload';
import {
applyGaplessQueueAdvance,
maybeReconcileGaplessFromProgress,
} from '@/features/playback/store/gaplessQueueAdvance';
import { noteEngineProgressForGapless } from '@/features/playback/store/gaplessProgressTracking';
import { showToast } from '@/lib/dom/toast';
import { useAuthStore } from '@/store/authStore';
import { getPlayGeneration, setIsAudioPaused } from '@/features/playback/store/engineState';
@@ -39,16 +40,8 @@ import {
getLastGaplessSwitchTime,
markGaplessSwitch,
setBytePreloadingId,
setGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from '@/features/playback/store/loudnessGainCache';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import {
emitPlaybackProgress,
getPlaybackProgressSnapshot,
@@ -64,15 +57,11 @@ import {
markStoreProgressCommit,
resetProgressEmitThrottles,
} from '@/features/playback/store/playbackThrottles';
import {
playbackSourceHintForResolvedUrl,
} from '@/features/playback/store/playbackUrlRouting';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
import {
flushQueueSyncToServer,
getLastQueueHeartbeatAt,
syncQueueToServer,
flushQueueSyncToServer,
} from '@/features/playback/store/queueSync';
import { clearQueueNaturallyEnded } from '@/features/playback/store/queuePlaybackIdle';
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
@@ -87,7 +76,6 @@ import {
getSeekTarget,
getSeekTargetSetAt,
} from '@/features/playback/store/seekTargetState';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { analyzeBoundary, computeWaveformSilence } from '@/lib/waveform/waveformSilence';
import { autodjMaxOverlapCapSec } from '@/lib/audio/autodjOverlapCap';
import {
@@ -167,12 +155,16 @@ export function handleAudioProgress(
clearSeekTarget();
} else {
clearSeekTarget();
noteEngineProgressForGapless(current_time);
}
}
const store = usePlayerStore.getState();
const track = store.currentTrack;
if (!track) return;
if (!buffering) {
maybeReconcileGaplessFromProgress(current_time, duration);
}
if (!store.currentRadio && store.isPlaybackBuffering !== buffering) {
usePlayerStore.setState({ isPlaybackBuffering: buffering });
}
@@ -201,6 +193,9 @@ export function handleAudioProgress(
}
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
if (!buffering) {
noteEngineProgressForGapless(current_time);
}
const progress = displayTime / dur;
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
if (!progressUiDisabled) {
@@ -374,90 +369,69 @@ export function handleAudioProgress(
const nextTrack = repeatMode === 'one'
? track
: (nextRef ? resolveQueueTrack(nextRef) : null);
if (!nextTrack || nextTrack.id === track.id) return;
// Gapless backup: keep next-track bytes ready even if chain/decode misses
// the boundary. Start earlier for larger files / slower conservative link.
const estBytes = (() => {
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
return nextTrack.size;
if (nextTrack && nextTrack.id !== track.id) {
// Gapless backup: keep next-track bytes ready even if chain/decode misses
// the boundary. Start earlier for larger files / slower conservative link.
const estBytes = (() => {
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
return nextTrack.size;
}
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
? nextTrack.bitRate
: 320;
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
})();
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
const estDownloadSecs = estBytes / conservativeBytesPerSec;
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
const shouldBytePreloadForGaplessBackup =
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
const analysisServerId = nextRef
? playbackCacheKeyForRef(nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
if (
shouldBytePreloadForGaplessBackup
&& nextTrack.id !== getBytePreloadingId()
) {
setBytePreloadingId(nextTrack.id);
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
// waveformBins and would replace the current track's seekbar while still playing it.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
if (import.meta.env.DEV) {
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
nextUrl,
shouldBytePreloadForGaplessBackup,
remaining,
gaplessEnabled,
});
}
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
? nextTrack.bitRate
: 320;
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
})();
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
const estDownloadSecs = estBytes / conservativeBytesPerSec;
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
const shouldBytePreloadForGaplessBackup =
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
const analysisServerId = nextRef
? playbackCacheKeyForRef(nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
if (
shouldBytePreloadForGaplessBackup
&& nextTrack.id !== getBytePreloadingId()
) {
setBytePreloadingId(nextTrack.id);
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
// waveformBins and would replace the current track's seekbar while still playing it.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
if (import.meta.env.DEV) {
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
nextUrl,
shouldBytePreloadForGaplessBackup,
remaining,
gaplessEnabled,
// Gapless chain — decode + chain into Sink 30s before track boundary.
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
requestGaplessChainPreload({
currentTrack: track,
nextTrack,
nextRef,
nextIdx,
queueItems,
repeatMode,
volume: store.volume,
});
}
invoke('audio_preload', {
url: nextUrl,
durationHint: nextTrack.duration,
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
// Gapless chain — decode + chain into Sink 30s before track boundary.
if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) {
setGaplessPreloadingId(nextTrack.id);
// Ensure loudness gain is already cached for the chained request payload.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
const authState = useAuthStore.getState();
// Auto-mode neighbours for the *next* track: current track on its left,
// queueItems[nextIdx+1] on its right (resolved; placeholder on a cold miss
// — only its replaygain tags matter, which a placeholder lacks → fallback).
const nextNeighbourRef = nextIdx + 1 < queueItems.length
? queueItems[nextIdx + 1]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
const nextNeighbour = nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
const replayGainDb = resolveReplayGainDb(
nextTrack, track, nextNeighbour,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive()
? (nextTrack.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
url: nextUrl,
volume: store.volume,
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
...audioPlayHiResBlendArgs(authState),
analysisTrackId: nextTrack.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
}
}
@@ -532,104 +506,20 @@ export function handleAudioEnded(): void {
* next source sample-accurately. We just need to update the UI state without
* touching the audio stream (no playTrack() call!).
*/
export function handleAudioTrackSwitched(_duration: number): void {
export function handleAudioTrackSwitched(duration: number): void {
markGaplessSwitch();
clearPreloadingIds(); // allow preloading for the track after this one
clearPreloadingIds();
setIsAudioPaused(false);
const store = usePlayerStore.getState();
if (store.currentTrack?.id) {
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
}
const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
if (currentTrack && !currentRadio) {
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
}
const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null;
let newIndex = queueIndex;
if (repeatMode === 'one' && store.currentTrack) {
nextTrack = store.currentTrack;
// queueIndex stays the same
} else if (nextIdx < queueItems.length) {
// The Rust engine already chained this source sample-accurately, so it must
// have been preloaded — meaning the resolver had it cached. resolveQueueTrack
// returns the full Track from cache (placeholder only on an unexpected miss).
nextTrack = resolveQueueTrack(queueItems[nextIdx]);
newIndex = nextIdx;
} else if (repeatMode === 'all' && queueItems.length > 0) {
nextTrack = resolveQueueTrack(queueItems[0]);
newIndex = 0;
}
if (!nextTrack) return;
void playListenSessionOnTrackSwitched(nextTrack);
const switchRef = queueItems[newIndex];
const switchServerId = playbackCacheKeyForRef(switchRef);
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
// Neighbour window for normalization (replaygain album-mode reads prev/next).
// current track on the left, the track after `nextTrack` on the right.
const switchPrev = store.currentTrack;
const switchNextNextRef = newIndex + 1 < queueItems.length ? queueItems[newIndex + 1] : null;
const switchNeighbourWindow: Track[] = [
switchPrev ?? nextTrack,
nextTrack,
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
];
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
isPlaying: true,
isPlaybackBuffering: switchPlaybackSource === 'stream',
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
networkLoved: false,
currentPlaybackSource: switchPlaybackSource,
applyGaplessQueueAdvance({
engineDurationHint: duration,
source: 'track-switched',
});
emitNormalizationDebug('track-switched', {
trackId: nextTrack.id,
queueIndex: newIndex,
engineRequested: useAuthStore.getState().normalizationEngine,
});
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
usePlayerStore.getState().updateReplayGainForCurrentTrack();
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
// now-playing follows scrobbling, as Last.fm now-playing did (the runtime gates
// on the master toggle, per-account enable and the nowPlaying capability
// internally). playbackReportStart opens the FSM on extension-capable servers
// and falls back to the legacy presence call otherwise (gating is internal).
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: nextTrack.title,
artist: nextTrack.artist,
album: nextTrack.album,
duration: nextTrack.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
.then(loved => {
usePlayerStore.getState().setNetworkLoved(loved);
});
}
syncQueueToServer(queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
}
export function handleAudioError(message: string): void {
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import {
_resetGaplessPreloadStateForTest,
getGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
import {
_resetGaplessChainPrepareInflightForTest,
requestGaplessChainPreload,
} from '@/features/playback/store/gaplessChainPreload';
const prepareMock = vi.hoisted(() =>
vi.fn(async (track: Track) => ({
...track,
title: 'Next indexed',
duration: 180,
replayGainTrackDb: -5.5,
})),
);
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
prepareTrackForEngineBind: prepareMock,
}));
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
const track = (id: string, extra: Partial<Track> = {}): Track => ({
id,
title: extra.title ?? `Track ${id}`,
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
duration: extra.duration ?? 200,
...extra,
});
describe('requestGaplessChainPreload', () => {
beforeEach(() => {
_resetGaplessPreloadStateForTest();
_resetGaplessChainPrepareInflightForTest();
prepareMock.mockClear();
vi.restoreAllMocks();
onInvoke('audio_chain_preload', () => undefined);
useAuthStore.setState({
activeServerId: 's1',
servers: [{
id: 's1',
name: 'Test',
url: 'https://music.example',
username: 'u',
password: 'p',
}],
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
gaplessEnabled: true,
});
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 0,
repeatMode: 'off',
isPlaying: true,
currentRadio: null,
volume: 0.75,
});
});
it('prefetches metadata then chains with ReplayGain from prepare', async () => {
requestGaplessChainPreload({
currentTrack: track('t1'),
nextTrack: track('t2', { title: '…', duration: 0 }),
nextRef: ref('t2'),
nextIdx: 1,
queueItems: [ref('t1'), ref('t2')],
repeatMode: 'off',
volume: 0.75,
});
await vi.waitFor(() => {
expect(getGaplessPreloadingId()).toBe('t2');
});
expect(prepareMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 't2' }),
'music.example',
);
const chainCalls = invokeMock.mock.calls.filter(c => c[0] === 'audio_chain_preload');
expect(chainCalls.length).toBe(1);
expect(chainCalls[0]?.[1]).toMatchObject({
replayGainDb: -5.5,
durationHint: 180,
});
});
it('no-ops when playback context changed before prepare finished', async () => {
requestGaplessChainPreload({
currentTrack: track('t1'),
nextTrack: track('t2', { title: '…', duration: 0 }),
nextRef: ref('t2'),
nextIdx: 1,
queueItems: [ref('t1'), ref('t2')],
repeatMode: 'off',
volume: 0.75,
});
usePlayerStore.setState({ currentTrack: track('t99', { id: 't99' }) });
await vi.waitFor(() => {
expect(prepareMock).toHaveBeenCalled();
});
expect(getGaplessPreloadingId()).toBeNull();
expect(invokeMock.mock.calls.filter(c => c[0] === 'audio_chain_preload').length).toBe(0);
});
});
@@ -0,0 +1,143 @@
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import { invoke } from '@tauri-apps/api/core';
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
playbackCacheKeyForRef,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
getGaplessPreloadingId,
setGaplessPreloadingId,
} from '@/features/playback/store/gaplessPreloadState';
import {
isReplayGainActive,
loudnessGainDbForEngineBind,
} from '@/features/playback/store/loudnessGainCache';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
export type GaplessChainPreloadContext = {
currentTrack: Track;
nextTrack: Track;
nextRef: QueueItemRef | null;
nextIdx: number;
queueItems: QueueItemRef[];
repeatMode: 'off' | 'one' | 'all';
volume: number;
};
const gaplessChainPrepareInflight = new Map<string, Promise<void>>();
function gaplessNextNeighbour(
ctx: GaplessChainPreloadContext,
): Track | null {
const { nextIdx, queueItems, repeatMode } = ctx;
const nextNeighbourRef = nextIdx + 1 < queueItems.length
? queueItems[nextIdx + 1]
: (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null);
return nextNeighbourRef ? resolveQueueTrack(nextNeighbourRef) : null;
}
function invokeGaplessChainPreload(
prepared: Track,
ctx: GaplessChainPreloadContext,
): void {
const authState = useAuthStore.getState();
const serverId = ctx.nextRef ? playbackCacheKeyForRef(ctx.nextRef) : getPlaybackCacheServerKey();
const analysisServerId = ctx.nextRef
? playbackCacheKeyForRef(ctx.nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(prepared.id, serverId);
const nextNeighbour = gaplessNextNeighbour(ctx);
const replayGainDb = resolveReplayGainDb(
prepared, ctx.currentTrack, nextNeighbour,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive()
? (prepared.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
url: nextUrl,
volume: ctx.volume,
durationHint: prepared.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(prepared.id),
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
...audioPlayHiResBlendArgs(authState),
analysisTrackId: prepared.id,
serverId: analysisServerId || null,
}).catch(() => {});
}
function liveNextRefForContext(
queueItems: QueueItemRef[],
queueIndex: number,
repeatMode: 'off' | 'one' | 'all',
): QueueItemRef | null {
if (repeatMode === 'one') return null;
const nextIdx = queueIndex + 1;
if (nextIdx < queueItems.length) return queueItems[nextIdx] ?? null;
if (repeatMode === 'all' && queueItems.length > 0) return queueItems[0] ?? null;
return null;
}
/**
* Prefetch metadata + loudness, then hand the next track to `audio_chain_preload`.
* Coalesces concurrent prepares for the same id; retries on the next progress
* tick when validation fails (track skip, queue rewrite).
*/
export function requestGaplessChainPreload(ctx: GaplessChainPreloadContext): void {
const { nextTrack } = ctx;
if (getGaplessPreloadingId() === nextTrack.id) return;
if (gaplessChainPrepareInflight.has(nextTrack.id)) return;
const job = (async () => {
try {
const serverId = ctx.nextRef
? playbackCacheKeyForRef(ctx.nextRef)
: getPlaybackCacheServerKey();
const prepared = serverId
? await prepareTrackForEngineBind(nextTrack, serverId)
: nextTrack;
if (serverId) {
await refreshLoudnessForTrack(prepared.id, { syncPlayingEngine: false });
}
const store = usePlayerStore.getState();
if (!store.isPlaying || store.currentRadio) return;
if (store.currentTrack?.id !== ctx.currentTrack.id) return;
const liveNextRef = liveNextRefForContext(
store.queueItems,
store.queueIndex,
store.repeatMode,
);
if (liveNextRef?.trackId !== prepared.id) return;
if (getGaplessPreloadingId() === prepared.id) return;
setGaplessPreloadingId(prepared.id);
invokeGaplessChainPreload(prepared, {
...ctx,
currentTrack: store.currentTrack ?? ctx.currentTrack,
nextTrack: prepared,
nextRef: liveNextRef,
});
} finally {
gaplessChainPrepareInflight.delete(nextTrack.id);
}
})().catch(() => {});
gaplessChainPrepareInflight.set(nextTrack.id, job);
}
/** Test-only: drop pending gapless chain prepare promises. */
export function _resetGaplessChainPrepareInflightForTest(): void {
gaplessChainPrepareInflight.clear();
}
@@ -0,0 +1,24 @@
/**
* Last engine progress position for gapless rewind detection.
* Kept separate from gaplessQueueAdvance to avoid seekAction → gaplessQueueAdvance
* → playerStore → seekAction cycles.
*/
let lastEngineProgressSec = 0;
export function _resetGaplessProgressTrackingForTest(): void {
lastEngineProgressSec = 0;
}
/** Clear stale position after a gapless or manual track switch. */
export function resetGaplessProgressTracking(): void {
lastEngineProgressSec = 0;
}
export function getLastEngineProgressSec(): number {
return lastEngineProgressSec;
}
export function noteEngineProgressForGapless(currentTime: number): void {
lastEngineProgressSec = currentTime;
}
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import {
_resetQueueResolverForTest,
seedQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import { _resetGaplessPreloadStateForTest } from '@/features/playback/store/gaplessPreloadState';
import { setSeekTarget, _resetSeekTargetStateForTest } from '@/features/playback/store/seekTargetState';
import {
_resetPlaybackProgressForTest,
getPlaybackProgressSnapshot,
} from '@/features/playback/store/playbackProgress';
import {
_resetGaplessProgressTrackingForTest,
noteEngineProgressForGapless,
} from '@/features/playback/store/gaplessProgressTracking';
import {
applyGaplessQueueAdvance,
maybeReconcileGaplessFromProgress,
} from '@/features/playback/store/gaplessQueueAdvance';
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
const track = (id: string, extra: Partial<Track> = {}): Track => ({
id,
title: extra.title ?? `Track ${id}`,
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
duration: extra.duration ?? 200,
...extra,
});
describe('applyGaplessQueueAdvance', () => {
beforeEach(() => {
_resetQueueResolverForTest();
_resetPlaybackProgressForTest();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({ gaplessEnabled: true });
seedQueueResolver('s1', [
track('t1'),
track('t2', { title: 'Second' }),
]);
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 0,
repeatMode: 'off',
isPlaying: true,
currentRadio: null,
progress: 0.8,
currentTime: 160,
});
});
it('advances currentTrack and resets the progress channel', () => {
const result = applyGaplessQueueAdvance({ engineDurationHint: 210, source: 'track-switched' });
expect(result.advanced).toBe(true);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
expect(usePlayerStore.getState().queueIndex).toBe(1);
expect(getPlaybackProgressSnapshot().currentTime).toBe(0);
expect(getPlaybackProgressSnapshot().progress).toBe(0);
});
});
describe('maybeReconcileGaplessFromProgress', () => {
beforeEach(() => {
_resetQueueResolverForTest();
_resetGaplessPreloadStateForTest();
_resetPlaybackProgressForTest();
_resetGaplessProgressTrackingForTest();
_resetSeekTargetStateForTest();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({ gaplessEnabled: true });
seedQueueResolver('s1', [track('t1'), track('t2', { title: 'Second' })]);
usePlayerStore.setState({
currentTrack: track('t1'),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 0,
repeatMode: 'off',
isPlaying: true,
currentRadio: null,
});
});
it('catches up UI when engine position regresses without track_switched', () => {
noteEngineProgressForGapless(170);
maybeReconcileGaplessFromProgress(0.4, 205);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
expect(getPlaybackProgressSnapshot().progress).toBe(0);
});
it('no-ops when position moves forward normally', () => {
noteEngineProgressForGapless(10);
maybeReconcileGaplessFromProgress(11, 200);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
});
it('no-ops during an active seek guard', () => {
noteEngineProgressForGapless(100);
setSeekTarget(20);
maybeReconcileGaplessFromProgress(0.5, 200);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
expect(usePlayerStore.getState().queueIndex).toBe(0);
});
it('no-ops on mid-track position regressions (not a gapless boundary)', () => {
noteEngineProgressForGapless(170);
maybeReconcileGaplessFromProgress(100, 200);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t1');
expect(usePlayerStore.getState().queueIndex).toBe(0);
});
it('does not double-advance after track_switched already moved the UI', () => {
noteEngineProgressForGapless(170);
applyGaplessQueueAdvance({ engineDurationHint: 210, source: 'track-switched' });
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
maybeReconcileGaplessFromProgress(2, 210);
expect(usePlayerStore.getState().currentTrack?.id).toBe('t2');
expect(usePlayerStore.getState().queueIndex).toBe(1);
});
});
@@ -0,0 +1,246 @@
/**
* Gapless queue advance: Rust already switched the audio source — update JS
* store + side-effects without calling `playTrack` / `audio_play`.
*/
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
import {
playbackReportStart,
} from '@/features/playback/store/playbackReportSession';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
clearPreloadingIds,
getLastGaplessSwitchTime,
markGaplessSwitch,
} from '@/features/playback/store/gaplessPreloadState';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh';
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
import { emitNormalizationDebug } from '@/features/playback/store/normalizationDebug';
import {
emitPlaybackProgress,
} from '@/features/playback/store/playbackProgress';
import {
resetProgressEmitThrottles,
} from '@/features/playback/store/playbackThrottles';
import {
playbackSourceHintForResolvedUrl,
} from '@/features/playback/store/playbackUrlRouting';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { playListenSessionOnTrackSwitched } from '@/features/playback/store/playListenSession';
import { appendTimelineLeaveTrack } from '@/features/playback/store/timelineSessionHistory';
import {
playbackCacheKeyForRef,
playbackProfileIdForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
import { syncQueueToServer } from '@/features/playback/store/queueSync';
import { useAuthStore } from '@/store/authStore';
import { setIsAudioPaused } from '@/features/playback/store/engineState';
import {
getLastEngineProgressSec,
noteEngineProgressForGapless,
resetGaplessProgressTracking,
} from '@/features/playback/store/gaplessProgressTracking';
import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce';
import { getSeekTarget } from '@/features/playback/store/seekTargetState';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
export type GaplessQueueAdvanceResult = {
advanced: boolean;
nextTrack: Track | null;
newIndex: number;
};
/** Resolve the queue successor for a gapless engine transition. */
export function resolveGaplessSuccessor(
queueItems: QueueItemRef[],
queueIndex: number,
repeatMode: 'off' | 'one' | 'all',
currentTrack: Track | null,
): { nextTrack: Track | null; newIndex: number } {
const nextIdx = queueIndex + 1;
if (repeatMode === 'one' && currentTrack) {
return { nextTrack: currentTrack, newIndex: queueIndex };
}
if (nextIdx < queueItems.length) {
return {
nextTrack: resolveQueueTrack(queueItems[nextIdx]),
newIndex: nextIdx,
};
}
if (repeatMode === 'all' && queueItems.length > 0) {
return {
nextTrack: resolveQueueTrack(queueItems[0]),
newIndex: 0,
};
}
return { nextTrack: null, newIndex: queueIndex };
}
function applyGaplessSuccessorUi(
store: ReturnType<typeof usePlayerStore.getState>,
nextTrack: Track,
newIndex: number,
source: 'track-switched' | 'progress-reconcile',
): void {
const switchRef = store.queueItems[newIndex];
const switchServerId = playbackCacheKeyForRef(switchRef);
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(
nextTrack.id,
switchServerId,
switchResolvedUrl,
);
const switchPrev = store.currentTrack;
const switchNextNextRef = newIndex + 1 < store.queueItems.length
? store.queueItems[newIndex + 1]
: null;
const switchNeighbourWindow: Track[] = [
switchPrev ?? nextTrack,
nextTrack,
...(switchNextNextRef ? [resolveQueueTrack(switchNextNextRef)] : []),
];
resetProgressEmitThrottles();
resetGaplessProgressTracking();
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
...deriveNormalizationSnapshot(nextTrack, switchNeighbourWindow, 1),
normalizationDbgSource: source,
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
isPlaying: true,
isPlaybackBuffering: switchPlaybackSource === 'stream',
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
networkLoved: false,
currentPlaybackSource: switchPlaybackSource,
});
emitPlaybackProgress({
currentTime: 0,
progress: 0,
buffered: 0,
buffering: switchPlaybackSource === 'stream',
});
emitNormalizationDebug(source, {
trackId: nextTrack.id,
queueIndex: newIndex,
engineRequested: useAuthStore.getState().normalizationEngine,
});
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
usePlayerStore.getState().updateReplayGainForCurrentTrack();
playbackReportStart(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: nextTrack.title,
artist: nextTrack.artist,
album: nextTrack.album,
duration: nextTrack.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: nextTrack.title, artist: nextTrack.artist })
.then(loved => {
usePlayerStore.getState().setNetworkLoved(loved);
});
}
syncQueueToServer(store.queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
}
/**
* Advance the queue UI to match a gapless engine transition. Returns whether
* the store was updated (false when there is no successor or already advanced).
*/
export function applyGaplessQueueAdvance(opts?: {
/** When set, patch duration on the successor if the resolver snapshot is thin. */
engineDurationHint?: number;
source?: 'track-switched' | 'progress-reconcile';
}): GaplessQueueAdvanceResult {
const source = opts?.source ?? 'track-switched';
const store = usePlayerStore.getState();
const { queueItems, queueIndex, repeatMode, currentTrack, currentRadio } = store;
if (currentRadio) {
return { advanced: false, nextTrack: null, newIndex: queueIndex };
}
const { nextTrack: resolved, newIndex } = resolveGaplessSuccessor(
queueItems,
queueIndex,
repeatMode,
currentTrack,
);
if (!resolved) {
return { advanced: false, nextTrack: null, newIndex: queueIndex };
}
const hint = opts?.engineDurationHint;
const nextTrack = hint != null && hint > 0 && resolved.duration <= 0
? { ...resolved, duration: hint }
: resolved;
if (currentTrack && repeatMode !== 'one' && currentTrack.id === nextTrack.id && queueIndex === newIndex) {
return { advanced: false, nextTrack, newIndex };
}
if (currentTrack && !currentRadio) {
appendTimelineLeaveTrack(currentTrack, queueItems, queueIndex);
}
void playListenSessionOnTrackSwitched(nextTrack);
applyGaplessSuccessorUi(store, nextTrack, newIndex, source);
return { advanced: true, nextTrack, newIndex };
}
/**
* When gapless is on and the engine position jumps backward mid-playback, the
* Rust side likely switched sources without delivering `audio:track_switched`.
*/
export function maybeReconcileGaplessFromProgress(
currentTime: number,
engineDuration: number,
): void {
if (!useAuthStore.getState().gaplessEnabled) return;
if (isSeekDebouncePending() || getSeekTarget() !== null) return;
const store = usePlayerStore.getState();
if (!store.isPlaying || store.currentRadio || !store.currentTrack) return;
if (Date.now() - getLastGaplessSwitchTime() < 400) return;
const prevSec = getLastEngineProgressSec();
// Gapless transitions restart near 0 — mid-track regressions are usually
// buffering/seek glitches, not a decoder boundary without track_switched.
const nearStart = currentTime < 8;
const regressed = nearStart && currentTime + 1.5 < prevSec && prevSec > 8;
if (!regressed) {
noteEngineProgressForGapless(currentTime);
return;
}
const { nextTrack, newIndex } = resolveGaplessSuccessor(
store.queueItems,
store.queueIndex,
store.repeatMode,
store.currentTrack,
);
if (!nextTrack || nextTrack.id === store.currentTrack.id) return;
const slotRef = store.queueItems[store.queueIndex];
if (!slotRef || slotRef.trackId !== store.currentTrack.id) return;
if (store.repeatMode !== 'one' && newIndex <= store.queueIndex) return;
applyGaplessQueueAdvance({
engineDurationHint: engineDuration,
source: 'progress-reconcile',
});
markGaplessSwitch();
clearPreloadingIds();
setIsAudioPaused(false);
noteEngineProgressForGapless(currentTime);
}
@@ -54,6 +54,10 @@ vi.mock('@/features/playback/store/queueTrackView', () => ({
}));
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
getPlaybackCacheServerKey: () => 'srv-key',
playbackCacheKeyForTrack: () => 'srv-key',
}));
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
prepareTrackForEngineBind: vi.fn(async (track: Track) => track),
}));
import {
@@ -1,7 +1,8 @@
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
import { scheduleHotCachePrefetchForTrack } from '@/hotCachePrefetch';
import { getPlaybackCacheServerKey } from '@/features/playback/utils/playback/playbackServer';
import { getPlaybackCacheServerKey, playbackCacheKeyForTrack } from '@/features/playback/utils/playback/playbackServer';
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
import { useAuthStore } from '@/store/authStore';
import { bumpPlayGeneration, getPlayGeneration } from '@/features/playback/store/engineState';
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
@@ -62,10 +63,20 @@ export function preparePausedRestoreOnStartup(
if (getPlayGeneration() !== generation) return;
if (usePlayerStore.getState().isPlaying) return;
const queue = getQueueTracksView(queueItems, [track]);
const metadataSid =
playbackCacheKeyForTrack(track)
|| getPlaybackCacheServerKey()
|| '';
const trackForEngine = metadataSid
? await prepareTrackForEngineBind(track, metadataSid)
: track;
if (getPlayGeneration() !== generation) return;
if (usePlayerStore.getState().isPlaying) return;
const queue = getQueueTracksView(queueItems, [trackForEngine]);
engineLoadTrackAtPosition({
generation,
track,
track: trackForEngine,
queue,
queueIndex,
atSeconds,
+72 -52
View File
@@ -33,6 +33,7 @@ import {
} from '@/store/localPlaybackResolve';
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
import { useAuthStore } from '@/store/authStore';
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition, peekArmedCrossfadeDynamicOverlap } from '@/features/playback/store/crossfadeTrimCache';
@@ -41,10 +42,10 @@ import {
getPlayGeneration,
setIsAudioPaused,
} from '@/features/playback/store/engineState';
import {
clearPreloadingIds,
import { clearPreloadingIds,
getLastGaplessSwitchTime,
} from '@/features/playback/store/gaplessPreloadState';
import { resetGaplessProgressTracking } from '@/features/playback/store/gaplessProgressTracking';
import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch';
import {
isReplayGainActive,
@@ -325,25 +326,26 @@ export function runPlayTrack(
&& getPlaybackCacheServerKey(),
);
const runPlayTrackBody = () => {
const runPlayTrackBody = (trackForPlay: Track) => {
const playNormWindow = normWindow.map((t, i) => (i === normIdx ? trackForPlay : t));
const authStateNow = useAuthStore.getState();
const playbackSid = playbackProfileIdForTrack(scopedTrack, playingRef);
const playbackCacheSid = playbackCacheKeyForTrack(scopedTrack, playingRef);
const playbackSid = playbackProfileIdForTrack(trackForPlay, playingRef);
const playbackCacheSid = playbackCacheKeyForTrack(trackForPlay, playingRef);
const url = libraryLocalUrl
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'library')
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'favorite-auto')
?? resolvePlaybackUrl(scopedTrack.id, playbackCacheSid);
recordEnginePlayUrl(scopedTrack.id, url);
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'library')
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'favorite-auto')
?? resolvePlaybackUrl(trackForPlay.id, playbackCacheSid);
recordEnginePlayUrl(trackForPlay.id, url);
const preloadedTrackId = get().enginePreloadedTrackId;
const keepPreloadHint = preloadedTrackId === scopedTrack.id;
const keepPreloadHint = preloadedTrackId === trackForPlay.id;
const playbackSourceHint = playbackSourceHintForResolvedUrl(
scopedTrack.id,
trackForPlay.id,
playbackCacheSid,
url,
);
if (import.meta.env.DEV) {
console.info('[psysonic][playTrack-source]', {
trackId: scopedTrack.id,
trackId: trackForPlay.id,
resolvedUrl: url,
preloadedTrackId,
keepPreloadHint,
@@ -363,24 +365,25 @@ export function runPlayTrack(
if (sid) seedQueueResolver(sid, [t]);
}
} else if (queueSid) {
seedQueueResolver(queueSid, [scopedTrack]);
seedQueueResolver(queueSid, [trackForPlay]);
}
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(scopedTrack.id);
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(trackForPlay.id);
const wantInterruptBlend = Boolean(
shouldAutodjInterruptBlend(wasPlayingBeforeSkip, hasJsAutoHandoff)
&& prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id),
&& !sameQueueTrackId(prevTrack.id, trackForPlay.id),
);
const bReadyNow = isCrossfadeNextReady(scopedTrack.id, playbackSid, playbackCacheSid);
const bReadyNow = isCrossfadeNextReady(trackForPlay.id, playbackSid, playbackCacheSid);
/** Cold interrupt: engine still on A — don't swap player-bar metadata until handoff. */
const deferInterruptUi = shouldDeferInterruptHandoffUi(wantInterruptBlend, bReadyNow);
const applyInterruptHandoffUi = () => {
resetGaplessProgressTracking();
set({
currentTrack: scopedTrack,
currentTrack: trackForPlay,
waveformBins: null,
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
...deriveNormalizationSnapshot(trackForPlay, playNormWindow, normIdx),
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
@@ -389,10 +392,10 @@ export function runPlayTrack(
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
enginePreloadedTrackId: keepPreloadHint ? trackForPlay.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(scopedTrack.id, { syncPlayingEngine: false });
void refreshWaveformForTrack(trackForPlay.id);
void refreshLoudnessForTrack(trackForPlay.id, { syncPlayingEngine: false });
};
if (deferInterruptUi) {
@@ -402,11 +405,12 @@ export function runPlayTrack(
queueIndex: idx >= 0 ? idx : 0,
});
} else {
resetGaplessProgressTracking();
set({
currentTrack: scopedTrack,
currentTrack: trackForPlay,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
...deriveNormalizationSnapshot(trackForPlay, playNormWindow, normIdx),
// Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
queueIndex: idx >= 0 ? idx : 0,
@@ -423,11 +427,11 @@ export function runPlayTrack(
? false
: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
enginePreloadedTrackId: keepPreloadHint ? trackForPlay.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshWaveformForTrack(trackForPlay.id);
void refreshLoudnessForTrack(
scopedTrack.id,
trackForPlay.id,
wantInterruptBlend ? { syncPlayingEngine: false } : undefined,
);
}
@@ -435,7 +439,7 @@ export function runPlayTrack(
setDeferHotCachePrefetch(true);
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& !sameQueueTrackId(prevTrack.id, trackForPlay.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef);
@@ -448,10 +452,10 @@ export function runPlayTrack(
}
}
const replayGainDb = resolveReplayGainDb(
scopedTrack, prevTrack, nextNeighbour,
trackForPlay, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
const replayGainPeak = isReplayGainActive() ? (trackForPlay.replayGainPeak ?? null) : null;
const invokeAudioPlay = (manualBlend: CrossfadeTransitionPlan | null) => {
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
@@ -467,8 +471,8 @@ export function runPlayTrack(
&& initialTime <= 0.05;
const useManualBlend = manualBlend !== null;
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(scopedTrack.id) : null;
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(trackForPlay.id) : null;
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(trackForPlay.id) : null;
const crossfadeStartSecs = useManualBlend
? manualBlend.bStartSec
: (crossfadePlan?.bStartSec ?? 0);
@@ -490,17 +494,17 @@ export function runPlayTrack(
invoke('audio_play', {
url,
volume: state.volume,
durationHint: scopedTrack.duration,
durationHint: trackForPlay.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id),
loudnessGainDb: loudnessGainDbForEngineBind(trackForPlay.id),
preGainDb: authStateNow.replayGainPreGainDb,
fallbackDb: authStateNow.replayGainFallbackDb,
manual,
...audioPlayHiResBlendArgs(authStateNow),
analysisTrackId: scopedTrack.id,
analysisTrackId: trackForPlay.id,
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: scopedTrack.suffix ?? null,
streamFormatSuffix: trackForPlay.suffix ?? null,
startPaused: false,
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
crossfadeSecsOverride,
@@ -515,7 +519,7 @@ export function runPlayTrack(
if (keepPreloadHint) {
set({ enginePreloadedTrackId: null });
}
const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null;
const durSeek = trackForPlay.duration && trackForPlay.duration > 0 ? trackForPlay.duration : null;
const seekTo = initialTime;
const canSeekAfterPlay =
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
@@ -524,12 +528,12 @@ export function runPlayTrack(
.then(() => {
if (getPlayGeneration() !== gen) return;
setSeekTarget(seekTo);
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
if (getSeekFallbackVisualTarget()?.trackId === trackForPlay.id) {
setSeekFallbackVisualTarget(null);
}
})
.catch(() => {
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
if (getSeekFallbackVisualTarget()?.trackId === trackForPlay.id) {
setSeekFallbackVisualTarget(null);
}
});
@@ -552,28 +556,28 @@ export function runPlayTrack(
// now-playing follows scrobbling, as Last.fm now-playing did (runtime gates
// internally). playbackReportStart opens the live FSM on extension-capable
// servers and falls back to the legacy presence call otherwise.
playbackReportStart(scopedTrack.id, playbackSid);
playbackReportStart(trackForPlay.id, playbackSid);
const runtime = getMusicNetworkRuntimeOrNull();
void runtime?.dispatchNowPlaying({
title: scopedTrack.title,
artist: scopedTrack.artist,
album: scopedTrack.album,
duration: scopedTrack.duration,
title: trackForPlay.title,
artist: trackForPlay.artist,
album: trackForPlay.album,
duration: trackForPlay.duration,
timestamp: Date.now(),
});
if (runtime?.getEnrichmentPrimaryId()) {
void runtime
.isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist })
.isTrackLoved({ title: trackForPlay.title, artist: trackForPlay.artist })
.then(loved => {
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
const cacheKey = `${trackForPlay.title}::${trackForPlay.artist}`;
set(s => ({
networkLoved: loved,
networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved },
}));
});
}
pushQueueOnPlaybackStart(get().queueItems, scopedTrack, initialTime);
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
pushQueueOnPlaybackStart(get().queueItems, trackForPlay, initialTime);
touchHotCacheOnPlayback(trackForPlay.id, playbackCacheSid);
};
const startAudio = (manualBlend: CrossfadeTransitionPlan | null) => {
@@ -593,12 +597,12 @@ export function runPlayTrack(
bReadyNow
? Promise.resolve({ ready: true })
: runInterruptBlendPrep(
scopedTrack,
trackForPlay,
playbackSid,
playbackCacheSid,
() => getPlayGeneration() !== gen,
),
fetchWaveformBins(scopedTrack.id, playbackCacheSid || null),
fetchWaveformBins(trackForPlay.id, playbackCacheSid || null),
]);
if (getPlayGeneration() !== gen) {
clearInterruptHandoff();
@@ -610,7 +614,7 @@ export function runPlayTrack(
aDur,
skipFromTimeSec,
bBins,
scopedTrack.duration || 0,
trackForPlay.duration || 0,
)
: null;
startAudio(blend
@@ -634,6 +638,22 @@ export function runPlayTrack(
startAudio(null);
};
const launchPlayTrackBody = () => {
void (async () => {
let trackForPlay = scopedTrack;
const metadataSid =
playbackCacheKeyForTrack(scopedTrack, playingRef)
|| get().queueServerId
|| getPlaybackIndexKey()
|| '';
if (metadataSid) {
trackForPlay = await enrichTrackPlaybackMetadata(scopedTrack, metadataSid);
}
if (getPlayGeneration() !== gen) return;
runPlayTrackBody(trackForPlay);
})();
};
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
@@ -643,7 +663,7 @@ export function runPlayTrack(
)
.then(() => {
if (getPlayGeneration() !== gen) return;
runPlayTrackBody();
launchPlayTrackBody();
})
.catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
@@ -652,6 +672,6 @@ export function runPlayTrack(
set({ isPlaying: false });
});
} else {
runPlayTrackBody();
launchPlayTrackBody();
}
}
@@ -61,6 +61,9 @@ vi.mock('@/features/playback/store/playerStore', () => ({
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('@/features/playback/utils/audio/prepareTrackForEngineBind', () => ({
prepareTrackForEngineBind: vi.fn(async (track: Track) => track),
}));
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
@@ -90,12 +93,14 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: true,
});
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
url: 'https://mock/t1',
durationHint: 100,
manual: false,
analysisTrackId: 't1',
}));
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
url: 'https://mock/t1',
durationHint: 100,
manual: false,
analysisTrackId: 't1',
}));
});
expect(hoisted.recordEnginePlayUrlMock).toHaveBeenCalledWith('t1', 'https://mock/t1');
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(true);
expect(hoisted.touchHotCacheOnPlaybackMock).toHaveBeenCalledWith('t1', 'srv');
@@ -110,9 +115,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 30,
wantPlaying: true,
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 });
});
});
it('skips audio_seek when atSeconds is near zero', async () => {
@@ -124,7 +129,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: true,
});
await Promise.resolve();
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.anything());
});
await Promise.resolve();
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
expect(seekCall).toBeUndefined();
@@ -139,17 +146,17 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: false,
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
startPaused: true,
}));
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.objectContaining({
startPaused: true,
}));
});
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
expect(pauseCall).toBeUndefined();
expect(hoisted.setIsAudioPausedMock).toHaveBeenCalledWith(true);
});
it('bails out of the .then chain when generation has moved on', async () => {
it('bails out before audio_play when generation has moved on during prepare', async () => {
hoisted.getPlayGeneration.mockReturnValue(2); // user navigated, new gen
queueUndoRestoreAudioEngine({
generation: 1,
@@ -159,9 +166,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 30,
wantPlaying: false,
});
await Promise.resolve();
await Promise.resolve();
// Should NOT have called audio_seek or audio_pause — gen moved on.
await vi.waitFor(() => {
expect(hoisted.invokeMock.mock.calls.length).toBe(0);
});
const seekCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_seek');
const pauseCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'audio_pause');
expect(seekCall).toBeUndefined();
@@ -178,7 +185,9 @@ describe('queueUndoRestoreAudioEngine', () => {
atSeconds: 0,
wantPlaying: true,
});
await Promise.resolve();
await vi.waitFor(() => {
expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_play', expect.anything());
});
await Promise.resolve();
await Promise.resolve();
expect(hoisted.setDeferHotCachePrefetchMock).toHaveBeenCalledWith(false);
@@ -1,5 +1,11 @@
import type { Track } from '@/lib/media/trackTypes';
import {
getPlaybackIndexKey,
playbackCacheKeyForTrack,
} from '@/features/playback/utils/playback/playbackServer';
import { getPlayGeneration } from '@/features/playback/store/engineState';
import { engineLoadTrackAtPosition } from '@/features/playback/store/engineLoadTrackAtPosition';
import { prepareTrackForEngineBind } from '@/features/playback/utils/audio/prepareTrackForEngineBind';
/**
* Reload the Rust audio engine to match a queue-undo snapshot. Zustand
@@ -20,5 +26,15 @@ export function queueUndoRestoreAudioEngine(opts: {
atSeconds: number;
wantPlaying: boolean;
}): void {
engineLoadTrackAtPosition(opts);
void (async () => {
const serverId =
playbackCacheKeyForTrack(opts.track)
|| getPlaybackIndexKey()
|| '';
const track = serverId
? await prepareTrackForEngineBind(opts.track, serverId)
: opts.track;
if (getPlayGeneration() !== opts.generation) return;
engineLoadTrackAtPosition({ ...opts, track });
})();
}
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
import {
_resetQueueResolverForTest,
seedQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import * as resolveSongMetaIndexFirst from '@/lib/library/resolveSongMetaIndexFirst';
import {
_resetIndexRefreshInflightForTest,
maybeRefreshCurrentTrackMetadataFromIndex,
maybeSyncCurrentTrackFromResolver,
shouldSyncCurrentTrackMetadata,
shouldUpgradeReplayGainMetadata,
syncIdleAppliesToQueueRef,
} from '@/features/playback/store/replayGainMetadataSync';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
const track = (id: string, extra: Partial<Track> = {}): Track => ({
id,
title: extra.title ?? `Track ${id}`,
artist: 'Artist',
album: 'Album',
albumId: extra.albumId ?? 'alb-1',
duration: 200,
...extra,
});
describe('shouldUpgradeReplayGainMetadata', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
});
});
it('returns true when track gain appears on a placeholder snapshot', () => {
const prev = track('t1', { title: '…', replayGainTrackDb: undefined });
const next = track('t1', { replayGainTrackDb: -8.5 });
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
it('returns false when both snapshots lack ReplayGain tags', () => {
const prev = track('t1', { replayGainTrackDb: undefined });
const next = track('t1', { title: 'Resolved title' });
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(false);
});
it('returns true when peak metadata arrives', () => {
const base = track('t1', { replayGainTrackDb: -6 });
const withPeak = { ...base, replayGainPeak: 0.98 };
expect(shouldUpgradeReplayGainMetadata(base, withPeak, [ref('t1')], 0)).toBe(true);
});
it('returns true when track gain was recalculated on the server', () => {
const prev = track('t1', { replayGainTrackDb: -6.0 });
const next = track('t1', { replayGainTrackDb: -8.5 });
expect(shouldUpgradeReplayGainMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
});
describe('shouldSyncCurrentTrackMetadata', () => {
it('returns true when placeholder title resolves', () => {
const prev = track('t1', { title: '…' });
const next = track('t1', { title: 'Resolved title' });
expect(shouldSyncCurrentTrackMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
it('returns true when duration arrives on a thin snapshot', () => {
const prev = track('t1', { duration: 0 });
const next = track('t1', { duration: 240 });
expect(shouldSyncCurrentTrackMetadata(prev, next, [ref('t1')], 0)).toBe(true);
});
});
describe('maybeSyncCurrentTrackFromResolver', () => {
beforeEach(() => {
_resetQueueResolverForTest();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
});
usePlayerStore.setState({
currentTrack: track('t1', { title: '…' }),
queueItems: [ref('t1')],
queueIndex: 0,
isPlaying: true,
currentRadio: null,
volume: 0.8,
});
vi.clearAllMocks();
});
it('upgrades currentTrack and pushes replay gain when the resolver cache fills', () => {
seedQueueResolver('s1', [track('t1', { replayGainTrackDb: -7.2, replayGainPeak: 0.95 })]);
maybeSyncCurrentTrackFromResolver();
const s = usePlayerStore.getState();
expect(s.currentTrack?.replayGainTrackDb).toBe(-7.2);
expect(s.currentTrack?.replayGainPeak).toBe(0.95);
});
it('no-ops engine gain when ReplayGain mode is off but still syncs thin title', () => {
useAuthStore.setState({ normalizationEngine: 'off', replayGainEnabled: false });
seedQueueResolver('s1', [track('t1', { title: 'Full title' })]);
maybeSyncCurrentTrackFromResolver();
expect(usePlayerStore.getState().currentTrack?.title).toBe('Full title');
expect(usePlayerStore.getState().currentTrack?.replayGainTrackDb).toBeUndefined();
});
it('no-ops when transport is idle', () => {
usePlayerStore.setState({ isPlaying: false });
seedQueueResolver('s1', [track('t1', { title: 'Full title', duration: 220 })]);
maybeSyncCurrentTrackFromResolver();
expect(usePlayerStore.getState().currentTrack?.title).toBe('…');
});
it('upgrades placeholder title without ReplayGain when normalization is off', () => {
useAuthStore.setState({ normalizationEngine: 'off', replayGainEnabled: false });
seedQueueResolver('s1', [track('t1', { title: 'Full title', duration: 220 })]);
maybeSyncCurrentTrackFromResolver();
const s = usePlayerStore.getState();
expect(s.currentTrack?.title).toBe('Full title');
expect(s.currentTrack?.duration).toBe(220);
});
it('syncs ReplayGain tags from the resolver onto the live track', () => {
seedQueueResolver('s1', [track('t1', { replayGainTrackDb: -7.2, replayGainPeak: 0.95 })]);
maybeSyncCurrentTrackFromResolver();
expect(usePlayerStore.getState().currentTrack?.replayGainTrackDb).toBe(-7.2);
});
});
describe('maybeRefreshCurrentTrackMetadataFromIndex', () => {
beforeEach(() => {
_resetQueueResolverForTest();
_resetIndexRefreshInflightForTest();
vi.restoreAllMocks();
onInvoke('audio_update_replay_gain', () => undefined);
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
replayGainMode: 'track',
replayGainPreGainDb: 0,
replayGainFallbackDb: -6,
});
useLibraryIndexStore.setState({ masterEnabled: true });
usePlayerStore.setState({
currentTrack: track('t1', { replayGainTrackDb: -6.0, replayGainPeak: 0.8 }),
queueItems: [ref('t1')],
queueIndex: 0,
isPlaying: true,
currentRadio: null,
volume: 0.8,
});
});
it('upgrades recalculated ReplayGain from the library index', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Track t1',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.5,
replayGainPeak: 0.91,
syncedAt: 0,
rawJson: {},
}));
await maybeRefreshCurrentTrackMetadataFromIndex();
const s = usePlayerStore.getState();
expect(s.currentTrack?.replayGainTrackDb).toBe(-8.5);
expect(s.currentTrack?.replayGainPeak).toBe(0.91);
});
it('no-ops when the playing slot changed during index fetch', async () => {
vi.spyOn(resolveSongMetaIndexFirst, 'resolveSongMetaIndexFirst').mockImplementation(async () => {
usePlayerStore.setState({
currentTrack: track('t2', { replayGainTrackDb: -3.0 }),
queueItems: [ref('t1'), ref('t2')],
queueIndex: 1,
});
return {
id: 't1',
title: 'Track t1',
album: 'Album',
duration: 200,
replayGain: { trackGain: -8.5, trackPeak: 0.91 },
} as Awaited<ReturnType<typeof resolveSongMetaIndexFirst.resolveSongMetaIndexFirst>>;
});
await maybeRefreshCurrentTrackMetadataFromIndex();
const s = usePlayerStore.getState();
expect(s.currentTrack?.id).toBe('t2');
expect(s.currentTrack?.replayGainTrackDb).toBe(-3.0);
});
});
describe('syncIdleAppliesToQueueRef', () => {
it('matches profile id to index key on the ref', () => {
expect(syncIdleAppliesToQueueRef('profile-uuid', { serverId: 'profile-uuid', trackId: 't1' }))
.toBe(true);
});
});
@@ -0,0 +1,189 @@
/**
* HTTP-stream playback often starts from a thin-queue placeholder (or any track
* snapshot missing ReplayGain tags). `audio_play` then applies fallback gain.
* The queue resolver fetches full metadata asynchronously — this side-effect
* upgrades `currentTrack` and pushes fresh gain to the engine once tags land,
* mirroring the loudness cache refresh path (`refreshLoudnessForTrack`).
*
* After library sync, {@link maybeRefreshCurrentTrackMetadataFromIndex} re-reads
* index-first metadata for the live slot so recalculated ReplayGain tags apply
* without re-starting playback.
*/
import { listen } from '@tauri-apps/api/event';
import type { LibrarySyncIdlePayload } from '@/lib/api/library/dto';
import { resolveSongMetaIndexFirst } from '@/lib/library/resolveSongMetaIndexFirst';
import { mergePlaybackTrackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
import { isReplayGainActive } from '@/features/playback/store/loudnessGainCache';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
patchCachedTrack,
subscribeQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
import { songToTrack } from '@/lib/media/songToTrack';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
import { useAuthStore } from '@/store/authStore';
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
function replayGainNeighbours(
queueItems: QueueItemRef[],
queueIndex: number,
): { prev: Track | null; next: Track | null } {
const prev = queueIndex > 0 && queueItems[queueIndex - 1]
? resolveQueueTrack(queueItems[queueIndex - 1])
: null;
const next = queueIndex + 1 < queueItems.length && queueItems[queueIndex + 1]
? resolveQueueTrack(queueItems[queueIndex + 1])
: null;
return { prev, next };
}
function resolvedReplayGainDb(
track: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): number | null {
const auth = useAuthStore.getState();
const { prev, next } = replayGainNeighbours(queueItems, queueIndex);
return resolveReplayGainDb(track, prev, next, true, auth.replayGainMode);
}
/** True when resolver metadata would change the ReplayGain bind for this slot. */
export function shouldUpgradeReplayGainMetadata(
prev: Track,
next: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): boolean {
if (prev.replayGainPeak !== next.replayGainPeak) return true;
return resolvedReplayGainDb(prev, queueItems, queueIndex)
!== resolvedReplayGainDb(next, queueItems, queueIndex);
}
/** True when resolver metadata would improve the live player-bar snapshot. */
export function shouldSyncCurrentTrackMetadata(
prev: Track,
next: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): boolean {
if (prev.title === '…' && next.title && next.title !== '…') return true;
if (prev.duration === 0 && next.duration > 0) return true;
return shouldUpgradeReplayGainMetadata(prev, next, queueItems, queueIndex);
}
function applyCurrentTrackMetadataUpgrade(
prev: Track,
merged: Track,
queueItems: QueueItemRef[],
queueIndex: number,
): void {
if (!shouldSyncCurrentTrackMetadata(prev, merged, queueItems, queueIndex)) return;
usePlayerStore.setState({ currentTrack: merged });
patchCachedTrack(prev.id, {
title: merged.title,
duration: merged.duration,
replayGainTrackDb: merged.replayGainTrackDb,
replayGainAlbumDb: merged.replayGainAlbumDb,
replayGainPeak: merged.replayGainPeak,
});
if (
isReplayGainActive()
&& shouldUpgradeReplayGainMetadata(prev, merged, queueItems, queueIndex)
) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
}
/** True when a library sync-idle event applies to this queue ref's server. */
export function syncIdleAppliesToQueueRef(syncServerId: string, ref: QueueItemRef): boolean {
const scopeId = syncServerId.trim();
if (!scopeId) return false;
const profileId = resolveServerIdForIndexKey(scopeId) || scopeId;
const refCanonical = canonicalQueueServerKey(ref.serverId);
const refProfile = resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
return ref.serverId === scopeId
|| ref.serverId === profileId
|| refCanonical === scopeId
|| refCanonical === profileId
|| refProfile === scopeId
|| refProfile === profileId;
}
/** Push resolver-fetched metadata onto the live track; upgrade engine gain when needed. */
export function maybeSyncCurrentTrackFromResolver(): void {
const state = usePlayerStore.getState();
const { currentTrack, queueItems, queueIndex, isPlaying, currentRadio } = state;
if (!currentTrack || !isPlaying || currentRadio) return;
const ref = queueItems[queueIndex];
if (!ref || ref.trackId !== currentTrack.id) return;
const resolved = resolveQueueTrack(ref, currentTrack);
const merged = mergePlaybackTrackMetadata(currentTrack, resolved);
applyCurrentTrackMetadataUpgrade(currentTrack, merged, queueItems, queueIndex);
}
/**
* Re-read index-first metadata for the playing slot and upgrade ReplayGain when
* library tags differ from the live snapshot (post-sync recalc, new tags, peak).
*/
export async function maybeRefreshCurrentTrackMetadataFromIndex(): Promise<void> {
const state = usePlayerStore.getState();
const { currentTrack, queueItems, queueIndex, isPlaying, currentRadio } = state;
if (!currentTrack || !isPlaying || currentRadio) return;
const ref = queueItems[queueIndex];
if (!ref || ref.trackId !== currentTrack.id) return;
const serverId = playbackCacheKeyForRef(ref);
if (!serverId) return;
const trackId = currentTrack.id;
const song = await resolveSongMetaIndexFirst(serverId, trackId);
if (!song) return;
const live = usePlayerStore.getState();
if (!live.isPlaying || live.currentRadio || !live.currentTrack) return;
const liveRef = live.queueItems[live.queueIndex];
if (live.currentTrack.id !== trackId || liveRef?.trackId !== trackId) return;
const merged = mergePlaybackTrackMetadata(live.currentTrack, songToTrack(song));
applyCurrentTrackMetadataUpgrade(live.currentTrack, merged, live.queueItems, live.queueIndex);
}
let indexRefreshInflight: Promise<void> | null = null;
export function scheduleCurrentTrackMetadataRefreshFromIndex(): void {
if (indexRefreshInflight) return;
indexRefreshInflight = maybeRefreshCurrentTrackMetadataFromIndex()
.catch(() => {})
.finally(() => {
indexRefreshInflight = null;
});
}
/** Test-only: drop coalesced index refresh state. */
export function _resetIndexRefreshInflightForTest(): void {
indexRefreshInflight = null;
}
function scheduleIndexRefreshAfterSyncIdle(payload: LibrarySyncIdlePayload): void {
if (!payload.ok) return;
const state = usePlayerStore.getState();
if (!state.isPlaying || state.currentRadio || !state.currentTrack) return;
const ref = state.queueItems[state.queueIndex];
if (!ref || ref.trackId !== state.currentTrack.id) return;
if (!syncIdleAppliesToQueueRef(payload.serverId, ref)) return;
scheduleCurrentTrackMetadataRefreshFromIndex();
}
subscribeQueueResolver(() => {
maybeSyncCurrentTrackFromResolver();
});
void listen<LibrarySyncIdlePayload>('library:sync-idle', ({ payload }) => {
scheduleIndexRefreshAfterSyncIdle(payload);
});
+51 -84
View File
@@ -1,4 +1,5 @@
import { getSong } from '@/lib/api/subsonicLibrary';
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
import { invoke } from '@tauri-apps/api/core';
import { estimateLivePosition, orbitSnapshot } from '@/store/orbitRuntime';
import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate';
@@ -57,10 +58,9 @@ type GetState = () => PlayerState;
* `audio_resume` is enough.
* - **Cold**: engine has no loaded stream (app relaunch, or track
* ended and user hit play again). Promote any
* `stream_completed_cache` to hot disk, refetch the song from
* Navidrome for fresh ReplayGain metadata, call `audio_play`,
* then seek to the persisted `currentTime`. A `getSong` failure
* falls back to the in-memory `currentTrack`.
* `stream_completed_cache` to hot disk, prefetch ReplayGain metadata
* (index → getSong), call `audio_play`, then seek to the persisted
* `currentTime`.
*/
export function runResume(set: SetState, get: GetState): void {
clearAllPlaybackScheduleTimers();
@@ -156,88 +156,55 @@ export function runResume(set: SetState, get: GetState): void {
}
if (getPlayGeneration() !== gen) return;
// Fetch fresh track data from server to get replay gain metadata
getSong(currentTrack.id).then(freshSong => {
if (getPlayGeneration() !== gen) return;
const coldServerId = getPlaybackIndexKey();
let trackToPlay = currentTrack;
try {
if (coldServerId) {
trackToPlay = await enrichTrackPlaybackMetadata(currentTrack, coldServerId);
}
} catch { /* keep currentTrack */ }
if (getPlayGeneration() !== gen) return;
if (trackToPlay !== currentTrack) set({ currentTrack: trackToPlay });
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
trackToPlay, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
recordEnginePlayUrl(trackToPlay.id, coldUrl);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
...audioPlayHiResBlendArgs(useAuthStore.getState()),
analysisTrackId: trackToPlay.id,
serverId: coldServerId || null,
streamFormatSuffix: trackToPlay.suffix ?? null,
startPaused: false,
}).then(() => {
if (getPlayGeneration() === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
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 = resolveReplayGainDb(
trackToPlay, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = getPlaybackIndexKey();
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
recordEnginePlayUrl(trackToPlay.id, coldUrl);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
...audioPlayHiResBlendArgs(useAuthStore.getState()),
analysisTrackId: trackToPlay.id,
serverId: coldServerId || null,
streamFormatSuffix: trackToPlay.suffix ?? null,
startPaused: false,
}).then(() => {
if (getPlayGeneration() === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
}
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
pushQueueOnPlaybackStart(queueItems, trackToPlay, currentTime);
}).catch(() => {
if (getPlayGeneration() !== gen) return;
// Fallback to currentTrack if fetch fails
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
currentTrack, coldPrev, coldNext,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = getPlaybackIndexKey();
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(currentTrack.id, coldServerId, coldUrl) });
recordEnginePlayUrl(currentTrack.id, coldUrl);
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
...audioPlayHiResBlendArgs(useAuthStore.getState()),
analysisTrackId: currentTrack.id,
serverId: coldServerId || null,
streamFormatSuffix: currentTrack.suffix ?? null,
startPaused: false,
}).catch((err: unknown) => {
if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
pushQueueOnPlaybackStart(queueItems, currentTrack, currentTime);
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
pushQueueOnPlaybackStart(queueItems, trackToPlay, currentTime);
})();
}
}
@@ -15,6 +15,7 @@ import {
setSeekFallbackTrackId,
setSeekFallbackVisualTarget,
} from '@/features/playback/store/seekFallbackState';
import { noteEngineProgressForGapless } from '@/features/playback/store/gaplessProgressTracking';
import {
clearSeekTarget,
setSeekTarget,
@@ -66,6 +67,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
invoke('audio_seek', { seconds: time }).then(() => {
// Arm stale-progress guard only after backend acknowledged seek.
setSeekTarget(time);
noteEngineProgressForGapless(time);
setSeekFallbackVisualTarget(null);
clearSeekFallbackRetry();
// Seeking straight into the crossfade pre-buffer window must kick off the
@@ -99,6 +101,7 @@ export function runSeek(set: SetState, get: GetState, progress: number): void {
// Keep stale progress ticks from snapping UI back to start while
// recoverable seek retries are still in flight.
setSeekTarget(time);
noteEngineProgressForGapless(time);
if (msg.includes('not seekable') && !sameBurst) {
setSeekFallbackTrackId(s.currentTrack.id);
setSeekFallbackRestartAt(now);
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTracks, seedQueue } from '@/test/helpers/factories';
@@ -22,13 +22,15 @@ describe('timeline history on queue replace', () => {
onInvoke('discord_update_presence', () => undefined);
});
it('keeps session history when playTrack replaces the queue', () => {
it('keeps session history when playTrack replaces the queue', async () => {
const first = makeTracks(1);
seedQueue(first, { index: 0, currentTrack: first[0] });
const album = makeTracks(3);
usePlayerStore.getState().playTrack(album[0]!, album, true, true);
await vi.waitFor(() => {
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(album.map(t => t.id));
});
const history = getTimelineSessionHistorySnapshot();
expect(history.some(h => h.trackId === first[0]!.id)).toBe(true);
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(album.map(t => t.id));
});
});
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as subsonicLibrary from '@/lib/api/subsonicLibrary';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import type { Track } from '@/lib/media/trackTypes';
import {
enrichTrackPlaybackMetadata,
mergePlaybackTrackMetadata,
trackNeedsPlaybackMetadataPrefetch,
trackNeedsReplayGainMetadataPrefetch,
} from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
const track = (extra: Partial<Track> = {}): Track => ({
id: 't1',
title: extra.title ?? 'Track',
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
duration: 200,
...extra,
});
describe('trackNeedsReplayGainMetadataPrefetch', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
});
it('returns true when ReplayGain is on and tags are missing', () => {
expect(trackNeedsReplayGainMetadataPrefetch(track())).toBe(true);
});
it('returns false when track gain is present', () => {
expect(trackNeedsReplayGainMetadataPrefetch(track({ replayGainTrackDb: -6 }))).toBe(false);
});
});
describe('mergePlaybackTrackMetadata', () => {
it('fills placeholder title and ReplayGain from the resolved track', () => {
const base = track({ title: '…', duration: 0 });
const resolved = track({
title: 'Resolved',
duration: 240,
replayGainTrackDb: -7.5,
replayGainPeak: 0.99,
});
const merged = mergePlaybackTrackMetadata(base, resolved);
expect(merged.title).toBe('Resolved');
expect(merged.duration).toBe(240);
expect(merged.replayGainTrackDb).toBe(-7.5);
expect(merged.replayGainPeak).toBe(0.99);
});
});
describe('trackNeedsPlaybackMetadataPrefetch', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'off',
replayGainEnabled: false,
});
});
it('returns true for thin placeholder snapshots even when ReplayGain is off', () => {
expect(trackNeedsPlaybackMetadataPrefetch(track({ title: '…' }))).toBe(true);
expect(trackNeedsPlaybackMetadataPrefetch(track({ duration: 0 }))).toBe(true);
});
it('returns true when ReplayGain is on and peak is missing but gain tags exist', () => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
expect(trackNeedsPlaybackMetadataPrefetch(track({ replayGainTrackDb: -6 }))).toBe(true);
});
it('returns false when ReplayGain tags and peak are present', () => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
expect(trackNeedsPlaybackMetadataPrefetch(track({
replayGainTrackDb: -6,
replayGainPeak: 0.88,
}))).toBe(false);
});
});
describe('enrichTrackPlaybackMetadata', () => {
beforeEach(() => {
useAuthStore.setState({
normalizationEngine: 'replaygain',
replayGainEnabled: true,
});
useLibraryIndexStore.setState({ masterEnabled: true });
vi.restoreAllMocks();
});
it('reads ReplayGain from the local index before network', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Indexed',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.1,
replayGainAlbumDb: -7.0,
syncedAt: 0,
rawJson: {},
}));
const networkSpy = vi.spyOn(subsonicLibrary, 'getSongForServer');
const enriched = await enrichTrackPlaybackMetadata(track({ title: '…' }), 's1');
expect(enriched.replayGainTrackDb).toBe(-8.1);
expect(enriched.title).toBe('Indexed');
expect(networkSpy).not.toHaveBeenCalled();
});
it('reads replayGainPeak from the local index without network', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Indexed',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.1,
replayGainPeak: 0.88,
syncedAt: 0,
rawJson: {},
}));
const networkSpy = vi.spyOn(subsonicLibrary, 'getSongForServer');
const enriched = await enrichTrackPlaybackMetadata(
track({ replayGainTrackDb: -8.1 }),
's1',
);
expect(enriched.replayGainPeak).toBe(0.88);
expect(networkSpy).not.toHaveBeenCalled();
});
it('refreshes recalculated ReplayGain from the index when tags already exist', async () => {
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
onInvoke('library_get_track', () => ({
serverId: 's1',
id: 't1',
title: 'Indexed',
album: 'Album',
durationSec: 200,
replayGainTrackDb: -8.5,
replayGainPeak: 0.91,
syncedAt: 0,
rawJson: {},
}));
const enriched = await enrichTrackPlaybackMetadata(
track({ replayGainTrackDb: -6.0, replayGainPeak: 0.8 }),
's1',
);
expect(enriched.replayGainTrackDb).toBe(-8.5);
expect(enriched.replayGainPeak).toBe(0.91);
});
});
@@ -0,0 +1,83 @@
import { libraryGetTrack } from '@/lib/api/library';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { isReplayGainActive } from '@/features/playback/store/loudnessGainCache';
import { trackToSong } from '@/lib/library/trackDtoMapping';
import { libraryIsReady } from '@/lib/library/libraryReady';
import { songToTrack } from '@/lib/media/songToTrack';
import type { Track } from '@/lib/media/trackTypes';
async function resolveSongMetaFromIndexForPlaybackEnrich(
serverId: string,
songId: string,
): Promise<SubsonicSong | null> {
if (!await libraryIsReady(serverId)) return null;
try {
const dto = await libraryGetTrack(serverId, songId);
if (dto) return trackToSong(dto);
} catch { /* index unavailable */ }
return null;
}
/** True when ReplayGain is on and the track snapshot has no gain tags yet. */
export function trackNeedsReplayGainMetadataPrefetch(track: Track): boolean {
if (!isReplayGainActive()) return false;
return track.replayGainTrackDb == null && track.replayGainAlbumDb == null;
}
/** True when index/getSong prefetch would improve a thin snapshot or ReplayGain tags. */
export function trackNeedsPlaybackMetadataPrefetch(track: Track): boolean {
if (track.title === '…' || track.duration === 0) return true;
if (trackNeedsReplayGainMetadataPrefetch(track)) return true;
return isReplayGainActive() && track.replayGainPeak == null
&& (track.replayGainTrackDb != null || track.replayGainAlbumDb != null);
}
/** Merge resolver/index metadata onto a thin playback snapshot without dropping queue flags. */
export function mergePlaybackTrackMetadata(base: Track, resolved: Track): Track {
const thin = base.title === '…' || base.duration === 0;
return {
...(thin ? resolved : base),
...base,
id: base.id,
autoAdded: base.autoAdded,
radioAdded: base.radioAdded,
playNextAdded: base.playNextAdded,
title: thin && resolved.title ? resolved.title : base.title,
artist: thin && resolved.artist ? resolved.artist : base.artist,
album: thin && resolved.album ? resolved.album : base.album,
albumId: resolved.albumId || base.albumId,
duration: resolved.duration > 0 ? resolved.duration : base.duration,
replayGainTrackDb: resolved.replayGainTrackDb ?? base.replayGainTrackDb,
replayGainAlbumDb: resolved.replayGainAlbumDb ?? base.replayGainAlbumDb,
replayGainPeak: resolved.replayGainPeak ?? base.replayGainPeak,
suffix: resolved.suffix ?? base.suffix,
bitRate: resolved.bitRate ?? base.bitRate,
samplingRate: resolved.samplingRate ?? base.samplingRate,
bitDepth: resolved.bitDepth ?? base.bitDepth,
coverArt: resolved.coverArt ?? base.coverArt,
artistId: resolved.artistId ?? base.artistId,
serverId: base.serverId ?? resolved.serverId,
};
}
/**
* Prefetch playback metadata (thin fields + ReplayGain) from the local index
* before binding the engine on stream / gapless paths. Network fallback stays
* in the queue resolver / {@link resolveSongMetaIndexFirst} path.
*/
export async function enrichTrackPlaybackMetadata(
track: Track,
serverId: string,
): Promise<Track> {
if (!serverId) return track;
const needsPrefetch = trackNeedsPlaybackMetadataPrefetch(track);
const rgRecheck = isReplayGainActive()
&& !needsPrefetch
&& (track.replayGainTrackDb != null
|| track.replayGainAlbumDb != null
|| track.replayGainPeak != null);
if (!needsPrefetch && !rgRecheck) return track;
const song = await resolveSongMetaFromIndexForPlaybackEnrich(serverId, track.id);
if (!song) return track;
return mergePlaybackTrackMetadata(track, songToTrack(song));
}
@@ -0,0 +1,16 @@
import type { Track } from '@/lib/media/trackTypes';
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
/**
* Index-first metadata before `audio_play` / `audio_chain_preload`.
* Callers in the playback store should await {@link refreshLoudnessForTrack}
* separately so dependency-cruiser does not route utils → store → utils cycles.
*/
export async function prepareTrackForEngineBind(
track: Track,
serverId: string,
): Promise<Track> {
return serverId
? enrichTrackPlaybackMetadata(track, serverId)
: track;
}
@@ -143,8 +143,15 @@ export default function WaveformSeek({ trackId }: Props) {
// Initial draw for static styles when style, track, or waveform payload changes.
useEffect(() => {
if (ANIMATED_STYLES.has(seekbarStyle)) return;
progressRef.current = 0;
bufferedRef.current = 0;
visualProgressRef.current = 0;
visualTargetProgressRef.current = 0;
// React Compiler immutability rule: intentional imperative reset on track change.
// eslint-disable-next-line react-hooks/immutability
progressAnchorRef.current = { progress: 0, atMs: performance.now() };
const canvas = canvasRef.current;
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, 0, 0);
}, [
seekbarStyle,
trackId,