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
+6
View File
@@ -116,6 +116,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* After cross-device idle pull while paused, a local queue change (e.g. enqueue) could be overwritten when auto-pull ran again. Idle auto-pull now stops on local mutations until manual sync from the header; the connection LED turns yellow while auto-sync is paused.
### Paused client did not push edited queue on Play
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1133](https://github.com/Psychotoxical/psysonic/pull/1133)**
* After editing the queue while paused (yellow sync LED), pressing Play only resumed audio and could leave the server on another device's queue until the debounced push fired. Resume and play-from-queue now flush the local play queue immediately and clear the yellow indicator when the push succeeds.
## [1.48.1] - 2026-06-15
+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);
});
})();
}