From 64a8a0ed8a6e1a1ed803dd8de29be4281d393fe8 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 18 May 2026 23:38:47 +0300 Subject: [PATCH] fix(playback): Lucky Mix after switching Subsonic servers (#785) * fix(playback): Lucky Mix after switching Subsonic servers Hand off unpinned legacy queues to the browsed server, start the mix lock before async work, skip cross-server enqueue toasts while rolling, bind queue server before batch enqueues, and restore queueServerId on failed builds. * chore(release): CHANGELOG and credits for PR #785 --- CHANGELOG.md | 4 ++++ src/config/settingsCredits.ts | 1 + src/store/queueMutationActions.ts | 2 ++ src/utils/mix/luckyMix.ts | 18 ++++++++++++++---- src/utils/playback/playbackServer.test.ts | 13 +++++++++++++ src/utils/playback/playbackServer.ts | 15 ++++++++++++++- 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c78fd7..7e130ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **In-page browse:** virtual artist/album/composer grids and lists no longer lose all rows after deep scroll — `scrollMargin` now targets the in-page overlay viewport, not the locked main route scrollport. * **Cover art on browse pages:** `CachedImage` priority scoring follows the real scrolling pane so visible thumbnails win network fetch slots; Artists infinite scroll loads one page per batch instead of re-entrantly queueing many pages during a fast fling. +**By [@cucadmuh](https://github.com/cucadmuh), PR [#785](https://github.com/Psychotoxical/psysonic/pull/785)** + +* **Lucky Mix after server switch:** starting a mix on the browsed server no longer spams cross-server enqueue errors — unpinned or foreign queues hand off cleanly before batch enqueue. + ### Mainstage — album rail hover controls diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 5815061e..8c95761f 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -120,6 +120,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Multi-server: Lucky Mix and Now Playing keep browsed server; queue metadata via apiForServer (PR #768)', 'Linux: session GDK/WebKit mitigations, in-page library scroll, Wayland text presets (PR #731)', 'In-page browse: virtual list scrollMargin + CachedImage load priority; Artists infinite-scroll batching (PR #783)', + 'Lucky Mix: hand off queue to browsed server after multi-server switch (PR #785)', 'Home album rails: stable play/enqueue hover on WebKitGTK/Wayland (PR #787)', ], }, diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index ac355588..4f63c825 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -19,6 +19,7 @@ import { clearSeekFallbackRetry } from './seekFallbackState'; import { clearSeekTarget } from './seekTargetState'; import i18n from '../i18n'; import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer'; +import { useLuckyMixStore } from './luckyMixStore'; import { showToast } from '../utils/ui/toast'; type SetState = ( @@ -27,6 +28,7 @@ type SetState = ( type GetState = () => PlayerState; function blockCrossServerEnqueue(): boolean { + if (useLuckyMixStore.getState().isRolling) return false; if (!playbackServerDiffersFromActive()) return false; showToast(i18n.t('queue.crossServerEnqueueBlocked'), 4500, 'error'); return true; diff --git a/src/utils/mix/luckyMix.ts b/src/utils/mix/luckyMix.ts index daac7d0a..2fe33fdc 100644 --- a/src/utils/mix/luckyMix.ts +++ b/src/utils/mix/luckyMix.ts @@ -12,8 +12,10 @@ import { useLuckyMixStore } from '../../store/luckyMixStore'; import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable'; import { showToast } from '../ui/toast'; import { + bindQueueServerForPlayback, playbackServerDiffersFromActive, prepareActiveServerForNewMix, + shouldHandoffQueueToActiveServer, } from '../playback/playbackServer'; import { filterSongsForLuckyMixRatings, @@ -79,6 +81,7 @@ export async function buildAndPlayLuckyMix(): Promise { libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all', mixRatingFilter: mixRatingCfg, crossServerPlayback: playbackServerDiffersFromActive(), + handoffQueueToActive: shouldHandoffQueueToActiveServer(), }); if (!available) { logStep('abort_unavailable'); @@ -86,13 +89,20 @@ export async function buildAndPlayLuckyMix(): Promise { return; } + lucky.start(); + // Snapshot the current queue *before* we prune — so if the build fails // before we ever play a track, we can put it back the way it was instead // of leaving the user with an empty player. const playerStateBefore = usePlayerStore.getState(); - const queueSnapshot: { queue: Track[]; queueIndex: number } = { + const queueSnapshot: { + queue: Track[]; + queueIndex: number; + queueServerId: string | null; + } = { queue: [...playerStateBefore.queue], queueIndex: playerStateBefore.queueIndex, + queueServerId: playerStateBefore.queueServerId, }; // One undo step for the whole Lucky Mix run — internal prune/play/enqueue @@ -103,7 +113,7 @@ export async function buildAndPlayLuckyMix(): Promise { try { // Browsed server ≠ queue server: stop A's stream so Now Playing does not call // ensurePlaybackServerActive() and revert the UI mid-build. - if (playbackServerDiffersFromActive()) { + if (shouldHandoffQueueToActiveServer()) { prepareActiveServerForNewMix(); logStep('cross_server_handoff', { activeServerId }); } else { @@ -111,8 +121,6 @@ export async function buildAndPlayLuckyMix(): Promise { // tracks while the mix is still building (first playTrack may be delayed). usePlayerStore.getState().pruneUpcomingToCurrent(true); } - - lucky.start(); let startedPlayback = false; try { let allSeedSongs: SubsonicSong[] = []; @@ -175,6 +183,7 @@ export async function buildAndPlayLuckyMix(): Promise { const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length)); if (!toAdd.length) return 0; const before = mixQueueSize(); + bindQueueServerForPlayback(); usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true, true); const added = mixQueueSize() - before; logStep('append_queue_batch', { @@ -382,6 +391,7 @@ export async function buildAndPlayLuckyMix(): Promise { usePlayerStore.setState({ queue: queueSnapshot.queue, queueIndex: queueSnapshot.queueIndex, + queueServerId: queueSnapshot.queueServerId, }); logStep('queue_restored_after_failure', { restoredCount: queueSnapshot.queue.length, diff --git a/src/utils/playback/playbackServer.test.ts b/src/utils/playback/playbackServer.test.ts index e857d91b..a776387c 100644 --- a/src/utils/playback/playbackServer.test.ts +++ b/src/utils/playback/playbackServer.test.ts @@ -10,6 +10,7 @@ import { playbackServerDiffersFromActive, prepareActiveServerForNewMix, shouldBindQueueServerForPlay, + shouldHandoffQueueToActiveServer, } from './playbackServer'; import { invoke } from '@tauri-apps/api/core'; import { vi } from 'vitest'; @@ -78,6 +79,18 @@ describe('playbackServer', () => { expect(usePlayerStore.getState().queueServerId).toBe('a'); }); + it('shouldHandoffQueueToActiveServer when queue is unpinned but non-empty', () => { + useAuthStore.setState({ activeServerId: 'b' }); + usePlayerStore.setState({ queueServerId: null }); + expect(shouldHandoffQueueToActiveServer()).toBe(true); + expect(playbackServerDiffersFromActive()).toBe(false); + }); + + it('shouldHandoffQueueToActiveServer when queue server differs from active', () => { + useAuthStore.setState({ activeServerId: 'b' }); + expect(shouldHandoffQueueToActiveServer()).toBe(true); + }); + it('ensurePlaybackServerActive calls switch when servers differ', async () => { const { switchActiveServer } = await import('../server/switchActiveServer'); useAuthStore.setState({ activeServerId: 'b' }); diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts index d6d6e988..d9778739 100644 --- a/src/utils/playback/playbackServer.ts +++ b/src/utils/playback/playbackServer.ts @@ -34,12 +34,25 @@ export function playbackServerDiffersFromActive(): boolean { return !!activeSid && queueServerId !== activeSid; } +/** + * True when the current queue belongs to another server (or is unpinned legacy + * state) and a browsed-server mix should clear it before enqueueing new tracks. + */ +export function shouldHandoffQueueToActiveServer(): boolean { + const activeSid = useAuthStore.getState().activeServerId; + if (!activeSid) return false; + const { queue, queueServerId } = usePlayerStore.getState(); + if ((queue?.length ?? 0) === 0) return false; + if (!queueServerId) return true; + return queueServerId !== activeSid; +} + /** * Stop playback owned by another server so a new mix on the browsed server * can replace the queue (Lucky Mix / similar flows after ConnectionIndicator switch). */ export function prepareActiveServerForNewMix(): void { - if (!playbackServerDiffersFromActive()) return; + if (!shouldHandoffQueueToActiveServer()) return; usePlayerStore.getState().clearQueue(); bindQueueServerForPlayback(); }