From 23f8008248dc4934340e7e8c7aaef1ac97138a2b Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:11:06 +0300 Subject: [PATCH] fix(queue): suspend idle pull only on user queue edits (#1136) --- CHANGELOG.md | 6 ++++++ src/hooks/usePlayQueueSyncLedState.ts | 3 ++- src/store/applyQueueHistorySnapshot.ts | 6 +++--- src/store/miscActions.ts | 4 ++-- src/store/queueMutationActions.ts | 24 +++++++++++----------- src/store/queueSync.test.ts | 16 ++++++++++++--- src/store/queueSync.ts | 28 ++++++++++++++++++++++---- 7 files changed, 62 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d53bebd7..98896878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/hooks/usePlayQueueSyncLedState.ts b/src/hooks/usePlayQueueSyncLedState.ts index 65baa715..6bfd9145 100644 --- a/src/hooks/usePlayQueueSyncLedState.ts +++ b/src/hooks/usePlayQueueSyncLedState.ts @@ -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) diff --git a/src/store/applyQueueHistorySnapshot.ts b/src/store/applyQueueHistorySnapshot.ts index 2f59b850..c8dc7cc1 100644 --- a/src/store/applyQueueHistorySnapshot.ts +++ b/src/store/applyQueueHistorySnapshot.ts @@ -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 | ((state: PlayerState) => Partial), @@ -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; } diff --git a/src/store/miscActions.ts b/src/store/miscActions.ts index 5f1a77a3..1019f779 100644 --- a/src/store/miscActions.ts +++ b/src/store/miscActions.ts @@ -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(); }, }; diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index 88cd9bc2..18b6215c 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -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); }, }; } diff --git a/src/store/queueSync.test.ts b/src/store/queueSync.test.ts index 5a7ca358..0bcc6f80 100644 --- a/src/store/queueSync.test.ts +++ b/src/store/queueSync.test.ts @@ -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); diff --git a/src/store/queueSync.ts b/src/store/queueSync.ts index 1e781290..5f0ea65d 100644 --- a/src/store/queueSync.ts +++ b/src/store/queueSync.ts @@ -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 { if (syncTimeout) { clearTimeout(syncTimeout);