fix(queue): pin queueServerId on first enqueue so refs resolve (#892)

* fix(queue): pin queueServerId on first enqueue so refs resolve (thin-state)

Adding a single track from a page that doesn't replace the queue (Advanced
Search row, SongRow + button, SongCard) left queueServerId null whenever
the app had not yet seen a queue-replacing playTrack. seedIncoming then
became a no-op, the new refs landed with an empty server key, and the
queue panel rendered every row as the resolver placeholder ("…" / 0:00)
until the next time something happened to bind the server.

Add an ensureQueueServerPinned step at the entry of every add-to-queue
mutation (enqueue / enqueueAt / playNext / enqueueRadio). It runs after
blockCrossServerEnqueue so a guarded cross-server enqueue still bails
without touching the pin, and after the undo snapshot so undo restores the
pre-pin baseline. Idempotent: no-op when already pinned or when no active
server is available to pin (e.g. unit tests without an authed store).

Regression cluster in b1QueueServerIdentity.test.ts covers enqueue /
enqueueAt / enqueueRadio cache-hit after pin, the no-active-server
fallback, and the already-pinned no-op.

* docs(release): CHANGELOG for queue placeholder fix (PR #892)
This commit is contained in:
Frank Stellmacher
2026-05-29 22:47:15 +02:00
committed by GitHub
parent c0d7079e88
commit 293672abbf
3 changed files with 105 additions and 1 deletions
+9
View File
@@ -533,6 +533,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Queue — new tracks no longer render as blank placeholders
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#892](https://github.com/Psychotoxical/psysonic/pull/892)**
* Adding tracks to the queue from Advanced Search results, song rows, or song cards right after launch could show every new entry as `…` / `0:00` instead of the real title and duration, until something else triggered a queue-replacing playback.
* Root cause: the queue's owning server was not pinned yet, so the resolver cache skipped seeding the incoming tracks. Add-to-queue mutations now pin the active server up-front.
## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
+76
View File
@@ -306,3 +306,79 @@ describe('B1 — queue sync emits track ids only, server identity flows out-of-b
});
});
// ── Add-to-queue mutations pin the active server when queueServerId is null ─
//
// Regression for the queue-blanking bug: the first user action after launch is
// often a single-track enqueue (e.g. clicking the + button on a search result
// row), not a queue-replacing playTrack. Before the pin, `queueServerId`
// stayed null, `seedIncoming` was a no-op, the refs landed with empty server
// keys, and every new queue row rendered as the resolver placeholder ("…" +
// 0:00) until the user happened to trigger a path that did call
// `bindQueueServerForPlayback`.
describe('B1+ — add-to-queue mutations pin queueServerId when it is null', () => {
it('enqueue seeds the cache so refs resolve to the real track instead of the placeholder', () => {
expect(usePlayerStore.getState().queueServerId).toBeNull();
const t = track('t1', 'Real Title');
usePlayerStore.getState().enqueue([t], true);
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
const refs = usePlayerStore.getState().queueItems;
expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
expect(getCachedTrack(refs[0])).toEqual(expect.objectContaining({
id: 't1',
title: 'Real Title',
}));
});
it('enqueueAt pins and seeds when queueServerId is null', () => {
expect(usePlayerStore.getState().queueServerId).toBeNull();
const t = track('t1', 'Inserted');
usePlayerStore.getState().enqueueAt([t], 0, true);
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
const refs = usePlayerStore.getState().queueItems;
expect(refs[0]).toEqual({ serverId: KEY_A, trackId: 't1' });
expect(getCachedTrack(refs[0])?.title).toBe('Inserted');
});
it('enqueueRadio pins and seeds when queueServerId is null', () => {
expect(usePlayerStore.getState().queueServerId).toBeNull();
const t = track('r1', 'Radio Track');
usePlayerStore.getState().enqueueRadio([t], 'artist-x');
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
const refs = usePlayerStore.getState().queueItems;
expect(refs[0]).toEqual(expect.objectContaining({ serverId: KEY_A, trackId: 'r1' }));
expect(getCachedTrack(refs[0])?.title).toBe('Radio Track');
});
it('does not crash or pin when no active server is available', () => {
useAuthStore.setState({ activeServerId: null });
expect(usePlayerStore.getState().queueServerId).toBeNull();
usePlayerStore.getState().enqueue([track('t1', 'X')], true);
// No active server → bindQueueServerForPlayback is a no-op. The mutation
// still runs (matches the pre-fix baseline behaviour) — placeholder UI is
// the expected fallback when no server can be pinned.
expect(usePlayerStore.getState().queueServerId).toBeNull();
expect(usePlayerStore.getState().queueItems).toHaveLength(1);
});
it('does not overwrite an already-pinned queueServerId', () => {
useAuthStore.setState({ activeServerId: SERVER_B.id });
usePlayerStore.setState({ queueServerId: KEY_A });
usePlayerStore.getState().enqueue([track('t1', 'Y')], true);
// Already pinned → ensureQueueServerPinned is a no-op even though the
// active server has since switched (cross-server enqueue is blocked
// elsewhere via `blockCrossServerEnqueue`, not here).
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
});
});
+20 -1
View File
@@ -21,7 +21,11 @@ import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import i18n from '../i18n';
import { playListenSessionFinalize } from './playListenSession';
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
import {
bindQueueServerForPlayback,
clearQueueServerForPlayback,
playbackServerDiffersFromActive,
} from '../utils/playback/playbackServer';
import { useLuckyMixStore } from './luckyMixStore';
import { showToast } from '../utils/ui/toast';
@@ -53,6 +57,17 @@ function seedIncoming(state: PlayerState, tracks: Track[]): void {
if (serverId) seedQueueResolver(serverId, tracks);
}
/** Pin the active server before a mutation adds new tracks. Without this an
* enqueue against a still-null `queueServerId` (e.g. the user's first action
* is a single-track enqueue rather than a queue-replacing playTrack) leaves
* `seedIncoming` as a no-op — the refs land with the empty server key, the
* cache stays cold, and every new row renders as the resolver placeholder
* in the queue panel. No-op when already pinned, or when no active server
* is available (e.g. unit tests without an authed auth store). */
function ensureQueueServerPinned(state: PlayerState): void {
if (state.queueServerId == null) bindQueueServerForPlayback();
}
/**
* Eleven queue-mutation actions. Shared invariant: every action except
* `setRadioArtistId` pushes a queue-undo snapshot and calls
@@ -84,6 +99,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
return;
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
ensureQueueServerPinned(get());
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
@@ -115,6 +131,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
setCurrentRadioArtistId(artistId);
}
pushQueueUndoFromGetter(get);
ensureQueueServerPinned(get());
set(state => {
const items = itemsOf(state);
// Drop all upcoming (not yet played) radio tracks — clicking "Start Radio"
@@ -166,6 +183,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
return;
}
pushQueueUndoFromGetter(get);
ensureQueueServerPinned(get());
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
@@ -184,6 +202,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
playNext: (tracks) => {
if (tracks.length === 0) return;
if (blockCrossServerEnqueue()) return;
ensureQueueServerPinned(get());
const state = get();
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
if (!state.currentTrack) {