fix(playback): preserve mixed queue ownership

This commit is contained in:
cucadmuh
2026-07-19 05:12:07 +03:00
parent 8e8cf72f23
commit 0a41b64adb
33 changed files with 600 additions and 122 deletions
+2 -2
View File
@@ -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);
}
@@ -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
?? '',
@@ -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,
@@ -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,
@@ -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);
}
}),
];
@@ -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);
});
+26 -10
View File
@@ -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(() => {});
}
@@ -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', {
@@ -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(
@@ -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. */
@@ -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', () => {
@@ -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 500600 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;
}
@@ -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> = {}): 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', () => {
@@ -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({
+7 -4
View File
@@ -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<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -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({
+34 -21
View File
@@ -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);
@@ -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.
@@ -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);
@@ -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',
}));
});
});
@@ -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;
@@ -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<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -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));
@@ -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();
@@ -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<T>(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<T>(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<string, QueueItemRef[]>();
const identityPools = new Map<string, QueueItemRef[]>();
const legacyPools = new Map<string, QueueItemRef[]>();
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<QueueItemRef>();
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))];
}
@@ -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[];
}
@@ -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
+21 -2
View File
@@ -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<void> {
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({
@@ -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<void> {
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,
@@ -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));
}
@@ -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);
});
@@ -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<Track, 'id' | 'serverId'> | null | undefined,
b: Pick<Track, 'id' | 'serverId'> | 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<QueueItemRef, 'serverId' | 'trackId'>,
): 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<QueueItemRef, 'serverId' | 'trackId'> | null | undefined,
track: Pick<Track, 'id' | 'serverId'> | 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<QueueItemRef, 'serverId' | 'trackId'>,
b: Pick<QueueItemRef, 'serverId' | 'trackId'>,
): 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;
}
@@ -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', () => {
@@ -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';
}
+10 -1
View File
@@ -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<string, Track[]>();
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,