fix(queue): push edited queue when playback starts on paused client (#1133)

This commit is contained in:
cucadmuh
2026-06-20 01:15:28 +03:00
committed by GitHub
parent c037ab459a
commit 2d1a078186
5 changed files with 69 additions and 6 deletions
+2 -2
View File
@@ -62,7 +62,7 @@ import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { getQueueTracksView, resolveQueueTrack } from '../utils/library/queueTrackView';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { pushQueueOnPlaybackStart } from './queueSync';
import { playListenSessionFinalize } from './playListenSession';
import { pushQueueUndoFromGetter } from './queueUndo';
import { stopRadio } from './radioPlayer';
@@ -543,7 +543,7 @@ export function runPlayTrack(
}));
});
}
syncQueueToServer(get().queueItems, scopedTrack, initialTime);
pushQueueOnPlaybackStart(get().queueItems, scopedTrack, initialTime);
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
};
+21
View File
@@ -40,6 +40,7 @@ import {
flushQueueSyncToServer,
getLastQueueHeartbeatAt,
hasPendingQueueSync,
pushQueueOnPlaybackStart,
syncQueueToServer,
} from './queueSync';
import {
@@ -115,6 +116,26 @@ describe('syncQueueToServer (debounced)', () => {
});
});
describe('pushQueueOnPlaybackStart', () => {
const queue = [ref('a'), ref('b')];
it('flushes immediately and clears idle pull suspension when locally edited', async () => {
syncQueueToServer(queue, track('a'), 30);
expect(hasPendingQueueSync()).toBe(true);
pushQueueOnPlaybackStart(queue, track('a'), 42);
expect(hasPendingQueueSync()).toBe(false);
await vi.runAllTimersAsync();
expect(savePlayQueueMock).toHaveBeenCalledWith(['a', 'b'], 'a', 42000, 'srv-a');
expect(isIdleQueuePullSuspended()).toBe(false);
});
it('debounces when idle pull is not suspended', () => {
pushQueueOnPlaybackStart(queue, track('a'), 12);
expect(hasPendingQueueSync()).toBe(true);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
});
describe('flushPlayQueueForServer', () => {
it('flushes only the target server slice', async () => {
playerState.queueItems = [ref('a', 'srv-a'), ref('b', 'b.test')];
+36 -1
View File
@@ -8,7 +8,7 @@ import {
} from '../utils/playback/playbackServer';
import { filterQueueRefsForServerProfile } from '../utils/playback/trackServerScope';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { touchQueueMutationClock } from './queuePlaybackIdle';
import { touchQueueMutationClock, isIdleQueuePullSuspended, resumeIdleQueuePull } from './queuePlaybackIdle';
import { usePlayerStore } from './playerStore';
/**
@@ -121,6 +121,41 @@ export function flushPlayQueuePosition(): Promise<void> {
return flushQueueSyncToServer(s.queueItems, s.currentTrack, getPlaybackProgressSnapshot().currentTime);
}
/**
* 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
* and re-enable idle auto-pull (blocked anyway while `isPlaying`).
*/
export function pushQueueOnPlaybackStart(
queue: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
): void {
if (!currentTrack || queue.length === 0) return;
if (isIdleQueuePullSuspended()) {
void flushQueueSyncToServer(queue, currentTrack, currentTime).then(() => {
resumeIdleQueuePull();
});
return;
}
syncQueueToServer(queue, currentTrack, currentTime);
}
export function flushLocalQueueWhenTakingPlayback(): Promise<void> {
if (!isIdleQueuePullSuspended()) return Promise.resolve();
const s = usePlayerStore.getState();
if (s.currentRadio || !s.currentTrack || s.queueItems.length === 0) {
return Promise.resolve();
}
return flushQueueSyncToServer(
s.queueItems,
s.currentTrack,
getPlaybackProgressSnapshot().currentTime,
).then(() => {
resumeIdleQueuePull();
});
}
/** Test-only: drop the debounce + reset the heartbeat. */
export function _resetQueueSyncForTest(): void {
if (syncTimeout) {
+4 -3
View File
@@ -30,7 +30,7 @@ import {
import type { PlayerState } from './playerStoreTypes';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import { syncQueueToServer } from './queueSync';
import { pushQueueOnPlaybackStart, flushLocalQueueWhenTakingPlayback } from './queueSync';
import { markPlaybackActive } from './queuePlaybackIdle';
import { playbackReportPlaying } from './playbackReportSession';
import { resumeRadio } from './radioPlayer';
@@ -134,6 +134,7 @@ export function runResume(set: SetState, get: GetState): void {
set({ isPlaying: true });
// Mirror pause(): tell the server immediately, don't wait for `audio:playing`.
playbackReportPlaying(currentTime);
void flushLocalQueueWhenTakingPlayback();
touchHotCacheOnPlayback(currentTrack.id, getPlaybackCacheServerKey());
} else {
// Engine has no loaded paused stream (app relaunch, or track ended and user
@@ -199,7 +200,7 @@ export function runResume(set: SetState, get: GetState): void {
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queueItems, trackToPlay, currentTime);
pushQueueOnPlaybackStart(queueItems, trackToPlay, currentTime);
}).catch(() => {
if (getPlayGeneration() !== gen) return;
// Fallback to currentTrack if fetch fails
@@ -236,7 +237,7 @@ export function runResume(set: SetState, get: GetState): void {
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
syncQueueToServer(queueItems, currentTrack, currentTime);
pushQueueOnPlaybackStart(queueItems, currentTrack, currentTime);
});
})();
}