mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user