diff --git a/src/cover/useLibraryCoverRef.ts b/src/cover/useLibraryCoverRef.ts index dbcf2a00..e442d998 100644 --- a/src/cover/useLibraryCoverRef.ts +++ b/src/cover/useLibraryCoverRef.ts @@ -12,7 +12,7 @@ import { } from './ref'; import { coverServerScopeForServerId } from './serverScope'; import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; -import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity'; +import { queueItemRefMatchesTrack } from '@/features/playback/utils/playback/queueIdentity'; import { resolveAlbumCoverRefFromLibrary, resolveArtistCoverRefFromLibrary, @@ -302,7 +302,7 @@ export function usePlaybackTrackCoverRef( const scope = useMemo(() => { if (track?.id) { const ref = queueItems[queueIndex]; - if (ref && sameQueueTrackId(ref.trackId, track.id)) { + if (ref && queueItemRefMatchesTrack(ref, track)) { const profileId = resolveServerIdForIndexKey(ref.serverId) || ref.serverId; return coverServerScopeForServerId(profileId); } diff --git a/src/features/playback/store/applyQueueHistorySnapshot.ts b/src/features/playback/store/applyQueueHistorySnapshot.ts index f44107ba..9bd68600 100644 --- a/src/features/playback/store/applyQueueHistorySnapshot.ts +++ b/src/features/playback/store/applyQueueHistorySnapshot.ts @@ -1,6 +1,9 @@ import { playbackReportStart } from '@/features/playback/store/playbackReportSession'; import { audioStop } from '@/lib/api/audio'; -import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer'; +import { + getPlaybackServerId, + playbackProfileIdForTrack, +} from '@/features/playback/utils/playback/playbackServer'; import { getPlaybackSourceKind } from '@/features/playback/utils/playback/resolvePlaybackUrl'; import { bumpPlayGeneration, @@ -14,7 +17,10 @@ import type { PlayerState } from '@/features/playback/store/playerStoreTypes'; import { resolveQueueTrack } from '@/features/playback/store/queueTrackView'; import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver'; import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey'; -import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity'; +import { + queueItemRefMatchesTrack, + sameQueueTrack, +} from '@/features/playback/utils/playback/queueIdentity'; import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore'; import { setPendingQueueListScrollTop, @@ -69,7 +75,7 @@ export function applyQueueHistorySnapshot( if (snap.currentTrack == null && prior.currentTrack) { const playing = prior.currentTrack; - const pos = nextQueue.findIndex(t => sameQueueTrackId(t.id, playing.id)); + const pos = nextItems.findIndex(ref => queueItemRefMatchesTrack(ref, playing)); if (pos === -1) { // Prepend ref must bind to the *snapshot's* playback server (H3): a live // server switch racing the undo would otherwise stamp the prepended ref @@ -78,7 +84,8 @@ export function applyQueueHistorySnapshot( // fallback (they share the snapshot's server); live `queueServerId` is // last resort. Canonical key everywhere (B1). const snapshotSid = - snap.queueServerId + playing.serverId + ?? snap.queueServerId ?? snap.queueItems[0]?.serverId ?? get().queueServerId ?? ''; @@ -101,17 +108,17 @@ export function applyQueueHistorySnapshot( const keepPlaybackFromPrior = prior.currentTrack != null && nextTrack != null - && sameQueueTrackId(prior.currentTrack.id, nextTrack.id) - && nextQueue.some(t => sameQueueTrackId(t.id, prior.currentTrack!.id)) + && sameQueueTrack(prior.currentTrack, nextTrack) + && nextItems.some(ref => queueItemRefMatchesTrack(ref, prior.currentTrack)) && ( - (snap.currentTrack != null && sameQueueTrackId(prior.currentTrack.id, snap.currentTrack.id)) + (snap.currentTrack != null && sameQueueTrack(prior.currentTrack, snap.currentTrack)) || snap.currentTrack == null ); if (keepPlaybackFromPrior) { const playingKeep = prior.currentTrack; if (playingKeep) { - const idxPrior = nextQueue.findIndex(t => sameQueueTrackId(t.id, playingKeep.id)); + const idxPrior = nextItems.findIndex(ref => queueItemRefMatchesTrack(ref, playingKeep)); if (idxPrior >= 0) { nextIndex = idxPrior; nextTrack = { ...playingKeep }; @@ -143,7 +150,7 @@ export function applyQueueHistorySnapshot( const keepWaveform = prior.currentTrack?.id != null && nextTrack?.id != null && - sameQueueTrackId(prior.currentTrack.id, nextTrack.id); + sameQueueTrack(prior.currentTrack, nextTrack); const norm = nextTrack != null ? deriveNormalizationSnapshot(nextTrack, nextQueue, nextIndex) @@ -155,7 +162,9 @@ export function applyQueueHistorySnapshot( PlayerState, 'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive' >); - const playbackSid = getPlaybackServerId(); + const playbackSid = nextTrack + ? playbackProfileIdForTrack(nextTrack, nextItems[nextIndex]) + : getPlaybackServerId(); const playbackSourceUndo = nextTrack ? getPlaybackSourceKind(nextTrack.id, playbackSid, null) : null; @@ -187,7 +196,8 @@ export function applyQueueHistorySnapshot( // source as the prepend above — keeps cache bucket and ref serverId in lockstep // even when a server switch races the undo. const seedSid = canonicalQueueServerKey( - snap.queueServerId + nextTrack?.serverId + ?? snap.queueServerId ?? snap.queueItems[0]?.serverId ?? get().queueServerId ?? '', diff --git a/src/features/playback/store/applyServerPlayQueue.ts b/src/features/playback/store/applyServerPlayQueue.ts index 4a5d84c8..77a998c4 100644 --- a/src/features/playback/store/applyServerPlayQueue.ts +++ b/src/features/playback/store/applyServerPlayQueue.ts @@ -21,6 +21,7 @@ import { clearQueueNaturallyEnded, } from '@/features/playback/store/queuePlaybackIdle'; import { clearQueueHandoffPending } from '@/features/playback/store/queueSyncUiState'; +import { sameQueueTrack } from '@/features/playback/utils/playback/queueIdentity'; export type ApplyPlayQueueMode = 'startup' | 'idle' | 'manual'; @@ -111,7 +112,7 @@ export function applyMappedQueue( const player = usePlayerStore.getState(); const wasPlaying = player.isPlaying; - const sameCurrent = player.currentTrack?.id === currentTrack.id; + const sameCurrent = sameQueueTrack(player.currentTrack, currentTrack); usePlayerStore.setState({ queueItems, diff --git a/src/features/playback/store/audioEventHandlers.ts b/src/features/playback/store/audioEventHandlers.ts index c0d3fe78..11df9833 100644 --- a/src/features/playback/store/audioEventHandlers.ts +++ b/src/features/playback/store/audioEventHandlers.ts @@ -39,7 +39,7 @@ import { getGaplessPreloadingId, getLastGaplessSwitchTime, markGaplessSwitch, - setBytePreloadingId, + setBytePreloadingRequest, } from '@/features/playback/store/gaplessPreloadState'; import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh'; import { @@ -89,6 +89,10 @@ import { isInterruptHandoffPending } from '@/features/playback/utils/playback/au import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from '@/features/playback/store/crossfadePreload'; import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from '@/features/playback/store/crossfadeTrimCache'; import { armAutodjMixing } from '@/features/playback/store/autodjTransitionUi'; +import { + queueItemIdentityKey, + sameQueueTrack, +} from '@/features/playback/utils/playback/queueIdentity'; // Silence-aware crossfade (A-tail): guards the early advance to once per play // generation so a single playback instance triggers at most one trim-advance @@ -287,10 +291,11 @@ export function handleAudioProgress( ? nextQueueRefForTransition(store.queueItems, store.queueIndex, store.repeatMode) : null; const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined; + const nextTrackIdentity = nextRef ? queueItemIdentityKey(nextRef) : undefined; if (trimActive && store.isPlaying && store.repeatMode !== 'one') { if (nextTrackId) { const cf = clampCrossfadeSecs(crossfadeSecs); - const plan = getCrossfadeTransition(nextTrackId); + const plan = getCrossfadeTransition(nextTrackIdentity ?? nextTrackId); let contentOverlap: number; // Scenario A: does A carry its own recorded fade-out? If so we let it ride // at full engine gain (no double fade) and bring B up underneath. @@ -325,7 +330,7 @@ export function handleAudioProgress( ) ) { crossfadeTrimAdvanceGen = gen; - armCrossfadeDynamicOverlap(nextTrackId, overlapSec, outgoingFadeSec); + armCrossfadeDynamicOverlap(nextTrackIdentity ?? nextTrackId, overlapSec, outgoingFadeSec); armAutodjMixing(overlapSec); store.next(false); return; @@ -370,7 +375,7 @@ export function handleAudioProgress( ? track : (nextRef ? resolveQueueTrack(nextRef) : null); - if (nextTrack && nextTrack.id !== track.id) { + if (nextTrack && !sameQueueTrack(nextTrack, track)) { // Gapless backup: keep next-track bytes ready even if chain/decode misses // the boundary. Start earlier for larger files / slower conservative link. const estBytes = (() => { @@ -389,6 +394,7 @@ export function handleAudioProgress( gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0; const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey(); + const nextIdentity = nextRef ? queueItemIdentityKey(nextRef) : nextTrack.id; const analysisServerId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackIndexKey(); @@ -397,9 +403,9 @@ export function handleAudioProgress( // Byte pre-download — gapless backup; runs early so bytes are ready by chain time. if ( shouldBytePreloadForGaplessBackup - && nextTrack.id !== getBytePreloadingId() + && nextIdentity !== getBytePreloadingId() ) { - setBytePreloadingId(nextTrack.id); + setBytePreloadingRequest(nextIdentity, nextUrl); // 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 }); @@ -421,7 +427,7 @@ export function handleAudioProgress( } // Gapless chain — decode + chain into Sink 30s before track boundary. - if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) { + if (shouldChainGapless && nextIdentity !== getGaplessPreloadingId()) { requestGaplessChainPreload({ currentTrack: track, nextTrack, diff --git a/src/features/playback/store/audioListenerSetup/audioEngineListeners.ts b/src/features/playback/store/audioListenerSetup/audioEngineListeners.ts index 81dc6834..e838e771 100644 --- a/src/features/playback/store/audioListenerSetup/audioEngineListeners.ts +++ b/src/features/playback/store/audioListenerSetup/audioEngineListeners.ts @@ -1,7 +1,10 @@ import { listen } from '@tauri-apps/api/event'; import { streamUrlTrackId } from '@/features/playback/utils/playback/resolvePlaybackUrl'; import { normalizationAlmostEqual } from '@/features/playback/utils/audio/normalizationCompare'; -import { normalizeAnalysisTrackId } from '@/features/playback/utils/playback/queueIdentity'; +import { + normalizeAnalysisTrackId, + queueIdentityContainsTrackId, +} from '@/features/playback/utils/playback/queueIdentity'; import { handleAudioEnded, handleAudioError, @@ -26,7 +29,11 @@ import { import { usePlayerStore } from '@/features/playback/store/playerStore'; import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh'; import { bumpWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen'; -import { setBytePreloadingId } from '@/features/playback/store/gaplessPreloadState'; +import { + getBytePreloadingId, + getBytePreloadingUrl, + setBytePreloadingId, +} from '@/features/playback/store/gaplessPreloadState'; type PreloadEventPayload = { url: string; @@ -174,7 +181,16 @@ export function setupAudioEngineListeners(): () => void { prevEnginePreloadedTrackId: usePlayerStore.getState().enginePreloadedTrackId, }); } - if (tid) usePlayerStore.setState({ enginePreloadedTrackId: tid }); + if (tid) { + const pendingIdentity = getBytePreloadingId(); + const pendingUrl = getBytePreloadingUrl(); + usePlayerStore.setState({ + enginePreloadedTrackId: pendingUrl === payload.url + && queueIdentityContainsTrackId(pendingIdentity, tid) + ? pendingIdentity + : tid, + }); + } else if (import.meta.env.DEV) { console.warn('[psysonic][preload-ready] could not parse track id from payload URL'); } @@ -183,7 +199,10 @@ export function setupAudioEngineListeners(): () => void { if (import.meta.env.DEV) { console.info('[psysonic][preload-cancelled]', payload); } - setBytePreloadingId(null); + const pendingUrl = getBytePreloadingUrl(); + if (!pendingUrl || pendingUrl === payload.url) { + setBytePreloadingId(null); + } }), ]; diff --git a/src/features/playback/store/audioListenerSetup/discordPresence.ts b/src/features/playback/store/audioListenerSetup/discordPresence.ts index 649ea746..e98ea571 100644 --- a/src/features/playback/store/audioListenerSetup/discordPresence.ts +++ b/src/features/playback/store/audioListenerSetup/discordPresence.ts @@ -6,6 +6,7 @@ import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackP import { resolveServerCoverForDiscord } from '@/cover/integrations/discord'; import { serverShareBaseUrl } from '@/lib/server/serverEndpoint'; import { playbackServerDiffersFromActive } from '@/features/playback/utils/playback/playbackServer'; +import { ownedEntityKey } from '@/lib/util/ownedEntityKey'; /** * Discord Rich Presence sync. Updates on track change or play/pause toggle — @@ -13,7 +14,7 @@ import { playbackServerDiffersFromActive } from '@/features/playback/utils/playb * start_timestamp we set. Returns a cleanup function. */ export function setupDiscordPresence(): () => void { - let discordPrevTrackId: string | null = null; + let discordPrevTrackKey: string | null = null; let discordPrevIsPlaying: boolean | null = null; let discordPrevTemplateDetails: string | null = null; let discordPrevTemplateState: string | null = null; @@ -37,8 +38,8 @@ export function setupDiscordPresence(): () => void { } = useAuthStore.getState(); if (!discordRichPresence || !currentTrack) { - if (discordPrevTrackId !== null) { - discordPrevTrackId = null; + if (discordPrevTrackKey !== null) { + discordPrevTrackKey = null; discordPrevIsPlaying = null; discordPrevCoverSource = null; discordPrevShareBase = null; @@ -59,7 +60,8 @@ export function setupDiscordPresence(): () => void { const profile = servers.find(s => s.id === activeServerId); const shareBase = profile ? serverShareBaseUrl(profile) : null; - const trackChanged = currentTrack.id !== discordPrevTrackId; + const currentTrackKey = ownedEntityKey(currentTrack); + const trackChanged = currentTrackKey !== discordPrevTrackKey; const playingChanged = isPlaying !== discordPrevIsPlaying; const coverSourceChanged = discordCoverSource !== discordPrevCoverSource; const shareBaseChanged = discordCoverSource === 'server' && shareBase !== discordPrevShareBase; @@ -69,7 +71,7 @@ export function setupDiscordPresence(): () => void { const nameTemplateChanged = discordTemplateName !== discordPrevTemplateName; if (!trackChanged && !playingChanged && !coverSourceChanged && !shareBaseChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged && !nameTemplateChanged) return; - discordPrevTrackId = currentTrack.id; + discordPrevTrackKey = currentTrackKey; discordPrevIsPlaying = isPlaying; discordPrevCoverSource = discordCoverSource; discordPrevShareBase = shareBase; @@ -108,13 +110,14 @@ export function setupDiscordPresence(): () => void { // for that album id. Skip the server lookup — and fall back to the app // icon — for that case rather than risk a wrong or 404ing cover. if (discordCoverSource === 'server' && currentTrack.albumId && !playbackServerDiffersFromActive()) { - const trackId = currentTrack.id; + const trackKey = currentTrackKey; void resolveServerCoverForDiscord(currentTrack.albumId, shareBase).then(url => { // Staleness guard: the resolve is async — drop it if playback moved on, // Rich Presence got disabled, or the cover source changed away from // 'server' while the request was in flight. const latest = useAuthStore.getState(); - if (usePlayerStore.getState().currentTrack?.id !== trackId) return; + const liveTrack = usePlayerStore.getState().currentTrack; + if (!liveTrack || ownedEntityKey(liveTrack) !== trackKey) return; if (!latest.discordRichPresence || latest.discordCoverSource !== 'server') return; sendPresence(url); }); diff --git a/src/features/playback/store/crossfadePreload.ts b/src/features/playback/store/crossfadePreload.ts index 62ebff9d..361b8112 100644 --- a/src/features/playback/store/crossfadePreload.ts +++ b/src/features/playback/store/crossfadePreload.ts @@ -12,10 +12,19 @@ import { markPlannedCrossfade, setCrossfadeTransition, } from '@/features/playback/store/crossfadeTrimCache'; -import { getBytePreloadingId, setBytePreloadingId } from '@/features/playback/store/gaplessPreloadState'; +import { + getBytePreloadingId, + setBytePreloadingRequest, +} from '@/features/playback/store/gaplessPreloadState'; import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { fetchWaveformBins } from '@/features/playback/store/waveformRefresh'; +import { + queueItemIdentityKey, + queueTrackIdentityKey, + queueTrackIdentityMatches, + sameQueueTrack, +} from '@/features/playback/utils/playback/queueIdentity'; // Crossfade pre-buffer budget: begin downloading the next track this many // seconds before it needs to play (the crossfade start), so a large lossless @@ -39,7 +48,11 @@ export function isCrossfadeNextReady( cacheKey: string | null, ): boolean { if (!trackId) return false; - if (usePlayerStore.getState().enginePreloadedTrackId === trackId) return true; + if (queueTrackIdentityMatches( + usePlayerStore.getState().enginePreloadedTrackId, + trackId, + cacheKey || profileId, + )) return true; for (const sid of [profileId, cacheKey]) { if (!sid) continue; if ( @@ -69,10 +82,11 @@ export function kickEagerCrossfadePreload( cacheKey: string | null, ): void { if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return; - if (track.id === getBytePreloadingId()) return; const serverId = cacheKey || profileId; + const preloadIdentity = queueTrackIdentityKey(track.id, serverId); + if (getBytePreloadingId() === preloadIdentity) return; const url = resolvePlaybackUrlForTrack(track, serverId ?? undefined); - setBytePreloadingId(track.id); + setBytePreloadingRequest(preloadIdentity, url); void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false }); audioPreload({ url, @@ -145,16 +159,17 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi : (repeatMode === 'all' && queueItems.length > 0 ? queueItems[0] : null); if (!nextRef) return; const nextTrack = resolveQueueTrack(nextRef); - if (!nextTrack || nextTrack.id === track.id) return; + if (!nextTrack || sameQueueTrack(nextTrack, track)) return; const serverId = playbackCacheKeyForRef(nextRef); + const nextIdentity = queueItemIdentityKey(nextRef); const nextUrl = resolvePlaybackUrlForTrack(nextTrack, serverId); // Byte pre-download — skipped when the hot cache is on (it already keeps the // upcoming queue on disk, which is also why hot cache makes the trim reliable: // the next track is local → seekable → starts instantly past its lead silence). - if (!hotCacheEnabled && nextTrack.id !== getBytePreloadingId()) { - setBytePreloadingId(nextTrack.id); + if (!hotCacheEnabled && nextIdentity !== getBytePreloadingId()) { + setBytePreloadingRequest(nextIdentity, nextUrl); // Loudness cache only — never refreshWaveformForTrack(next): it writes the // global waveformBins and would replace the current track's seekbar. void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false }); @@ -177,8 +192,9 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi // so it runs regardless of hot cache (which otherwise skips the byte // pre-download). Cold/un-analysed tracks fall back to a fixed overlap + no // head trim → today's behaviour. - if (crossfadeTrimSilence && !hasPlannedCrossfade(nextTrack.id)) { - markPlannedCrossfade(nextTrack.id); + if (crossfadeTrimSilence && !hasPlannedCrossfade(nextIdentity)) { + markPlannedCrossfade(nextIdentity); + const planIdentity = nextIdentity; const planTrackId = nextTrack.id; const planDuration = nextTrack.duration; const curBins = store.waveformBins; @@ -188,7 +204,7 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi // user's crossfadeSecs is intentionally not a factor in this mode. const maxOverlapSec = autodjMaxOverlapCapSec(useAuthStore.getState()); const plan = planCrossfadeTransition(curBins, dur, nextBins, planDuration, { maxOverlapSec }); - setCrossfadeTransition(planTrackId, plan); + setCrossfadeTransition(planIdentity, plan); }) .catch(() => {}); } diff --git a/src/features/playback/store/engineLoadTrackAtPosition.ts b/src/features/playback/store/engineLoadTrackAtPosition.ts index 8af87e95..d174bb56 100644 --- a/src/features/playback/store/engineLoadTrackAtPosition.ts +++ b/src/features/playback/store/engineLoadTrackAtPosition.ts @@ -15,6 +15,7 @@ import { touchHotCacheOnPlayback } from '@/features/playback/store/hotCacheTouch import { isReplayGainActive, loudnessGainDbForEngineBind } from '@/features/playback/store/loudnessGainCache'; import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from '@/features/playback/store/playbackUrlRouting'; import { usePlayerStore } from '@/features/playback/store/playerStore'; +import { queueTrackIdentityMatches } from '@/features/playback/utils/playback/queueIdentity'; /** * Load a track into the Rust engine at `atSeconds`, optionally leaving transport @@ -45,7 +46,11 @@ export function engineLoadTrackAtPosition(opts: { usePlayerStore.setState({ currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url), }); - const keepPreloadHint = usePlayerStore.getState().enginePreloadedTrackId === track.id; + const keepPreloadHint = queueTrackIdentityMatches( + usePlayerStore.getState().enginePreloadedTrackId, + track.id, + playbackCacheSid, + ); const startPaused = !wantPlaying; setDeferHotCachePrefetch(true); invoke('audio_play', { diff --git a/src/features/playback/store/gaplessChainPreload.test.ts b/src/features/playback/store/gaplessChainPreload.test.ts index 7e0ed289..463c8cca 100644 --- a/src/features/playback/store/gaplessChainPreload.test.ts +++ b/src/features/playback/store/gaplessChainPreload.test.ts @@ -11,6 +11,7 @@ import { _resetGaplessChainPrepareInflightForTest, requestGaplessChainPreload, } from '@/features/playback/store/gaplessChainPreload'; +import { queueItemIdentityKey } from '@/features/playback/utils/playback/queueIdentity'; const prepareMock = vi.hoisted(() => vi.fn(async (track: Track) => ({ @@ -83,7 +84,7 @@ describe('requestGaplessChainPreload', () => { }); await vi.waitFor(() => { - expect(getGaplessPreloadingId()).toBe('t2'); + expect(getGaplessPreloadingId()).toBe(queueItemIdentityKey(ref('t2'))); }); expect(prepareMock).toHaveBeenCalledWith( diff --git a/src/features/playback/store/gaplessChainPreload.ts b/src/features/playback/store/gaplessChainPreload.ts index 6e9e2999..d622795e 100644 --- a/src/features/playback/store/gaplessChainPreload.ts +++ b/src/features/playback/store/gaplessChainPreload.ts @@ -21,6 +21,13 @@ import { import { refreshLoudnessForTrack } from '@/features/playback/store/loudnessRefresh'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useAuthStore } from '@/store/authStore'; +import { + queueItemIdentityKey, + queueItemRefMatchesTrack, + queueTrackIdentityKey, + sameQueueItemRef, + sameQueueTrack, +} from '@/features/playback/utils/playback/queueIdentity'; export type GaplessChainPreloadContext = { currentTrack: Track; @@ -96,8 +103,11 @@ function liveNextRefForContext( */ export function requestGaplessChainPreload(ctx: GaplessChainPreloadContext): void { const { nextTrack } = ctx; - if (getGaplessPreloadingId() === nextTrack.id) return; - if (gaplessChainPrepareInflight.has(nextTrack.id)) return; + const preloadIdentity = ctx.nextRef + ? queueItemIdentityKey(ctx.nextRef) + : queueTrackIdentityKey(nextTrack.id, nextTrack.serverId); + if (getGaplessPreloadingId() === preloadIdentity) return; + if (gaplessChainPrepareInflight.has(preloadIdentity)) return; const job = (async () => { try { @@ -113,17 +123,20 @@ export function requestGaplessChainPreload(ctx: GaplessChainPreloadContext): voi const store = usePlayerStore.getState(); if (!store.isPlaying || store.currentRadio) return; - if (store.currentTrack?.id !== ctx.currentTrack.id) return; + if (!sameQueueTrack(store.currentTrack, ctx.currentTrack)) return; const liveNextRef = liveNextRefForContext( store.queueItems, store.queueIndex, store.repeatMode, ); - if (liveNextRef?.trackId !== prepared.id) return; - if (getGaplessPreloadingId() === prepared.id) return; + if (!liveNextRef) return; + if (ctx.nextRef + ? !sameQueueItemRef(liveNextRef, ctx.nextRef) + : !queueItemRefMatchesTrack(liveNextRef, prepared)) return; + if (getGaplessPreloadingId() === preloadIdentity) return; - setGaplessPreloadingId(prepared.id); + setGaplessPreloadingId(preloadIdentity); invokeGaplessChainPreload(prepared, { ...ctx, currentTrack: store.currentTrack ?? ctx.currentTrack, @@ -131,10 +144,10 @@ export function requestGaplessChainPreload(ctx: GaplessChainPreloadContext): voi nextRef: liveNextRef, }); } finally { - gaplessChainPrepareInflight.delete(nextTrack.id); + gaplessChainPrepareInflight.delete(preloadIdentity); } })().catch(() => {}); - gaplessChainPrepareInflight.set(nextTrack.id, job); + gaplessChainPrepareInflight.set(preloadIdentity, job); } /** Test-only: drop pending gapless chain prepare promises. */ diff --git a/src/features/playback/store/gaplessPreloadState.test.ts b/src/features/playback/store/gaplessPreloadState.test.ts index f453c752..b4291ed9 100644 --- a/src/features/playback/store/gaplessPreloadState.test.ts +++ b/src/features/playback/store/gaplessPreloadState.test.ts @@ -1,5 +1,5 @@ /** - * Three mutables that coordinate the gapless preloader. Most of the surface + * Preload identity, URL and switch-time mutables coordinate the gapless preloader. Most of the surface * is straight get/set — the interesting bit is `clearPreloadingIds` (atomic * clear of both) and `markGaplessSwitch` (timestamp side effect). */ @@ -8,10 +8,12 @@ import { _resetGaplessPreloadStateForTest, clearPreloadingIds, getBytePreloadingId, + getBytePreloadingUrl, getGaplessPreloadingId, getLastGaplessSwitchTime, markGaplessSwitch, setBytePreloadingId, + setBytePreloadingRequest, setGaplessPreloadingId, } from '@/features/playback/store/gaplessPreloadState'; @@ -29,6 +31,7 @@ describe('initial state', () => { it('is null / 0 for unread accessors', () => { expect(getGaplessPreloadingId()).toBeNull(); expect(getBytePreloadingId()).toBeNull(); + expect(getBytePreloadingUrl()).toBeNull(); expect(getLastGaplessSwitchTime()).toBe(0); }); }); @@ -44,6 +47,14 @@ describe('preloading-id accessors', () => { expect(getBytePreloadingId()).toBe('t2'); }); + it('keeps the requested URL beside the server-qualified byte identity', () => { + setBytePreloadingRequest('["srv","t2"]', 'https://srv/stream?id=t2'); + expect(getBytePreloadingId()).toBe('["srv","t2"]'); + expect(getBytePreloadingUrl()).toBe('https://srv/stream?id=t2'); + setBytePreloadingId(null); + expect(getBytePreloadingUrl()).toBeNull(); + }); + it('keeps the two guards independent', () => { setGaplessPreloadingId('a'); setBytePreloadingId('b'); @@ -65,6 +76,7 @@ describe('clearPreloadingIds', () => { clearPreloadingIds(); expect(getGaplessPreloadingId()).toBeNull(); expect(getBytePreloadingId()).toBeNull(); + expect(getBytePreloadingUrl()).toBeNull(); }); it('does not touch the gapless-switch timestamp', () => { diff --git a/src/features/playback/store/gaplessPreloadState.ts b/src/features/playback/store/gaplessPreloadState.ts index d6c07217..bebd8ba2 100644 --- a/src/features/playback/store/gaplessPreloadState.ts +++ b/src/features/playback/store/gaplessPreloadState.ts @@ -3,8 +3,8 @@ * the same track twice or fire a chain-switch while a previous one is * still settling. * - * - `gaplessPreloadingId` — track id last handed to `audio_chain_preload` - * - `bytePreloadingId` — track id last handed to `audio_preload` + * - `gaplessPreloadingId` — queue identity last handed to `audio_chain_preload` + * - `bytePreloadingId` — queue identity last handed to `audio_preload` * - `lastGaplessSwitchTime` — timestamp of the last gapless track-switch * event from Rust. The 500–600 ms guards in `handleAudioTrackSwitched` * and the progress handler use this to suppress stale IPC arriving @@ -16,6 +16,7 @@ let gaplessPreloadingId: string | null = null; let bytePreloadingId: string | null = null; +let bytePreloadingUrl: string | null = null; let lastGaplessSwitchTime = 0; export function getGaplessPreloadingId(): string | null { @@ -32,12 +33,23 @@ export function getBytePreloadingId(): string | null { export function setBytePreloadingId(id: string | null): void { bytePreloadingId = id; + if (id == null) bytePreloadingUrl = null; +} + +export function getBytePreloadingUrl(): string | null { + return bytePreloadingUrl; +} + +export function setBytePreloadingRequest(id: string, url: string): void { + bytePreloadingId = id; + bytePreloadingUrl = url; } /** Atomic: clear both preloading guards. Called on track switch + on errors. */ export function clearPreloadingIds(): void { gaplessPreloadingId = null; bytePreloadingId = null; + bytePreloadingUrl = null; } export function getLastGaplessSwitchTime(): number { @@ -53,5 +65,6 @@ export function markGaplessSwitch(): void { export function _resetGaplessPreloadStateForTest(): void { gaplessPreloadingId = null; bytePreloadingId = null; + bytePreloadingUrl = null; lastGaplessSwitchTime = 0; } diff --git a/src/features/playback/store/gaplessQueueAdvance.test.ts b/src/features/playback/store/gaplessQueueAdvance.test.ts index a8f74193..4d7bc3d4 100644 --- a/src/features/playback/store/gaplessQueueAdvance.test.ts +++ b/src/features/playback/store/gaplessQueueAdvance.test.ts @@ -22,7 +22,7 @@ import { maybeReconcileGaplessFromProgress, } from '@/features/playback/store/gaplessQueueAdvance'; -const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId }); +const ref = (trackId: string, serverId = 's1'): QueueItemRef => ({ serverId, trackId }); const track = (id: string, extra: Partial = {}): Track => ({ id, @@ -65,6 +65,24 @@ describe('applyGaplessQueueAdvance', () => { expect(getPlaybackProgressSnapshot().currentTime).toBe(0); expect(getPlaybackProgressSnapshot().progress).toBe(0); }); + + it('advances between equal raw ids owned by different servers', () => { + const a = track('shared', { serverId: 'srv-a' }); + const b = track('shared', { serverId: 'srv-b' }); + seedQueueResolver('srv-a', [a]); + seedQueueResolver('srv-b', [b]); + usePlayerStore.setState({ + currentTrack: a, + queueItems: [ref('shared', 'srv-a'), ref('shared', 'srv-b')], + queueIndex: 0, + }); + + const result = applyGaplessQueueAdvance({ engineDurationHint: 210, source: 'track-switched' }); + + expect(result.advanced).toBe(true); + expect(usePlayerStore.getState().queueIndex).toBe(1); + expect(usePlayerStore.getState().currentTrack?.serverId).toBe('srv-b'); + }); }); describe('maybeReconcileGaplessFromProgress', () => { diff --git a/src/features/playback/store/gaplessQueueAdvance.ts b/src/features/playback/store/gaplessQueueAdvance.ts index cdd93a30..836c7ffe 100644 --- a/src/features/playback/store/gaplessQueueAdvance.ts +++ b/src/features/playback/store/gaplessQueueAdvance.ts @@ -45,6 +45,10 @@ import { import { isSeekDebouncePending } from '@/features/playback/store/seekDebounce'; import { getSeekTarget } from '@/features/playback/store/seekTargetState'; import type { QueueItemRef, Track } from '@/lib/media/trackTypes'; +import { + queueItemRefMatchesTrack, + sameQueueTrack, +} from '@/features/playback/utils/playback/queueIdentity'; export type GaplessQueueAdvanceResult = { advanced: boolean; @@ -188,7 +192,7 @@ export function applyGaplessQueueAdvance(opts?: { ? { ...resolved, duration: hint } : resolved; - if (currentTrack && repeatMode !== 'one' && currentTrack.id === nextTrack.id && queueIndex === newIndex) { + if (currentTrack && repeatMode !== 'one' && sameQueueTrack(currentTrack, nextTrack) && queueIndex === newIndex) { return { advanced: false, nextTrack, newIndex }; } @@ -230,9 +234,9 @@ export function maybeReconcileGaplessFromProgress( store.repeatMode, store.currentTrack, ); - if (!nextTrack || nextTrack.id === store.currentTrack.id) return; + if (!nextTrack || sameQueueTrack(nextTrack, store.currentTrack)) return; const slotRef = store.queueItems[store.queueIndex]; - if (!slotRef || slotRef.trackId !== store.currentTrack.id) return; + if (!queueItemRefMatchesTrack(slotRef, store.currentTrack)) return; if (store.repeatMode !== 'one' && newIndex <= store.queueIndex) return; applyGaplessQueueAdvance({ diff --git a/src/features/playback/store/miscActions.ts b/src/features/playback/store/miscActions.ts index e3329c9b..eeb930ae 100644 --- a/src/features/playback/store/miscActions.ts +++ b/src/features/playback/store/miscActions.ts @@ -32,6 +32,8 @@ import { setSeekFallbackVisualTarget, } from '@/features/playback/store/seekFallbackState'; import { clearSeekTarget } from '@/features/playback/store/seekTargetState'; +import { sameQueueTrack } from '@/features/playback/utils/playback/queueIdentity'; +import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey'; type SetState = ( partial: Partial | ((state: PlayerState) => Partial), @@ -121,8 +123,9 @@ export function createMiscActions(set: SetState, get: GetState): Pick< const currentTime = getPlaybackProgressSnapshot().currentTime; if (currentTime > 3) { // Restart current track from the beginning. - const authState = useAuthStore.getState(); - const sid = authState.activeServerId ?? ''; + const sid = queueItems[queueIndex]?.serverId + ?? currentTrack?.serverId + ?? ''; if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) { setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() }); // No-arg queue: keep the canonical refs, restart in place. @@ -169,13 +172,13 @@ export function createMiscActions(set: SetState, get: GetState): Pick< reseedQueueForInstantMix: (track) => { const s = get(); - if (s.currentTrack?.id !== track.id) { + if (!sameQueueTrack(s.currentTrack, track)) { get().playTrack(track, [track]); return; } pushQueueUndoFromGetter(get); const wasPlaying = s.isPlaying; - const sid = s.queueServerId ?? ''; + const sid = canonicalQueueServerKey(track.serverId ?? s.queueServerId ?? ''); if (sid) seedQueueResolver(sid, [track]); const newItems = toQueueItemRefs(sid, [track]); set({ diff --git a/src/features/playback/store/playTrackAction.ts b/src/features/playback/store/playTrackAction.ts index f9f554ad..90ee3944 100644 --- a/src/features/playback/store/playTrackAction.ts +++ b/src/features/playback/store/playTrackAction.ts @@ -4,7 +4,13 @@ import { audioSeek } from '@/lib/api/audio'; import { getMusicNetworkRuntimeOrNull } from '@/music-network'; import { setDeferHotCachePrefetch } from '@/lib/cache/hotCacheGate'; import { orbitBulkGuard, orbitSnapshot } from '@/store/orbitRuntime'; -import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity'; +import { + queueItemRefMatchesTrack, + queueItemIdentityKey, + queueTrackIdentityKey, + queueTrackIdentityMatches, + sameQueueTrack, +} from '@/features/playback/utils/playback/queueIdentity'; import { computeAutodjManualBlendPlan, shouldAutodjInterruptBlend, @@ -130,7 +136,7 @@ export function runPlayTrack( if (!_orbitConfirmed && queue && queue.length > 1) { const current = get().queueItems; const sameAsCurrent = queue.length === current.length - && queue.every((t, i) => sameQueueTrackId(current[i]?.trackId, t.id)); + && queue.every((queueTrack, index) => queueItemRefMatchesTrack(current[index], queueTrack)); if (!sameAsCurrent) { void orbitBulkGuard(queue.length).then(ok => { if (!ok) return; @@ -163,9 +169,9 @@ export function runPlayTrack( const orbitRole = orbitSnapshot().role; if (orbitRole === 'host') { const currentItems = get().queueItems; - const currentTrackId = currentItems[get().queueIndex]?.trackId; - if (track.id !== currentTrackId) { - const existsAt = currentItems.findIndex(r => sameQueueTrackId(r.trackId, track.id)); + const currentRef = currentItems[get().queueIndex]; + if (!queueItemRefMatchesTrack(currentRef, track)) { + const existsAt = currentItems.findIndex(ref => queueItemRefMatchesTrack(ref, track)); if (existsAt >= 0) { // Re-jump within the existing queue: pass undefined so playTrack keeps // the canonical queueItems and just moves the index. @@ -193,7 +199,7 @@ export function runPlayTrack( const scopedTrackEarly = stampTrackServerId(track); if ( prevTrackForHistory - && !sameQueueTrackId(prevTrackForHistory.id, scopedTrackEarly.id) + && !sameQueueTrack(prevTrackForHistory, scopedTrackEarly) ) { appendTimelineLeaveTrack( prevTrackForHistory, @@ -210,10 +216,10 @@ export function runPlayTrack( return targetQueueIndex; } if (replacingEarly && queue) { - const i = queue.findIndex(t => sameQueueTrackId(t.id, scopedTrackEarly.id)); + const i = queue.findIndex(queueTrack => sameQueueTrack(queueTrack, scopedTrackEarly)); return i >= 0 ? i : 0; } - const i = stateBeforePlay.queueItems.findIndex(r => sameQueueTrackId(r.trackId, scopedTrackEarly.id)); + const i = stateBeforePlay.queueItems.findIndex(ref => queueItemRefMatchesTrack(ref, scopedTrackEarly)); return i >= 0 ? i : 0; })(); const playingRefEarly = replacingEarly ? undefined : stateBeforePlay.queueItems[playIdxEarly]; @@ -247,7 +253,7 @@ export function runPlayTrack( const skipFromTimeSec = state.currentTime; const outgoingWaveformBins = state.waveformBins; const prevTrack = state.currentTrack; - if (prevTrack?.id !== scopedTrack.id) { + if (!prevTrack || !sameQueueTrack(prevTrack, scopedTrack)) { setSeekFallbackTrackId(null); } const visualOnEntry = getSeekFallbackVisualTarget(); @@ -271,8 +277,8 @@ export function runPlayTrack( // track twice — breaking radio playback (issue #500). const matchesAt = (i: number): boolean => replacing - ? sameQueueTrackId(scopedQueue[i]?.id, scopedTrack.id) - : sameQueueTrackId(state.queueItems[i]?.trackId, scopedTrack.id); + ? sameQueueTrack(scopedQueue[i], scopedTrack) + : queueItemRefMatchesTrack(state.queueItems[i], scopedTrack); const explicitIdxValid = typeof targetQueueIndex === 'number' && targetQueueIndex >= 0 @@ -281,8 +287,8 @@ export function runPlayTrack( const idx = explicitIdxValid ? (targetQueueIndex as number) : replacing - ? scopedQueue.findIndex(t => sameQueueTrackId(t.id, scopedTrack.id)) - : state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, scopedTrack.id)); + ? scopedQueue.findIndex(queueTrack => sameQueueTrack(queueTrack, scopedTrack)) + : state.queueItems.findIndex(ref => queueItemRefMatchesTrack(ref, scopedTrack)); const playIdx = idx >= 0 ? idx : 0; const playingRef = replacing ? undefined : state.queueItems[playIdx]; const prevPlayingRef = replacing ? undefined : state.queueItems[state.queueIndex]; @@ -292,7 +298,7 @@ export function runPlayTrack( const nextPlaybackSid = playbackProfileIdForTrack(scopedTrack, playingRef) ?? ''; if ( prevTrack - && !sameQueueTrackId(prevTrack.id, scopedTrack.id) + && !sameQueueTrack(prevTrack, scopedTrack) && prevPlaybackSid && nextPlaybackSid && prevPlaybackSid !== nextPlaybackSid @@ -344,7 +350,7 @@ export function runPlayTrack( && !(playbackProfileId && hasLocalPersistentPlaybackBytes(scopedTrack.id, playbackProfileId)) && Boolean( prevTrack - && sameQueueTrackId(prevTrack.id, scopedTrack.id) + && sameQueueTrack(prevTrack, scopedTrack) && authState.hotCacheEnabled && getPlaybackCacheServerKey(), ); @@ -360,7 +366,11 @@ export function runPlayTrack( ?? resolvePlaybackUrlForTrack(trackForPlay, playbackCacheSid); recordEnginePlayUrl(trackForPlay.id, url); const preloadedTrackId = get().enginePreloadedTrackId; - const keepPreloadHint = preloadedTrackId === trackForPlay.id; + const keepPreloadHint = queueTrackIdentityMatches( + preloadedTrackId, + trackForPlay.id, + playbackCacheSid, + ); const playbackSourceHint = playbackSourceHintForResolvedUrl( trackForPlay.id, playbackCacheSid, @@ -396,11 +406,14 @@ export function runPlayTrack( seedQueueResolver(queueSid, [trackForPlay]); } - const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(trackForPlay.id); + const trackIdentity = playingRef + ? queueItemIdentityKey(playingRef) + : queueTrackIdentityKey(trackForPlay.id, playbackCacheSid); + const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(trackIdentity); const wantInterruptBlend = Boolean( shouldAutodjInterruptBlend(wasPlayingBeforeSkip, hasJsAutoHandoff) && prevTrack - && !sameQueueTrackId(prevTrack.id, trackForPlay.id), + && !sameQueueTrack(prevTrack, trackForPlay), ); const bReadyNow = isCrossfadeNextReady(trackForPlay.id, playbackSid, playbackCacheSid); /** Cold interrupt: engine still on A — don't swap player-bar metadata until handoff. */ @@ -469,7 +482,7 @@ export function runPlayTrack( setDeferHotCachePrefetch(true); if ( prevTrack - && !sameQueueTrackId(prevTrack.id, trackForPlay.id) + && !sameQueueTrack(prevTrack, trackForPlay) && authStateNow.hotCacheEnabled ) { const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef); @@ -501,8 +514,8 @@ export function runPlayTrack( && initialTime <= 0.05; const useManualBlend = manualBlend !== null; - const crossfadePlan = useTrimAuto ? getCrossfadeTransition(trackForPlay.id) : null; - const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(trackForPlay.id) : null; + const crossfadePlan = useTrimAuto ? getCrossfadeTransition(trackIdentity) : null; + const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(trackIdentity) : null; const crossfadeStartSecs = useManualBlend ? manualBlend.bStartSec : (crossfadePlan?.bStartSec ?? 0); diff --git a/src/features/playback/store/playerStore.events.test.ts b/src/features/playback/store/playerStore.events.test.ts index 6ae26f9c..769c3271 100644 --- a/src/features/playback/store/playerStore.events.test.ts +++ b/src/features/playback/store/playerStore.events.test.ts @@ -57,6 +57,12 @@ import { } from '@/test/mocks/tauri'; import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; +import { + _resetGaplessPreloadStateForTest, + getBytePreloadingId, + setBytePreloadingRequest, +} from '@/features/playback/store/gaplessPreloadState'; +import { queueTrackIdentityKey } from '@/features/playback/utils/playback/queueIdentity'; function stubPlaybackInvokes(): void { onInvoke('audio_play', () => undefined); @@ -77,6 +83,7 @@ beforeEach(() => { vi.useFakeTimers(); resetPlayerStore(); resetAuthStore(); + _resetGaplessPreloadStateForTest(); stubPlaybackInvokes(); cleanupListeners = initAudioListeners(); }); @@ -234,6 +241,28 @@ describe('audio:ended', () => { }); }); +describe('audio preload events', () => { + it('does not let a stale cancellation clear a newer owner-qualified request', () => { + const identity = queueTrackIdentityKey('shared', 'srv-b'); + const currentUrl = 'https://b.test/stream?id=shared'; + setBytePreloadingRequest(identity, currentUrl); + + emitTauriEvent('audio:preload-cancelled', { + url: 'https://a.test/stream?id=shared', + trackId: 'shared', + }); + + expect(getBytePreloadingId()).toBe(identity); + + emitTauriEvent('audio:preload-ready', { + url: currentUrl, + trackId: 'shared', + }); + + expect(usePlayerStore.getState().enginePreloadedTrackId).toBe(identity); + }); +}); + describe('initAudioListeners — listener lifecycle (regression §4.2)', () => { it('registers exactly one listener per audio:* channel', () => { // beforeEach already called initAudioListeners once. diff --git a/src/features/playback/store/playerStore.playbackActions.test.ts b/src/features/playback/store/playerStore.playbackActions.test.ts index 1a6d21d5..3e797668 100644 --- a/src/features/playback/store/playerStore.playbackActions.test.ts +++ b/src/features/playback/store/playerStore.playbackActions.test.ts @@ -55,7 +55,8 @@ vi.mock('@/store/orbitRuntime', async (importOriginal) => ({ import { usePlayerStore } from '@/features/playback/store/playerStore'; import { onInvoke, invokeMock } from '@/test/mocks/tauri'; import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; -import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; +import { makeServer, makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; +import { useAuthStore } from '@/store/authStore'; function stubPlaybackInvokes(): void { onInvoke('audio_play', () => undefined); @@ -237,6 +238,27 @@ describe('next', () => { }); }); +describe('mixed-server play selection', () => { + it('jumps to the matching owner when raw track ids collide', async () => { + const serverA = makeServer({ id: 'srv-a', url: 'https://a.test' }); + const serverB = makeServer({ id: 'srv-b', url: 'https://b.test' }); + useAuthStore.setState({ servers: [serverA, serverB], activeServerId: serverA.id }); + const a = makeTrack({ id: 'shared', serverId: serverA.id }); + const b = makeTrack({ id: 'shared', serverId: serverB.id }); + seedQueue([a, b], { index: 0, currentTrack: a }); + + usePlayerStore.getState().playTrack(b); + await vi.runAllTimersAsync(); + + const state = usePlayerStore.getState(); + expect(state.queueIndex).toBe(1); + expect(state.currentTrack).toEqual(expect.objectContaining({ + id: 'shared', + serverId: serverB.id, + })); + }); +}); + describe('previous', () => { it('restarts the current track when currentTime > 3 s', () => { const queue = makeTracks(3); diff --git a/src/features/playback/store/playerStore.queue.test.ts b/src/features/playback/store/playerStore.queue.test.ts index 3d1b4c4d..49a0ec73 100644 --- a/src/features/playback/store/playerStore.queue.test.ts +++ b/src/features/playback/store/playerStore.queue.test.ts @@ -40,12 +40,15 @@ import { onInvoke } from '@/test/mocks/tauri'; import { resetPlayerStore } from '@/test/helpers/storeReset'; import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories'; import { getCachedTrack } from '@/features/playback/store/queueTrackResolver'; +import { _resetRadioSessionStateForTest } from '@/features/playback/store/radioSessionState'; beforeEach(() => { resetPlayerStore(); + _resetRadioSessionStateForTest(); // `clearQueue` fires `invoke('audio_stop')`; every queue mutation triggers a // debounced `syncQueueToServer` we don't need to advance. onInvoke('audio_stop', () => undefined); + onInvoke('audio_update_replay_gain', () => undefined); }); afterEach(() => { @@ -227,6 +230,67 @@ describe('reorderQueue', () => { expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual([b.id, c.id, a.id]); expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b` }); + + it('follows the exact owner when equal raw ids exist on different servers', () => { + const a = makeTrack({ id: 'shared', serverId: 'srv-a' }); + const b = makeTrack({ id: 'shared', serverId: 'srv-b' }); + const c = makeTrack({ id: 'tail', serverId: 'srv-b' }); + seedQueue([a, b, c], { index: 1, currentTrack: b }); + + usePlayerStore.getState().reorderQueue(1, 2); + + const state = usePlayerStore.getState(); + expect(state.queueIndex).toBe(2); + expect(state.queueItems[state.queueIndex]).toEqual(expect.objectContaining({ + serverId: 'srv-b', + trackId: 'shared', + })); + }); +}); + +describe('mixed-server queue identity', () => { + it('prunes after the current owner, not the first equal raw id', () => { + const a = makeTrack({ id: 'shared', serverId: 'srv-a' }); + const b = makeTrack({ id: 'shared', serverId: 'srv-b' }); + const tail = makeTrack({ id: 'tail', serverId: 'srv-b' }); + seedQueue([a, b, tail], { index: 1, currentTrack: b }); + + usePlayerStore.getState().pruneUpcomingToCurrent(); + + expect(usePlayerStore.getState().queueItems).toEqual([ + expect.objectContaining({ serverId: 'srv-a', trackId: 'shared' }), + expect.objectContaining({ serverId: 'srv-b', trackId: 'shared' }), + ]); + expect(usePlayerStore.getState().queueIndex).toBe(1); + }); + + it('keeps the current owner at the front when shuffling the whole queue', () => { + const a = makeTrack({ id: 'shared', serverId: 'srv-a' }); + const b = makeTrack({ id: 'shared', serverId: 'srv-b' }); + const tail = makeTrack({ id: 'tail', serverId: 'srv-b' }); + seedQueue([a, b, tail], { index: 1, currentTrack: b }); + + usePlayerStore.getState().shuffleQueue(); + + expect(usePlayerStore.getState().queueItems[0]).toEqual(expect.objectContaining({ + serverId: 'srv-b', + trackId: 'shared', + })); + expect(usePlayerStore.getState().queueIndex).toBe(0); + }); + + it('does not dedupe a radio track against the same raw id on another server', () => { + const a = makeTrack({ id: 'shared', serverId: 'srv-a' }); + const b = makeTrack({ id: 'shared', serverId: 'srv-b', radioAdded: true }); + seedQueue([a], { index: 0, currentTrack: a }); + + usePlayerStore.getState().enqueueRadio([b], 'artist'); + + expect(usePlayerStore.getState().queueItems).toEqual([ + expect.objectContaining({ serverId: 'srv-a', trackId: 'shared' }), + expect.objectContaining({ serverId: 'srv-b', trackId: 'shared', radioAdded: true }), + ]); + }); }); describe('removeTrack', () => { @@ -298,4 +362,21 @@ describe('undo / redo', () => { expect(usePlayerStore.getState().redoLastQueueEdit()).toBe(false); }); + + it('restores the current owner when equal raw ids exist on different servers', () => { + const a = makeTrack({ id: 'shared', serverId: 'srv-a' }); + const b = makeTrack({ id: 'shared', serverId: 'srv-b' }); + seedQueue([a, b], { index: 1, currentTrack: b }); + usePlayerStore.getState().enqueue([makeTrack({ id: 'tail', serverId: 'srv-b' })], true); + + expect(usePlayerStore.getState().undoLastQueueEdit()).toBe(true); + + const state = usePlayerStore.getState(); + expect(state.queueIndex).toBe(1); + expect(state.currentTrack?.serverId).toBe('srv-b'); + expect(state.queueItems[state.queueIndex]).toEqual(expect.objectContaining({ + serverId: 'srv-b', + trackId: 'shared', + })); + }); }); diff --git a/src/features/playback/store/playerStoreTypes.ts b/src/features/playback/store/playerStoreTypes.ts index e0e44717..57567278 100644 --- a/src/features/playback/store/playerStoreTypes.ts +++ b/src/features/playback/store/playerStoreTypes.ts @@ -18,7 +18,8 @@ export interface PlayerState { /** Latches the source used to start the currently playing track. */ currentPlaybackSource: PlaybackSourceKind | null; /** - * Subsonic track id for which `audio_preload` finished into the engine RAM slot (see `audio:preload-ready`). + * Server-qualified queue identity for which `audio_preload` finished into the engine RAM slot. + * Legacy engine events may temporarily provide a raw Subsonic track id. * Cleared after a successful `audio_play` consumed that preload, or when starting another track. */ enginePreloadedTrackId: string | null; diff --git a/src/features/playback/store/queueMutationActions.ts b/src/features/playback/store/queueMutationActions.ts index 5b7708f3..1e05f329 100644 --- a/src/features/playback/store/queueMutationActions.ts +++ b/src/features/playback/store/queueMutationActions.ts @@ -34,6 +34,11 @@ import { shuffled, } from '@/features/playback/store/shuffleModeActions'; import { persistShuffleModeSnapshot } from '@/features/playback/store/shuffleModeStorage'; +import { + queueItemIdentityKey, + queueItemRefMatchesTrack, +} from '@/features/playback/utils/playback/queueIdentity'; +import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey'; type SetState = ( partial: Partial | ((state: PlayerState) => Partial), @@ -104,8 +109,9 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< pushQueueUndoFromGetter(get); let result: QueueItemRef[]; + const currentRef = items[queueIndex]; if (enabling) { - setShuffleOriginalOrder(items.map(r => r.trackId)); + setShuffleOriginalOrder(items.map(queueItemIdentityKey)); // Everything up to and including the current track stays put: the playing // track must not move, and already-played rows are history. result = [...items.slice(0, queueIndex + 1), ...shuffled(items.slice(queueIndex + 1))]; @@ -117,7 +123,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< persistShuffleModeSnapshot({ enabled: enabling, originalOrder: getShuffleOriginalOrder() }); const newIndex = currentTrack - ? Math.max(0, result.findIndex(r => r.trackId === currentTrack.id)) + ? Math.max(0, currentRef ? result.indexOf(currentRef) : result.findIndex(ref => queueItemRefMatchesTrack(ref, currentTrack))) : 0; set({ shuffleMode: enabling, queueItems: result, queueIndex: newIndex }); syncUserQueueMutationToServer(items, result, currentTrack, get().currentTime); @@ -163,7 +169,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< setCurrentRadioArtistId(artistId); } pushQueueUndoFromGetter(get); - ensureQueueServerPinned(); + ensureQueueServerPinned(tracks); set(state => { const items = itemsOf(state); // Drop all upcoming (not yet played) radio tracks — clicking "Start Radio" @@ -176,12 +182,12 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< const droppedRadioIds = items .slice(state.queueIndex + 1) .filter(r => r.radioAdded) - .map(r => r.trackId); + .map(queueItemIdentityKey); for (const id of droppedRadioIds) deleteRadioSessionSeen(id); // Capture surviving queue ids in the seen-set so the next radio top-up // can dedupe against the seed track + already-queued non-radio items. - for (const r of beforeAndCurrent) addRadioSessionSeen(r.trackId); - for (const r of upcoming) addRadioSessionSeen(r.trackId); + for (const r of beforeAndCurrent) addRadioSessionSeen(queueItemIdentityKey(r)); + for (const r of upcoming) addRadioSessionSeen(queueItemIdentityKey(r)); // Drop incoming tracks already seen earlier this session AND // intra-batch duplicates (top + similar Last.fm responses commonly // overlap). The seen-set is mutated inside the loop so a repeated @@ -189,8 +195,10 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< // the first occurrence (issue #500). const dedupedTracks: Track[] = []; for (const t of tracks) { - if (hasRadioSessionSeen(t.id)) continue; - addRadioSessionSeen(t.id); + const serverId = canonicalQueueServerKey(t.serverId ?? state.queueServerId ?? ''); + const identity = queueItemIdentityKey({ serverId, trackId: t.id }); + if (hasRadioSessionSeen(identity)) continue; + addRadioSessionSeen(identity); dedupedTracks.push(t); } seedIncoming(state, dedupedTracks); @@ -264,7 +272,10 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< // resolves even when it had not been in the cache window before. seedIncoming(s, [s.currentTrack]); const items = itemsOf(s); - const at = items.findIndex(r => r.trackId === s.currentTrack!.id); + const indexedRef = items[s.queueIndex]; + const at = queueItemRefMatchesTrack(indexedRef, s.currentTrack) + ? s.queueIndex + : items.findIndex(ref => queueItemRefMatchesTrack(ref, s.currentTrack)); const newItems = at >= 0 ? items.slice(0, at + 1) : toQueueItemRefs(s.queueServerId ?? '', [s.currentTrack!]); @@ -303,11 +314,16 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< const state = get(); const { queueIndex, currentTrack } = state; const previousItems = itemsOf(state); + const currentRef = previousItems[queueIndex]; const result = [...previousItems]; const [removed] = result.splice(startIndex, 1); result.splice(endIndex, 0, removed); let newIndex = queueIndex; - if (currentTrack) newIndex = result.findIndex(r => r.trackId === currentTrack.id); + if (currentTrack) { + newIndex = currentRef + ? result.indexOf(currentRef) + : result.findIndex(ref => queueItemRefMatchesTrack(ref, currentTrack)); + } set({ queueItems: result, queueIndex: Math.max(0, newIndex) }); syncUserQueueMutationToServer(previousItems, result, currentTrack, get().currentTime); }, @@ -318,7 +334,9 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< if (state.queueItems.length < 2) return; pushQueueUndoFromGetter(get); const items = itemsOf(state); - const currentIdx = currentTrack ? items.findIndex(r => r.trackId === currentTrack.id) : -1; + const currentIdx = currentTrack && queueItemRefMatchesTrack(items[state.queueIndex], currentTrack) + ? state.queueIndex + : currentTrack ? items.findIndex(ref => queueItemRefMatchesTrack(ref, currentTrack)) : -1; const others = items.filter((_, i) => i !== currentIdx); for (let i = others.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); diff --git a/src/features/playback/store/shuffleModeActions.test.ts b/src/features/playback/store/shuffleModeActions.test.ts index 3a905ce3..706cc221 100644 --- a/src/features/playback/store/shuffleModeActions.test.ts +++ b/src/features/playback/store/shuffleModeActions.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { QueueItemRef } from '@/lib/media/trackTypes'; import { restoreOriginalOrder, setShuffleOriginalOrder } from './shuffleModeActions'; import { createQueueMutationActions } from './queueMutationActions'; +import { queueItemIdentityKey } from '@/features/playback/utils/playback/queueIdentity'; vi.mock('@/features/playback/store/queueSync', () => ({ syncUserQueueMutationToServer: vi.fn(), @@ -16,7 +17,7 @@ vi.mock('@/features/playback/store/queueUndo', () => ({ pushQueueUndoFromGetter: vi.fn(), })); -const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId }); +const ref = (trackId: string, serverId = 's1'): QueueItemRef => ({ serverId, trackId }); const ids = (items: QueueItemRef[]) => items.map(i => i.trackId); describe('restoreOriginalOrder', () => { @@ -47,6 +48,17 @@ describe('restoreOriginalOrder', () => { expect(restored).toHaveLength(3); }); + it('restores equal raw ids to their original server-qualified positions', () => { + const a = ref('shared', 'srv-a'); + const b = ref('shared', 'srv-b'); + const restored = restoreOriginalOrder( + [b, a], + [queueItemIdentityKey(a), queueItemIdentityKey(b)], + ); + + expect(restored).toEqual([a, b]); + }); + it('never loses or duplicates a row', () => { const current = [ref('c'), ref('x'), ref('a'), ref('b')]; const restored = restoreOriginalOrder(current, ['a', 'b', 'c', 'ghost']); @@ -69,7 +81,7 @@ describe('toggleShuffleMode', () => { /** Minimal player-state stub: only what the action reads and writes. */ function harness(queue: string[], queueIndex: number, shuffleMode = false) { let state = { - queueItems: queue.map(ref), + queueItems: queue.map(trackId => ref(trackId)), queueIndex, shuffleMode, currentTrack: queue[queueIndex] ? { id: queue[queueIndex] } : null, @@ -128,7 +140,9 @@ describe('toggleShuffleMode', () => { h.toggleShuffleMode(); const stored = JSON.parse(window.localStorage.getItem('psysonic_shuffle_mode') ?? '{}'); expect(stored.enabled).toBe(true); - expect(stored.originalOrder).toEqual(['a', 'b', 'c']); + expect(stored.originalOrder).toEqual(['a', 'b', 'c'].map(trackId => ( + queueItemIdentityKey(ref(trackId)) + ))); h.toggleShuffleMode(); expect(window.localStorage.getItem('psysonic_shuffle_mode')).toBeNull(); diff --git a/src/features/playback/store/shuffleModeActions.ts b/src/features/playback/store/shuffleModeActions.ts index 37b9a649..eeaa6832 100644 --- a/src/features/playback/store/shuffleModeActions.ts +++ b/src/features/playback/store/shuffleModeActions.ts @@ -17,6 +17,10 @@ */ import type { QueueItemRef } from '@/lib/media/trackTypes'; +import { + queueItemIdentityKey, + queueTrackIdFromIdentity, +} from '@/features/playback/utils/playback/queueIdentity'; /** Module-level, like the other non-render queue state: the UI never reads it. */ let originalOrder: string[] = []; @@ -44,7 +48,7 @@ export function shuffled(items: T[]): T[] { } /** - * Reorders `items` back into `order`, matching by track id. + * Reorders `items` back into `order`, matching by server + track id. * * Ids can repeat (the same track may sit in the queue twice), so each id consumes * one ref at a time rather than filtering. Refs the remembered order does not @@ -53,19 +57,29 @@ export function shuffled(items: T[]): T[] { * original list, so there is no position to restore them to. */ export function restoreOriginalOrder(items: QueueItemRef[], order: string[]): QueueItemRef[] { - const pools = new Map(); + const identityPools = new Map(); + const legacyPools = new Map(); for (const ref of items) { - const pool = pools.get(ref.trackId); - if (pool) pool.push(ref); - else pools.set(ref.trackId, [ref]); + const identity = queueItemIdentityKey(ref); + const identityPool = identityPools.get(identity); + if (identityPool) identityPool.push(ref); + else identityPools.set(identity, [ref]); + + const legacyPool = legacyPools.get(ref.trackId); + if (legacyPool) legacyPool.push(ref); + else legacyPools.set(ref.trackId, [ref]); } const restored: QueueItemRef[] = []; - for (const trackId of order) { - const ref = pools.get(trackId)?.shift(); + const restoredSet = new Set(); + for (const identity of order) { + const legacyTrackId = queueTrackIdFromIdentity(identity) ?? identity; + const pool = identityPools.get(identity) ?? legacyPools.get(legacyTrackId); + let ref = pool?.shift(); + while (ref && restoredSet.has(ref)) ref = pool?.shift(); if (ref) restored.push(ref); + if (ref) restoredSet.add(ref); } - const restoredSet = new Set(restored); return [...restored, ...items.filter(ref => !restoredSet.has(ref))]; } diff --git a/src/features/playback/store/shuffleModeStorage.ts b/src/features/playback/store/shuffleModeStorage.ts index d682bad8..0e927b5b 100644 --- a/src/features/playback/store/shuffleModeStorage.ts +++ b/src/features/playback/store/shuffleModeStorage.ts @@ -8,7 +8,7 @@ * Shuffle physically reorders `queueItems`, so "next track" stays "the next one * in the list" for the gapless chain, the server play-queue and Orbit guests. * The price is that turning shuffle off has to put the queue back — hence the - * original order, remembered as track ids and persisted alongside the flag so it + * original order, remembered as queue identity keys and persisted alongside the flag so it * survives a restart while shuffle is still on. */ @@ -16,7 +16,7 @@ const STORAGE_KEY = 'psysonic_shuffle_mode'; export interface ShuffleModeSnapshot { enabled: boolean; - /** Track ids in their pre-shuffle order; empty when shuffle is off. */ + /** Server-qualified queue identities in pre-shuffle order; legacy snapshots contain raw track ids. */ originalOrder: string[]; } diff --git a/src/features/playback/store/startupPlayQueueReconcile.ts b/src/features/playback/store/startupPlayQueueReconcile.ts index 08551a68..7e550023 100644 --- a/src/features/playback/store/startupPlayQueueReconcile.ts +++ b/src/features/playback/store/startupPlayQueueReconcile.ts @@ -9,6 +9,7 @@ import { } from '@/features/playback/store/applyServerPlayQueue'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { filterQueueRefsForServerProfile } from '@/features/playback/utils/playback/trackServerScope'; +import { sameQueueTrack } from '@/features/playback/utils/playback/queueIdentity'; type StructuralQueue = { trackIds: string[]; @@ -64,7 +65,10 @@ function localSnapshotStillCurrent(snapshot: LocalQueueSnapshot): boolean { const state = usePlayerStore.getState(); if ( state.queueIndex !== snapshot.queueIndex - || state.currentTrack?.id !== snapshot.currentTrack?.id + || !( + state.currentTrack == null && snapshot.currentTrack == null + || sameQueueTrack(state.currentTrack, snapshot.currentTrack) + ) || state.isPlaying !== snapshot.isPlaying || (state.currentRadio?.id ?? null) !== snapshot.currentRadioId || state.queueItems.length !== snapshot.queueItems.length diff --git a/src/features/playback/store/waveformRefresh.ts b/src/features/playback/store/waveformRefresh.ts index b395007b..1c8dd15a 100644 --- a/src/features/playback/store/waveformRefresh.ts +++ b/src/features/playback/store/waveformRefresh.ts @@ -3,6 +3,11 @@ import { coerceWaveformBins } from '@/lib/waveform/waveformParse'; import { getPlaybackIndexKey } from '@/features/playback/utils/playback/playbackServer'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { getWaveformRefreshGen } from '@/features/playback/store/waveformRefreshGen'; +import { + queueItemIdentityKey, + queueTrackIdentityKey, + sameQueueTrackId, +} from '@/features/playback/utils/playback/queueIdentity'; /** Subsonic-server waveform-cache row as Rust hands it back. */ export type WaveformCachePayload = { @@ -48,13 +53,27 @@ export async function fetchWaveformBins( export async function refreshWaveformForTrack(trackId: string): Promise { if (!trackId) return; const gen = getWaveformRefreshGen(trackId); + const requestedServerId = getPlaybackIndexKey() || null; + const requestedIdentity = requestedServerId + ? queueTrackIdentityKey(trackId, requestedServerId) + : null; try { - const res = await commands.analysisGetWaveformForTrack(trackId, getPlaybackIndexKey() || null); + const res = await commands.analysisGetWaveformForTrack(trackId, requestedServerId); if (res.status === 'error') throw new Error(res.error); const row = res.data; if (getWaveformRefreshGen(trackId) !== gen) return; // Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour). - if (usePlayerStore.getState().currentTrack?.id !== trackId) return; + const state = usePlayerStore.getState(); + if (!sameQueueTrackId(state.currentTrack?.id, trackId)) return; + if (requestedIdentity) { + const currentRef = state.queueItems?.[state.queueIndex]; + const currentIdentity = currentRef + ? queueItemIdentityKey(currentRef) + : state.currentTrack + ? queueTrackIdentityKey(state.currentTrack.id, state.currentTrack.serverId) + : null; + if (currentIdentity !== requestedIdentity) return; + } const bins = row ? coerceWaveformBins(row.bins) : null; if (!bins || bins.length === 0) { usePlayerStore.setState({ diff --git a/src/features/playback/utils/playback/playSong.ts b/src/features/playback/utils/playback/playSong.ts index 5acf997d..10e54fb3 100644 --- a/src/features/playback/utils/playback/playSong.ts +++ b/src/features/playback/utils/playback/playSong.ts @@ -3,6 +3,7 @@ import { songToTrack } from '@/lib/media/songToTrack'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { fadeOut } from '@/features/playback/utils/playback/fadeOut'; import { shouldAutodjInterruptBlend } from '@/features/playback/utils/playback/autodjManualBlend'; +import { queueItemRefMatchesTrack } from '@/features/playback/utils/playback/queueIdentity'; /** * Play a single song. When `queue` is provided, surrounds the chosen song with that queue @@ -39,7 +40,7 @@ export async function enqueueAndPlay(song: SubsonicSong): Promise { usePlayerStore.setState({ volume }); } - if (!queueItems.some(r => r.trackId === track.id)) { + if (!queueItems.some(ref => queueItemRefMatchesTrack(ref, track))) { usePlayerStore.getState().enqueue([track]); } // playTrack with no queue arg uses the current state.queue, finds the track by id, diff --git a/src/features/playback/utils/playback/playbackServer.ts b/src/features/playback/utils/playback/playbackServer.ts index bc03e738..da717276 100644 --- a/src/features/playback/utils/playback/playbackServer.ts +++ b/src/features/playback/utils/playback/playbackServer.ts @@ -7,7 +7,7 @@ import { useAuthStore } from '@/store/authStore'; import type { ServerProfile } from '@/store/authStoreTypes'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { switchActiveServer } from '@/utils/server/switchActiveServer'; -import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity'; +import { queueItemRefMatchesTrack } from '@/features/playback/utils/playback/queueIdentity'; import type { QueueItemRef, Track } from '@/lib/media/trackTypes'; import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; import { @@ -248,5 +248,5 @@ export function shouldBindQueueServerForPlay( if (prevQueue.length === 0) return true; if (explicitQueueArg === undefined) return false; if (explicitQueueArg.length !== prevQueue.length) return true; - return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.trackId, t.id)); + return !explicitQueueArg.every((track, index) => queueItemRefMatchesTrack(prevQueue[index], track)); } diff --git a/src/features/playback/utils/playback/queueIdentity.test.ts b/src/features/playback/utils/playback/queueIdentity.test.ts index 9cfb6fbd..0cdb57fb 100644 --- a/src/features/playback/utils/playback/queueIdentity.test.ts +++ b/src/features/playback/utils/playback/queueIdentity.test.ts @@ -8,7 +8,13 @@ import type { Track } from '@/lib/media/trackTypes'; import { describe, expect, it } from 'vitest'; import { normalizeAnalysisTrackId, + queueIdentityContainsTrackId, + queueItemIdentityKey, + queueItemRefMatchesTrack, + queueTrackIdentityMatches, queuesStructuralEqual, + sameQueueItemRef, + sameQueueTrack, sameQueueTrackId, shallowCloneQueueTracks, } from '@/features/playback/utils/playback/queueIdentity'; @@ -60,6 +66,40 @@ describe('sameQueueTrackId', () => { }); }); +describe('mixed-server queue identity', () => { + it('distinguishes equal raw ids owned by different servers', () => { + const a = track('shared', { serverId: 'srv-a' }); + const b = track('shared', { serverId: 'srv-b' }); + + expect(sameQueueTrack(a, b)).toBe(false); + expect(queueItemRefMatchesTrack({ serverId: 'srv-a', trackId: 'shared' }, b)).toBe(false); + expect(sameQueueItemRef( + { serverId: 'srv-a', trackId: 'shared' }, + { serverId: 'srv-b', trackId: 'shared' }, + )).toBe(false); + }); + + it('normalizes stream ids inside composite keys', () => { + expect(queueItemIdentityKey({ serverId: 'srv-a', trackId: 'stream:shared' })) + .toBe(queueItemIdentityKey({ serverId: 'srv-a', trackId: 'shared' })); + }); + + it('matches composite preload identities without accepting another owner', () => { + const identity = queueItemIdentityKey({ serverId: 'srv-a', trackId: 'shared' }); + expect(queueTrackIdentityMatches(identity, 'shared', 'srv-a')).toBe(true); + expect(queueTrackIdentityMatches(identity, 'shared', 'srv-b')).toBe(false); + expect(queueIdentityContainsTrackId(identity, 'stream:shared')).toBe(true); + }); + + it('keeps raw-id compatibility when one side is legacy ownerless data', () => { + expect(sameQueueTrack(track('shared'), track('shared', { serverId: 'srv-a' }))).toBe(true); + expect(queueItemRefMatchesTrack( + { serverId: '', trackId: 'shared' }, + track('shared', { serverId: 'srv-a' }), + )).toBe(true); + }); +}); + describe('queuesStructuralEqual', () => { it('returns true for same ids in same order', () => { expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('b')])).toBe(true); @@ -80,6 +120,13 @@ describe('queuesStructuralEqual', () => { expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('c')])).toBe(false); }); + it('returns false for equal raw ids from different servers', () => { + expect(queuesStructuralEqual( + [track('shared', { serverId: 'srv-a' })], + [track('shared', { serverId: 'srv-b' })], + )).toBe(false); + }); + it('treats empty queues as equal', () => { expect(queuesStructuralEqual([], [])).toBe(true); }); diff --git a/src/features/playback/utils/playback/queueIdentity.ts b/src/features/playback/utils/playback/queueIdentity.ts index 48d1150f..7bb8539f 100644 --- a/src/features/playback/utils/playback/queueIdentity.ts +++ b/src/features/playback/utils/playback/queueIdentity.ts @@ -1,4 +1,5 @@ import type { QueueItemRef, Track } from '@/lib/media/trackTypes'; +import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey'; /** * Strip the `stream:` prefix that some Rust events attach to track ids when @@ -19,12 +20,86 @@ export function sameQueueTrackId(a: string | undefined | null, b: string | undef return na === nb; } +function normalizedQueueServerId(serverId?: string | null): string | null { + if (!serverId) return null; + return canonicalQueueServerKey(serverId) || serverId; +} + +/** Compare full tracks by owner + normalized id, with raw-id fallback for legacy ownerless tracks. */ +export function sameQueueTrack( + a: Pick | null | undefined, + b: Pick | null | undefined, +): boolean { + if (!a || !b || !sameQueueTrackId(a.id, b.id)) return false; + const aServerId = normalizedQueueServerId(a.serverId); + const bServerId = normalizedQueueServerId(b.serverId); + return !aServerId || !bServerId || aServerId === bServerId; +} + +/** Stable mixed-queue identity for in-memory sets and persisted shuffle order. */ +export function queueItemIdentityKey( + item: Pick, +): string { + return queueTrackIdentityKey(item.trackId, item.serverId); +} + +export function queueTrackIdentityKey(trackId: string, serverId?: string | null): string { + return JSON.stringify([ + normalizedQueueServerId(serverId) ?? '', + normalizeAnalysisTrackId(trackId) ?? trackId, + ]); +} + +/** Accepts legacy raw preload ids while preferring the server-qualified key. */ +export function queueTrackIdentityMatches( + identityOrTrackId: string | null | undefined, + trackId: string, + serverId?: string | null, +): boolean { + if (!identityOrTrackId) return false; + if (sameQueueTrackId(identityOrTrackId, trackId)) return true; + return identityOrTrackId === queueTrackIdentityKey(trackId, serverId); +} + +/** Match an engine event's raw id to a pending server-qualified preload key. */ +export function queueIdentityContainsTrackId( + identityOrTrackId: string | null | undefined, + trackId: string, +): boolean { + if (!identityOrTrackId) return false; + if (sameQueueTrackId(identityOrTrackId, trackId)) return true; + const identityTrackId = queueTrackIdFromIdentity(identityOrTrackId); + return identityTrackId != null && sameQueueTrackId(identityTrackId, trackId); +} + +export function queueTrackIdFromIdentity(identityOrTrackId: string): string | null { + try { + const parsed = JSON.parse(identityOrTrackId) as unknown; + return Array.isArray(parsed) && typeof parsed[1] === 'string' + ? parsed[1] + : identityOrTrackId; + } catch { + return identityOrTrackId; + } +} + +/** Compare a thin queue ref to a resolved track without treating raw ids as global. */ +export function queueItemRefMatchesTrack( + ref: Pick | null | undefined, + track: Pick | null | undefined, +): boolean { + if (!ref || !track || !sameQueueTrackId(ref.trackId, track.id)) return false; + const refServerId = normalizedQueueServerId(ref.serverId); + const trackServerId = normalizedQueueServerId(track.serverId); + return !refServerId || !trackServerId || refServerId === trackServerId; +} + /** Canonical queue ref identity — server + track id (mixed-server safe). */ export function sameQueueItemRef( a: Pick, b: Pick, ): boolean { - return a.serverId === b.serverId && sameQueueTrackId(a.trackId, b.trackId); + return queueItemIdentityKey(a) === queueItemIdentityKey(b); } export function findQueueItemRefIndex( @@ -41,7 +116,7 @@ export function findQueueItemRefIndex( export function queuesStructuralEqual(a: Track[], b: Track[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { - if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false; + if (!sameQueueTrack(a[i], b[i])) return false; } return true; } diff --git a/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts b/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts index c10caa72..194444d9 100644 --- a/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts +++ b/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts @@ -29,6 +29,7 @@ import { } from '@/features/playback/utils/playback/resolvePlaybackUrl'; import { useAuthStore } from '@/store/authStore'; import { resetAuthStore } from '@/test/helpers/storeReset'; +import { queueTrackIdentityKey } from '@/features/playback/utils/playback/queueIdentity'; function seedLibraryEntry(trackId: string, serverIndexKey: string, localPath: string): void { entriesMock[`${serverIndexKey}:${trackId}`] = { @@ -125,6 +126,11 @@ describe('getPlaybackSourceKind', () => { it('returns "hot" when the engine reported a preload for this trackId (RAM-loaded)', () => { expect(getPlaybackSourceKind('t1', 'srv-1', 't1')).toBe('hot'); }); + + it('does not reuse a server-qualified preload from another owner', () => { + const preloadIdentity = queueTrackIdentityKey('t1', 'srv-other'); + expect(getPlaybackSourceKind('t1', 'srv-1', preloadIdentity)).toBe('stream'); + }); }); describe('streamUrlTrackId', () => { diff --git a/src/features/playback/utils/playback/resolvePlaybackUrl.ts b/src/features/playback/utils/playback/resolvePlaybackUrl.ts index 9588cb7e..36f76abc 100644 --- a/src/features/playback/utils/playback/resolvePlaybackUrl.ts +++ b/src/features/playback/utils/playback/resolvePlaybackUrl.ts @@ -3,6 +3,7 @@ import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve'; import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; import { getPlaybackCacheServerKey, getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer'; import type { Track } from '@/lib/media/trackTypes'; +import { queueTrackIdentityMatches } from '@/features/playback/utils/playback/queueIdentity'; /** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */ export type PlaybackSourceKind = 'offline' | 'hot' | 'stream'; @@ -38,8 +39,8 @@ function resolvePlaybackProfileId(serverIdOrKey: string): string { } /** - * @param enginePreloadedTrackId — song id for which `audio_preload` finished into the engine RAM slot - * (parsed from `audio:preload-ready` payload URL). + * @param enginePreloadedTrackId — server-qualified queue identity for which `audio_preload` + * finished into the engine RAM slot; legacy callers may pass a raw song id. */ export function getPlaybackSourceKind( trackId: string, @@ -54,7 +55,7 @@ export function getPlaybackSourceKind( if ( !resolved.startsWith('psysonic-local://') && enginePreloadedTrackId - && trackId === enginePreloadedTrackId + && queueTrackIdentityMatches(enginePreloadedTrackId, trackId, serverId) ) { return 'hot'; } diff --git a/src/test/helpers/factories.ts b/src/test/helpers/factories.ts index 92f48bbd..815190ff 100644 --- a/src/test/helpers/factories.ts +++ b/src/test/helpers/factories.ts @@ -90,7 +90,16 @@ export function seedQueue( const index = opts.index ?? 0; const currentTrack = opts.currentTrack === undefined ? (tracks[index] ?? null) : opts.currentTrack; - seedQueueResolver(serverId, tracks); + const tracksByServer = new Map(); + for (const track of tracks) { + const owner = track.serverId ?? serverId; + const bucket = tracksByServer.get(owner); + if (bucket) bucket.push(track); + else tracksByServer.set(owner, [track]); + } + for (const [owner, ownedTracks] of tracksByServer) { + seedQueueResolver(owner, ownedTracks); + } usePlayerStore.setState({ queueItems: toQueueItemRefs(serverId, tracks), queueIndex: index,