mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(playback): preserve mixed queue ownership
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user