mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
c0d7079e88
commit
293672abbf
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user