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
This commit is contained in:
cucadmuh
2026-05-18 23:38:47 +03:00
committed by GitHub
parent d7b23b3c08
commit 64a8a0ed8a
6 changed files with 48 additions and 5 deletions
+4
View File
@@ -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
+1
View File
@@ -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)',
],
},
+2
View File
@@ -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;
+14 -4
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
usePlayerStore.setState({
queue: queueSnapshot.queue,
queueIndex: queueSnapshot.queueIndex,
queueServerId: queueSnapshot.queueServerId,
});
logStep('queue_restored_after_failure', {
restoredCount: queueSnapshot.queue.length,
+13
View File
@@ -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' });
+14 -1
View File
@@ -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();
}