diff --git a/CHANGELOG.md b/CHANGELOG.md index b939f9b4..a6e50bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/store/b1QueueServerIdentity.test.ts b/src/store/b1QueueServerIdentity.test.ts index 0cda7718..2c607330 100644 --- a/src/store/b1QueueServerIdentity.test.ts +++ b/src/store/b1QueueServerIdentity.test.ts @@ -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); + }); +}); + diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index 9fd9f990..5d01375e 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -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) {