mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(autodj): last-track tail and queue-end idle pull rewind (#1183)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user