fix(queue): suspend idle pull only on user queue edits (#1136)

This commit is contained in:
cucadmuh
2026-06-20 17:11:06 +03:00
committed by GitHub
parent b9b4f76c11
commit 23f8008248
7 changed files with 62 additions and 25 deletions
+6
View File
@@ -134,6 +134,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The header connection probe now retries a failed ping twice (2 s apart) before marking the server unreachable, so a single dropped packet on an otherwise fine link no longer flips the LED to disconnected.
### Yellow sync LED during normal playback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1136](https://github.com/Psychotoxical/psysonic/pull/1136)**
* Track-advance queue pushes no longer suspend idle auto-pull, so the connection LED does not flash yellow on every song change. Yellow sync still appears after a local queue edit while paused; it clears while audio is playing.
## [1.48.1] - 2026-06-15
+2 -1
View File
@@ -17,6 +17,7 @@ export function usePlayQueueSyncLedState(status: ConnectionStatus) {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const orbitRole = useOrbitStore(s => s.role);
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentRadio = usePlayerStore(s => s.currentRadio);
const [pullInFlight, setPullInFlight] = useState(false);
const idlePullSuspended = useSyncExternalStore(
@@ -40,7 +41,7 @@ export function usePlayQueueSyncLedState(status: ConnectionStatus) {
}, [activeServerId, playbackServerId]);
const autoSyncContext = canAutoIdlePlayQueuePull(status, orbitRole);
const localQueueSyncPaused = autoSyncContext && idlePullSuspended;
const localQueueSyncPaused = autoSyncContext && idlePullSuspended && !isPlaying;
const needsQueuePull = status === 'connected'
&& Boolean(activeServerId)
+3 -3
View File
@@ -23,7 +23,7 @@ import { refreshLoudnessForTrack } from './loudnessRefresh';
import { refreshWaveformForTrack } from './waveformRefresh';
import { stopRadio } from './radioPlayer';
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
import { syncQueueToServer } from './queueSync';
import { syncUserQueueMutationToServer } from './queueSync';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -209,7 +209,7 @@ export function applyQueueHistorySnapshot(
if (!nextTrack) {
invoke('audio_stop').catch(console.error);
setIsAudioPaused(false);
syncQueueToServer(nextItems, null, 0);
syncUserQueueMutationToServer(nextItems, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
@@ -235,6 +235,6 @@ export function applyQueueHistorySnapshot(
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
}
syncQueueToServer(nextItems, nextTrack, tRestore);
syncUserQueueMutationToServer(nextItems, nextTrack, tRestore);
return true;
}
+2 -2
View File
@@ -16,7 +16,7 @@ import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { pushQueueUndoFromGetter } from './queueUndo';
import { syncQueueToServer } from './queueSync';
import { syncUserQueueMutationToServer } from './queueSync';
import {
clearRadioReconnectTimer,
playRadioStream,
@@ -173,7 +173,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
queueIndex: 0,
currentTrack: track,
});
syncQueueToServer(newItems, track, s.currentTime);
syncUserQueueMutationToServer(newItems, track, s.currentTime);
if (!wasPlaying) get().resume();
},
};
+12 -12
View File
@@ -7,7 +7,7 @@ import type { PlayerState, QueueItemRef, Track } from './playerStoreTypes';
import { toQueueItemRefs } from '../utils/library/queueItemRef';
import { seedQueueResolver } from '../utils/library/queueTrackResolver';
import { pushQueueUndoFromGetter } from './queueUndo';
import { syncQueueToServer } from './queueSync';
import { syncUserQueueMutationToServer } from './queueSync';
import {
addRadioSessionSeen,
clearRadioSessionSeenIds,
@@ -51,7 +51,7 @@ function seedIncoming(state: PlayerState, tracks: Track[]): void {
/**
* Eleven queue-mutation actions. Shared invariant: every action except
* `setRadioArtistId` pushes a queue-undo snapshot and calls
* `syncQueueToServer` so the Navidrome `savePlayQueue` stays in sync.
* `syncUserQueueMutationToServer` so the Navidrome `savePlayQueue` stays in sync.
* Exceptions: `enqueue`'s optional third argument **`skipQueueUndo`** and
* **`pruneUpcomingToCurrent(true)`** — Lucky Mix pushes one snapshot up-front.
*/
@@ -89,7 +89,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
const newItems = firstAutoIdx === -1
? [...items, ...incoming]
: [...items.slice(0, firstAutoIdx), ...incoming, ...items.slice(firstAutoIdx)];
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
syncUserQueueMutationToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, state.queueIndex);
return { queueItems: newItems };
});
@@ -148,7 +148,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
? [...upcoming, ...incoming]
: [...upcoming.slice(0, firstAutoIdx), ...incoming, ...upcoming.slice(firstAutoIdx)];
const newItems = [...beforeAndCurrent, ...mergedItems];
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
syncUserQueueMutationToServer(newItems, state.currentTrack, state.currentTime);
return { queueItems: newItems };
});
},
@@ -171,7 +171,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
const newQueueIndex = idx <= state.queueIndex
? state.queueIndex + tracks.length
: state.queueIndex;
syncQueueToServer(newItems, state.currentTrack, state.currentTime);
syncUserQueueMutationToServer(newItems, state.currentTrack, state.currentTime);
prefetchLoudnessForEnqueuedTracks(newItems, newQueueIndex);
return { queueItems: newItems, queueIndex: newQueueIndex };
});
@@ -202,7 +202,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
if (s.queueItems.length === 0) return;
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
set({ queueItems: [], queueIndex: 0 });
syncQueueToServer([], null, 0);
syncUserQueueMutationToServer([], null, 0);
return;
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
@@ -216,7 +216,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
: toQueueItemRefs(s.queueServerId ?? '', [s.currentTrack!]);
const newIndex = at >= 0 ? at : 0;
set({ queueItems: newItems, queueIndex: newIndex });
syncQueueToServer(newItems, s.currentTrack, s.currentTime);
syncUserQueueMutationToServer(newItems, s.currentTrack, s.currentTime);
},
clearQueue: () => {
@@ -229,7 +229,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
setCurrentRadioArtistId(null);
clearQueueServerForPlayback();
set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
syncQueueToServer([], null, 0);
syncUserQueueMutationToServer([], null, 0);
},
reorderQueue: (startIndex, endIndex) => {
@@ -242,7 +242,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
let newIndex = queueIndex;
if (currentTrack) newIndex = result.findIndex(r => r.trackId === currentTrack.id);
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
},
shuffleQueue: () => {
@@ -262,7 +262,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
: others;
const newIndex = currentIdx >= 0 ? 0 : -1;
set({ queueItems: result, queueIndex: Math.max(0, newIndex) });
syncQueueToServer(result, currentTrack, get().currentTime);
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
},
shuffleUpcomingQueue: () => {
@@ -281,7 +281,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
}
const result = [...head, ...upcoming];
set({ queueItems: result });
syncQueueToServer(result, currentTrack, get().currentTime);
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
@@ -294,7 +294,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
queueItems: newItems,
queueIndex: Math.min(queueIndex, newItems.length - 1),
});
syncQueueToServer(newItems, get().currentTrack, get().currentTime);
syncUserQueueMutationToServer(newItems, get().currentTrack, get().currentTime);
},
};
}
+13 -3
View File
@@ -42,6 +42,7 @@ import {
hasPendingQueueSync,
pushQueueOnPlaybackStart,
syncQueueToServer,
syncUserQueueMutationToServer,
} from './queueSync';
import {
_resetQueuePlaybackIdleForTest,
@@ -97,8 +98,17 @@ describe('syncQueueToServer (debounced)', () => {
expect(savePlayQueueMock).toHaveBeenCalledWith(['a'], 'a', 12000, 'srv-a');
});
it('suspends idle pull on mutation and stays suspended after successful debounced push', async () => {
it('does not suspend idle pull during playback sync', () => {
syncQueueToServer(queue, track('a'), 30);
expect(isIdleQueuePullSuspended()).toBe(false);
});
});
describe('syncUserQueueMutationToServer (debounced)', () => {
const queue = [ref('a'), ref('b')];
it('suspends idle pull on user mutation and stays suspended after successful debounced push', async () => {
syncUserQueueMutationToServer(queue, track('a'), 30);
expect(isIdleQueuePullSuspended()).toBe(true);
expect(hasPendingQueueSync()).toBe(true);
vi.advanceTimersByTime(5000);
@@ -109,7 +119,7 @@ describe('syncQueueToServer (debounced)', () => {
it('keeps idle pull suspended when debounced push fails', async () => {
savePlayQueueMock.mockRejectedValueOnce(new Error('offline'));
syncQueueToServer(queue, track('a'), 30);
syncUserQueueMutationToServer(queue, track('a'), 30);
vi.advanceTimersByTime(5000);
await Promise.resolve();
expect(isIdleQueuePullSuspended()).toBe(true);
@@ -120,7 +130,7 @@ 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);
syncUserQueueMutationToServer(queue, track('a'), 30);
expect(hasPendingQueueSync()).toBe(true);
pushQueueOnPlaybackStart(queue, track('a'), 42);
expect(hasPendingQueueSync()).toBe(false);
+24 -4
View File
@@ -18,8 +18,10 @@ import { usePlayerStore } from './playerStore';
* another client.
*
* Two flush shapes:
* - `syncQueueToServer` debounces for 5 s so rapid edits (drag-reorder,
* auto-queue trimming, lucky-mix swaps) collapse into a single roundtrip.
* - `syncQueueToServer` debounces playback position/queue pushes (track
* changes, resume) without blocking idle auto-pull.
* - `syncUserQueueMutationToServer` — same debounce plus idle-pull
* suspension for user-initiated queue edits.
* - `flushQueueSyncToServer` cancels the debounce and pushes immediately —
* called from the playback heartbeat, `pause()`, and the app-close path
* where the user might switch devices mid-track.
@@ -55,8 +57,11 @@ function pushRefsForServer(
});
}
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
touchQueueMutationClock();
function scheduleQueueSyncToServer(
queue: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
): void {
if (!isPlaybackServerReachable()) return;
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
@@ -68,6 +73,21 @@ export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | n
}, SYNC_DEBOUNCE_MS);
}
/** Debounced push during playback (track advance, resume) — does not suspend idle pull. */
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
scheduleQueueSyncToServer(queue, currentTrack, currentTime);
}
/** Debounced push after a user queue edit — suspends idle auto-pull until manual sync or Play. */
export function syncUserQueueMutationToServer(
queue: QueueItemRef[],
currentTrack: Track | null,
currentTime: number,
): void {
touchQueueMutationClock();
scheduleQueueSyncToServer(queue, currentTrack, currentTime);
}
export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);