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
+12
View File
@@ -266,6 +266,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* After retagging a track and resyncing the library, genres with no remaining albums could still appear on the Genres page until restart. The local genre catalog now counts only live indexed tracks, filters zero-count genres, and the Genres page refreshes when library sync finishes.
### AutoDJ — last track in the queue was cut short
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
* With AutoDJ active and no next track to blend into, the engine could still fire the crossfade end timer and trim the final song. The last track now plays through to real source exhaustion.
### Play queue sync — idle pull rewound after the queue finished
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1183](https://github.com/Psychotoxical/psysonic/pull/1183)**
* After the last track ended (repeat off), idle auto-pull could restore an earlier server position from the last debounced push and seek backward. The client now flushes end-of-track position to the server and skips idle auto-pull until playback resumes, the queue is edited, or the user pulls manually.
## Under the Hood
### ESLint setup and a strict lint pass over the frontend
+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;
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 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') {
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 {