From a88d5f3181298419c33a64182c9b62c7b5c52bfb Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:31:12 +0300 Subject: [PATCH] fix(autodj): last-track tail and queue-end idle pull rewind (#1183) --- CHANGELOG.md | 12 ++++++++ src/hooks/useIdlePlayQueuePull.ts | 2 ++ src/store/applyServerPlayQueue.ts | 3 ++ src/store/audioEventHandlers.ts | 18 +++++++---- src/store/nextAction.ts | 32 +++++++++++--------- src/store/queuePlaybackIdle.test.ts | 27 +++++++++++++++++ src/store/queuePlaybackIdle.ts | 17 +++++++++++ src/store/queueSync.test.ts | 16 ++++++++++ src/store/queueSync.ts | 19 +++++++++++- src/utils/playback/autodjAutoAdvance.test.ts | 26 ++++++++++++++++ src/utils/playback/autodjAutoAdvance.ts | 16 ++++++++++ 11 files changed, 166 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3253777..7a9d2c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/hooks/useIdlePlayQueuePull.ts b/src/hooks/useIdlePlayQueuePull.ts index 965d7a32..221104f4 100644 --- a/src/hooks/useIdlePlayQueuePull.ts +++ b/src/hooks/useIdlePlayQueuePull.ts @@ -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; diff --git a/src/store/applyServerPlayQueue.ts b/src/store/applyServerPlayQueue.ts index 66cee389..d3f9e7a9 100644 --- a/src/store/applyServerPlayQueue.ts +++ b/src/store/applyServerPlayQueue.ts @@ -14,6 +14,7 @@ import { getIdlePullGeneration, isIdleQueuePullSuspended, resumeIdleQueuePull, + clearQueueNaturallyEnded, } from './queuePlaybackIdle'; import { clearQueueHandoffPending } from './queueSyncUiState'; @@ -179,6 +180,8 @@ export async function pullPlayQueueFromActiveServer(): Promise 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(); diff --git a/src/store/nextAction.ts b/src/store/nextAction.ts index a6691d02..c4195b5d 100644 --- a/src/store/nextAction.ts +++ b/src/store/nextAction.ts @@ -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); } } } diff --git a/src/store/queuePlaybackIdle.test.ts b/src/store/queuePlaybackIdle.test.ts index 11c7355d..58f76cdb 100644 --- a/src/store/queuePlaybackIdle.test.ts +++ b/src/store/queuePlaybackIdle.test.ts @@ -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); + }); +}); diff --git a/src/store/queuePlaybackIdle.ts b/src/store/queuePlaybackIdle.ts index aefdaacf..4d4e6c9d 100644 --- a/src/store/queuePlaybackIdle.ts +++ b/src/store/queuePlaybackIdle.ts @@ -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; } diff --git a/src/store/queueSync.test.ts b/src/store/queueSync.test.ts index 0bcc6f80..fcbf5c50 100644 --- a/src/store/queueSync.test.ts +++ b/src/store/queueSync.test.ts @@ -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); + }); +}); diff --git a/src/store/queueSync.ts b/src/store/queueSync.ts index 5f0ea65d..90421a61 100644 --- a/src/store/queueSync.ts +++ b/src/store/queueSync.ts @@ -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 { 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 { + 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 diff --git a/src/utils/playback/autodjAutoAdvance.test.ts b/src/utils/playback/autodjAutoAdvance.test.ts index 24c9e13f..749934d8 100644 --- a/src/utils/playback/autodjAutoAdvance.test.ts +++ b/src/utils/playback/autodjAutoAdvance.test.ts @@ -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(); + }); +}); diff --git a/src/utils/playback/autodjAutoAdvance.ts b/src/utils/playback/autodjAutoAdvance.ts index 839e0ec6..9c4a8236 100644 --- a/src/utils/playback/autodjAutoAdvance.ts +++ b/src/utils/playback/autodjAutoAdvance.ts @@ -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 {