mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(queue): suspend idle pull only on user queue edits (#1136)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user