fix(autodj): last-track tail and queue-end idle pull rewind (#1183)

This commit is contained in:
cucadmuh
2026-06-25 00:31:12 +03:00
committed by GitHub
parent b6ac879b14
commit a88d5f3181
11 changed files with 166 additions and 22 deletions
+2
View File
@@ -6,6 +6,7 @@ import { usePlayerStore } from '../store/playerStore';
import {
getPlaybackIdleSinceMs,
isIdleQueuePullSuspended,
isQueueNaturallyEnded,
isPlaybackIdleLongEnough,
markPlaybackIdle,
} from '../store/queuePlaybackIdle';
@@ -38,6 +39,7 @@ export function useIdlePlayQueuePull(status: ConnectionStatus) {
if (isPlaying) return;
if (!isPlaybackIdleLongEnough(IDLE_THRESHOLD_MS)) return;
if (isIdleQueuePullSuspended()) return;
if (isQueueNaturallyEnded()) return;
if (hasPendingQueueSync()) return;
if (!activeServerId) return;
+3
View File
@@ -14,6 +14,7 @@ import {
getIdlePullGeneration,
isIdleQueuePullSuspended,
resumeIdleQueuePull,
clearQueueNaturallyEnded,
} from './queuePlaybackIdle';
import { clearQueueHandoffPending } from './queueSyncUiState';
@@ -179,6 +180,8 @@ export async function pullPlayQueueFromActiveServer(): Promise<ApplyPlayQueueRes
const activeId = useAuthStore.getState().activeServerId;
if (!activeId) return 'error';
clearQueueNaturallyEnded();
try {
const q = await getPlayQueueForServer(activeId);
if (q.songs.length === 0) {
+12 -6
View File
@@ -73,6 +73,7 @@ import {
getLastQueueHeartbeatAt,
syncQueueToServer,
} from './queueSync';
import { clearQueueNaturallyEnded } from './queuePlaybackIdle';
import { isSeekDebouncePending } from './seekDebounce';
import {
SEEK_FALLBACK_VISUAL_GUARD_MS,
@@ -92,6 +93,7 @@ import {
autodjJsTriggerAtSec,
clampCrossfadeSecs,
computeAutodjJsOverlap,
nextQueueRefForTransition,
shouldJsDriveAutodjTransition,
} from '../utils/playback/autodjAutoAdvance';
import { isInterruptHandoffPending } from '../utils/playback/autodjInterruptPrep';
@@ -126,6 +128,7 @@ export type NormalizationStatePayload = {
};
export function handleAudioPlaying(duration: number): void {
clearQueueNaturallyEnded();
setDeferHotCachePrefetch(false);
resetProgressEmitThrottles();
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
@@ -284,12 +287,11 @@ export function handleAudioProgress(
// ~2 s JS blend (not the engine crossfadeSecs slider). When JS drives we
// suppress the engine's autonomous crossfade timer so B is readiness-gated.
let autodjSuppressWant = false;
const nextRef = trimActive && store.isPlaying && store.repeatMode !== 'one'
? nextQueueRefForTransition(store.queueItems, store.queueIndex, store.repeatMode)
: null;
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
if (trimActive && store.isPlaying && store.repeatMode !== 'one') {
const nextIdx = store.queueIndex + 1;
const nextRef = nextIdx < store.queueItems.length
? store.queueItems[nextIdx]
: (store.repeatMode === 'all' && store.queueItems.length > 0 ? store.queueItems[0] : null);
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
if (nextTrackId) {
const cf = clampCrossfadeSecs(crossfadeSecs);
const plan = getCrossfadeTransition(nextTrackId);
@@ -333,11 +335,15 @@ export function handleAudioProgress(
return;
}
}
} else {
// Queue tail with no successor — play A through its real ending; suppress
// the engine's early crossfade timer so `audio:ended` fires on exhaustion.
autodjSuppressWant = true;
}
}
syncAutodjSuppress(autodjSuppressWant);
if (trimActive && store.isPlaying && !autodjSuppressWant) {
if (trimActive && store.isPlaying && !autodjSuppressWant && nextTrackId) {
const cf = clampCrossfadeSecs(crossfadeSecs);
if (remaining > 0 && remaining <= cf) {
const gen = getPlayGeneration();
+17 -15
View File
@@ -21,6 +21,7 @@ import {
isRadioFetching,
setRadioFetching,
} from './radioSessionState';
import { finalizePlayQueueAtTrackEnd } from './queueSync';
import { applySkipStarOnManualNext } from './skipStarRating';
type SetState = (
@@ -50,6 +51,17 @@ function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]):
get().playTrack(fresh[0], undefined, false, false, playAt);
}
/** Repeat-off queue tail: stop transport and finalize server play queue at EOF. */
function stopAtNaturalQueueEnd(set: SetState, get: GetState): void {
const { currentTrack, queueItems } = get();
if (currentTrack && queueItems.length > 0) {
void finalizePlayQueueAtTrackEnd(queueItems, currentTrack);
}
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
}
/**
* Advance to the next track. Three top-level outcomes:
*
@@ -232,16 +244,12 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
if (fresh.length > 0) {
appendTracksAndPlayFirst(set, get, fresh);
} else {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
stopAtNaturalQueueEnd(set, get);
}
})
.catch(() => {
setRadioFetching(false);
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
stopAtNaturalQueueEnd(set, get);
});
return;
}
@@ -262,22 +270,16 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
return;
}
if (newTracks.length === 0) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
stopAtNaturalQueueEnd(set, get);
return;
}
appendTracksAndPlayFirst(set, get, newTracks);
}).catch(() => {
setInfiniteQueueFetching(false);
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
stopAtNaturalQueueEnd(set, get);
});
} else {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
stopAtNaturalQueueEnd(set, get);
}
}
}
+27
View File
@@ -3,6 +3,9 @@ import {
_resetQueuePlaybackIdleForTest,
getIdlePullGeneration,
isIdleQueuePullSuspended,
isQueueNaturallyEnded,
markPlaybackActive,
markQueueNaturallyEnded,
resumeIdleQueuePull,
subscribeIdleQueuePullSuspended,
touchQueueMutationClock,
@@ -48,3 +51,27 @@ describe('idle queue pull suspension', () => {
unsub();
});
});
describe('natural queue end', () => {
beforeEach(() => {
_resetQueuePlaybackIdleForTest();
});
it('starts clear and can be marked after queue exhaustion', () => {
expect(isQueueNaturallyEnded()).toBe(false);
markQueueNaturallyEnded();
expect(isQueueNaturallyEnded()).toBe(true);
});
it('clears when playback becomes active again', () => {
markQueueNaturallyEnded();
markPlaybackActive();
expect(isQueueNaturallyEnded()).toBe(false);
});
it('clears on local queue mutation', () => {
markQueueNaturallyEnded();
touchQueueMutationClock();
expect(isQueueNaturallyEnded()).toBe(false);
});
});
+17
View File
@@ -4,6 +4,8 @@ let playbackIdleSinceMs = 0;
let lastQueueMutationAt = 0;
/** When true, idle auto-pull is disabled until manual pull re-enables it. */
let idleQueuePullSuspended = false;
/** Set when repeat-off playback reaches the queue tail — blocks idle pull until play resumes. */
let queueNaturallyEnded = false;
/** Bumped on each local queue mutation; stale in-flight idle pulls must not apply. */
let idlePullGeneration = 0;
@@ -30,6 +32,19 @@ export function markPlaybackIdle(): void {
export function markPlaybackActive(): void {
playbackIdleSinceMs = 0;
clearQueueNaturallyEnded();
}
export function markQueueNaturallyEnded(): void {
queueNaturallyEnded = true;
}
export function clearQueueNaturallyEnded(): void {
queueNaturallyEnded = false;
}
export function isQueueNaturallyEnded(): boolean {
return queueNaturallyEnded;
}
export function getPlaybackIdleSinceMs(): number {
@@ -62,6 +77,7 @@ export function getIdlePullGeneration(): number {
export function touchQueueMutationClock(): void {
lastQueueMutationAt = Date.now();
clearQueueNaturallyEnded();
suspendIdleQueuePull();
idlePullGeneration += 1;
}
@@ -79,5 +95,6 @@ export function _resetQueuePlaybackIdleForTest(): void {
playbackIdleSinceMs = 0;
lastQueueMutationAt = 0;
idleQueuePullSuspended = false;
queueNaturallyEnded = false;
idlePullGeneration = 0;
}
+16
View File
@@ -35,6 +35,7 @@ vi.mock('./playbackProgress', () => ({
import {
_resetQueueSyncForTest,
finalizePlayQueueAtTrackEnd,
flushPlayQueueForServer,
flushPlayQueuePosition,
flushQueueSyncToServer,
@@ -47,6 +48,7 @@ import {
import {
_resetQueuePlaybackIdleForTest,
isIdleQueuePullSuspended,
isQueueNaturallyEnded,
} from './queuePlaybackIdle';
function track(id: string, serverId = 'srv-a'): Track {
@@ -186,3 +188,17 @@ describe('flushPlayQueuePosition', () => {
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
});
describe('finalizePlayQueueAtTrackEnd', () => {
it('flushes immediately at track duration and marks the queue naturally ended', async () => {
const queue = [ref('a'), ref('b')];
const current = track('b');
current.duration = 245;
syncQueueToServer(queue, current, 120);
await finalizePlayQueueAtTrackEnd(queue, current);
expect(savePlayQueueMock).toHaveBeenCalledTimes(1);
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'b', 245000, 'srv-a');
expect(isQueueNaturallyEnded()).toBe(true);
expect(hasPendingQueueSync()).toBe(false);
});
});
+18 -1
View File
@@ -8,7 +8,7 @@ import {
} from '../utils/playback/playbackServer';
import { filterQueueRefsForServerProfile } from '../utils/playback/trackServerScope';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { touchQueueMutationClock, isIdleQueuePullSuspended, resumeIdleQueuePull } from './queuePlaybackIdle';
import { touchQueueMutationClock, isIdleQueuePullSuspended, resumeIdleQueuePull, markQueueNaturallyEnded } from './queuePlaybackIdle';
import { usePlayerStore } from './playerStore';
/**
@@ -141,6 +141,23 @@ export function flushPlayQueuePosition(): Promise<void> {
return flushQueueSyncToServer(s.queueItems, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
}
/**
* Queue exhausted (repeat off): push the final track at end-of-file so idle
* auto-pull does not rewind to an earlier debounced position on the server.
*/
export function finalizePlayQueueAtTrackEnd(
queue: QueueItemRef[],
currentTrack: Track,
): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
markQueueNaturallyEnded();
const endSec = Math.max(0, currentTrack.duration ?? 0);
return flushQueueSyncToServer(queue, currentTrack, endSec);
}
/**
* When the user edited the queue while paused, idle pull is suspended (yellow LED).
* Starting playback makes this client authoritative — push the local queue immediately
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { QueueItemRef } from '../../store/playerStoreTypes';
import {
autodjJsTriggerAtSec,
computeAutodjJsOverlap,
nextQueueRefForTransition,
shouldJsDriveAutodjTransition,
} from './autodjAutoAdvance';
@@ -44,3 +46,27 @@ describe('autodjJsTriggerAtSec', () => {
expect(autodjJsTriggerAtSec(200, 3, 2)).toBe(195);
});
});
describe('nextQueueRefForTransition', () => {
const ref = (id: string): QueueItemRef => ({ trackId: id, serverId: 's1' });
it('returns the next slot when one exists', () => {
const items = [ref('a'), ref('b')];
expect(nextQueueRefForTransition(items, 0, 'off')).toBe(items[1]);
});
it('returns null on the queue tail without repeat-all', () => {
const items = [ref('a'), ref('b')];
expect(nextQueueRefForTransition(items, 1, 'off')).toBeNull();
});
it('wraps to the head on repeat-all', () => {
const items = [ref('a'), ref('b')];
expect(nextQueueRefForTransition(items, 1, 'all')).toBe(items[0]);
});
it('returns null for repeat-one', () => {
const items = [ref('a'), ref('b')];
expect(nextQueueRefForTransition(items, 0, 'one')).toBeNull();
});
});
+16
View File
@@ -1,4 +1,20 @@
import { DYNAMIC_OVERLAP_HARD_CAP_SEC, STANDARD_BLEND_SEC } from '../waveform/waveformSilence';
import type { QueueItemRef } from '../../store/playerStoreTypes';
export type QueueRepeatMode = 'off' | 'all' | 'one';
/** Next queue slot AutoDJ / silence-aware crossfade may hand off to, if any. */
export function nextQueueRefForTransition(
queueItems: QueueItemRef[],
queueIndex: number,
repeatMode: QueueRepeatMode,
): QueueItemRef | null {
if (repeatMode === 'one') return null;
const nextIdx = queueIndex + 1;
if (nextIdx < queueItems.length) return queueItems[nextIdx] ?? null;
if (repeatMode === 'all' && queueItems.length > 0) return queueItems[0] ?? null;
return null;
}
/** Clamp engine crossfade setting to the same bounds used in progress handling. */
export function clampCrossfadeSecs(crossfadeSecs: number): number {