mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
2a88ca3248
* fix(queue): extend server-pin contract to auto-add paths The infinite-queue top-up and radio top-up paths in nextAction.ts read state.queueServerId directly inside their set callbacks. When the queue was populated without a queue-replacing playTrack (single- track enqueue from a SongRow + button, AdvancedSearch row, etc), queueServerId stayed null, seedQueueResolver skipped its store-write under the if (serverId) guard, and the auto-added refs landed with an empty server key. Every auto-added row rendered as the resolver placeholder (… / 0:00) until the next time something happened to bind the server. Same symptom PR #892 fixed for the manual enqueue surface, just on the auto-add paths. Extract ensureQueueServerPinned() from the private helper in queueMutationActions.ts into playbackServer.ts so it can be shared. Call it before every set callback that appends or splices refs in nextAction.ts — appendTracksAndPlayFirst, proactive infinite top-up, proactive radio top-up. Helper returns the pinned canonical key so the caller does not need a second store read. Regression coverage in ensureQueueServerPinned.test.ts: pin on null + active server, idempotent on already-bound, empty-string fallback when no active server, canonical-key return value matches what toQueueItemRefs expects (not the raw auth uuid). Existing b1QueueServerIdentity.test.ts continues to cover the manual enqueue surface unchanged. * docs(release): CHANGELOG for queue auto-top-up placeholder fix (PR #930)
159 lines
6.5 KiB
TypeScript
159 lines
6.5 KiB
TypeScript
import { buildCoverArtFetchUrl } from '../../cover/fetchUrl';
|
|
import { resolvePlaybackCoverScope } from '../../cover/ref';
|
|
import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntry';
|
|
import { coverStorageKeyFromRef } from '../../cover/storageKeys';
|
|
import { resolveCoverDisplayTier } from '../../cover/tiers';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { usePlayerStore } from '../../store/playerStore';
|
|
import { switchActiveServer } from '../server/switchActiveServer';
|
|
import { sameQueueTrackId } from './queueIdentity';
|
|
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
|
|
import { resolveServerIdForIndexKey } from '../server/serverLookup';
|
|
import {
|
|
resolveIndexKey,
|
|
serverIndexKeyForProfile,
|
|
serverIndexKeyFromUrl,
|
|
} from '../server/serverIndexKey';
|
|
|
|
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
|
|
export function getPlaybackServerId(): string {
|
|
const { queueServerId, queueItems } = usePlayerStore.getState();
|
|
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
|
|
return resolveServerIdForIndexKey(queueServerId);
|
|
}
|
|
return useAuthStore.getState().activeServerId ?? '';
|
|
}
|
|
|
|
export function getPlaybackIndexKey(): string {
|
|
const { queueServerId, queueItems } = usePlayerStore.getState();
|
|
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
|
|
return resolveIndexKey(queueServerId);
|
|
}
|
|
const activeId = useAuthStore.getState().activeServerId ?? '';
|
|
if (!activeId) return '';
|
|
const server = useAuthStore.getState().servers.find(s => s.id === activeId);
|
|
return server ? serverIndexKeyFromUrl(server.url) || activeId : activeId;
|
|
}
|
|
|
|
/**
|
|
* Canonical cache/storage key for playback-owned artifacts (offline/hot-cache).
|
|
* Falls back to legacy UUID when an indexKey cannot be resolved yet.
|
|
*/
|
|
export function getPlaybackCacheServerKey(): string {
|
|
const indexKey = getPlaybackIndexKey();
|
|
if (indexKey) return indexKey;
|
|
return getPlaybackServerId();
|
|
}
|
|
|
|
export function bindQueueServerForPlayback(): void {
|
|
const sid = useAuthStore.getState().activeServerId;
|
|
if (!sid) return;
|
|
const server = useAuthStore.getState().servers.find(s => s.id === sid);
|
|
// Canonical index key on writes so mixed-server queues stay unambiguous —
|
|
// every ref/queue-level server identifier follows the same shape that the
|
|
// library index already uses. Falls back to the raw id when the server
|
|
// profile cannot be resolved (e.g. tests with a stubbed auth store).
|
|
const canonical = server ? serverIndexKeyForProfile(server) || sid : sid;
|
|
usePlayerStore.setState({ queueServerId: canonical });
|
|
}
|
|
|
|
/**
|
|
* Bind `queueServerId` via {@link bindQueueServerForPlayback} when it is still
|
|
* null, then return the (now-bound) server identifier. Call this synchronously
|
|
* before any state mutation that adds new tracks to the queue.
|
|
*
|
|
* Without the pin, refs land with an empty server key, {@link seedQueueResolver}
|
|
* skips its store-write, and queue rows render as the resolver placeholder
|
|
* (`…` / 0:00) until something else binds the server (see PR #892). Affects
|
|
* both the manual enqueue mutations and the auto-add paths (infinite-queue
|
|
* top-up, radio top-up).
|
|
*
|
|
* Idempotent: no-op when already pinned. Returns `''` when no active server is
|
|
* available to pin (e.g. unit tests without an authed store).
|
|
*/
|
|
export function ensureQueueServerPinned(): string {
|
|
if (usePlayerStore.getState().queueServerId == null) {
|
|
bindQueueServerForPlayback();
|
|
}
|
|
return usePlayerStore.getState().queueServerId ?? '';
|
|
}
|
|
|
|
export function clearQueueServerForPlayback(): void {
|
|
usePlayerStore.setState({ queueServerId: null });
|
|
}
|
|
|
|
export function playbackServerDiffersFromActive(): boolean {
|
|
const { queueServerId, queueItems } = usePlayerStore.getState();
|
|
if ((queueItems?.length ?? 0) === 0 || !queueServerId) return false;
|
|
const activeSid = useAuthStore.getState().activeServerId;
|
|
const resolvedQueue = resolveServerIdForIndexKey(queueServerId);
|
|
return !!activeSid && resolvedQueue !== 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 { queueItems, queueServerId } = usePlayerStore.getState();
|
|
if ((queueItems?.length ?? 0) === 0) return false;
|
|
if (!queueServerId) return true;
|
|
return resolveServerIdForIndexKey(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 (!shouldHandoffQueueToActiveServer()) return;
|
|
usePlayerStore.getState().clearQueue();
|
|
bindQueueServerForPlayback();
|
|
}
|
|
|
|
/** Switch the browsed server to the queue server when they differ (e.g. artist/album links). */
|
|
export async function ensurePlaybackServerActive(): Promise<boolean> {
|
|
if (!playbackServerDiffersFromActive()) return true;
|
|
const playbackSid = getPlaybackServerId();
|
|
const server = useAuthStore.getState().servers.find(s => s.id === playbackSid);
|
|
if (!server) return false;
|
|
return switchActiveServer(server);
|
|
}
|
|
|
|
/** Cover fetch URL + storage key for queue prefetch (displayCssPx = layout CSS px). */
|
|
export function playbackCoverArtForAlbum(
|
|
albumId: string,
|
|
coverArt: string,
|
|
displayCssPx: number,
|
|
): { src: string; cacheKey: string } {
|
|
const entry = resolveAlbumCoverEntry(albumId, coverArt);
|
|
if (!entry) {
|
|
return playbackCoverArtForId(coverArt, displayCssPx);
|
|
}
|
|
const ref = coverEntryToRef(entry, resolvePlaybackCoverScope());
|
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
|
|
return {
|
|
src: buildCoverArtFetchUrl(ref, tier),
|
|
cacheKey: coverStorageKeyFromRef(ref, tier),
|
|
};
|
|
}
|
|
|
|
/** @deprecated Use {@link playbackCoverArtForAlbum} with album id. */
|
|
export function playbackCoverArtForId(coverId: string, displayCssPx: number): { src: string; cacheKey: string } {
|
|
return playbackCoverArtForAlbum(coverId, coverId, displayCssPx);
|
|
}
|
|
|
|
export function shouldBindQueueServerForPlay(
|
|
prevQueue: QueueItemRef[],
|
|
newQueue: Track[],
|
|
explicitQueueArg: Track[] | undefined,
|
|
): boolean {
|
|
if (newQueue.length === 0) return false;
|
|
if (prevQueue.length === 0) return true;
|
|
if (explicitQueueArg === undefined) return false;
|
|
if (explicitQueueArg.length !== prevQueue.length) return true;
|
|
return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.trackId, t.id));
|
|
}
|