mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(playback): ReplayGain prefetch, gapless UI sync, library peak column (#1231)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user