Files
Psychotoxical-psysonic/src/utils/playback/playbackServer.ts
T

251 lines
9.4 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 type { ServerProfile } from '../../store/authStoreTypes';
import { usePlayerStore } from '../../store/playerStore';
import { switchActiveServer } from '../server/switchActiveServer';
import { sameQueueTrackId } from './queueIdentity';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../server/serverLookup';
import {
canonicalQueueServerKey,
resolveIndexKey,
serverIndexKeyForProfile,
serverIndexKeyFromUrl,
} from '../server/serverIndexKey';
import {
activeServerProfileId,
filterQueueRefsForServerProfile,
isMultiServerQueue,
profileIdFromQueueRef,
queueItemRefAt,
stampTrackServerIds,
} from './trackServerScope';
function playbackServerFromRef(ref: QueueItemRef): ServerProfile | undefined {
const profileId = profileIdFromQueueRef(ref);
return findServerByIdOrIndexKey(profileId) ?? findServerByIdOrIndexKey(ref.serverId);
}
/** Profile id for the currently playing queue item (mixed-server safe). */
export function getPlaybackServerId(): string {
const playingRef = queueItemRefAt();
if (playingRef?.serverId) {
const server = playbackServerFromRef(playingRef);
if (server) return server.id;
}
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
const resolved = resolveServerIdForIndexKey(queueServerId);
const server = findServerByIdOrIndexKey(resolved);
return server?.id ?? resolved;
}
return activeServerProfileId() ?? '';
}
export function getPlaybackIndexKey(): string {
const playingRef = queueItemRefAt();
if (playingRef?.serverId) {
const server = playbackServerFromRef(playingRef);
if (server) return serverIndexKeyForProfile(server) || server.id;
return playbackCacheKeyForRef(playingRef);
}
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
return resolveIndexKey(queueServerId);
}
const activeId = activeServerProfileId() ?? '';
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;
bindQueueServerId(sid);
}
/** Pin queue playback to an explicit server profile or index key. */
export function bindQueueServerId(serverId: string): void {
const server = findServerByIdOrIndexKey(serverId);
const canonical = server
? serverIndexKeyForProfile(server) || server.id
: canonicalQueueServerKey(serverId) || serverId;
usePlayerStore.setState({ queueServerId: canonical });
}
/**
* Pin the queue-level server anchor from incoming tracks.
* Per-item `QueueItemRef.serverId` remains authoritative for mixed-server queues;
* `queueServerId` is the first track's bucket (legacy hot-cache / sync hints).
*/
export function bindQueueServerForTracks(tracks: Track[]): void {
const scoped = stampTrackServerIds(tracks);
const sid = scoped[0]?.serverId ?? activeServerProfileId();
if (!sid) return;
bindQueueServerId(sid);
}
export function playbackCacheKeyForRef(ref: QueueItemRef | null | undefined): string {
if (ref?.serverId) {
return canonicalQueueServerKey(ref.serverId) || ref.serverId;
}
return getPlaybackCacheServerKey();
}
export function playbackProfileIdForRef(ref: QueueItemRef | null | undefined): string {
const key = playbackCacheKeyForRef(ref);
if (!key) return getPlaybackServerId();
return resolveServerIdForIndexKey(key) || key;
}
export function playbackCacheKeyForTrack(
track: Track,
ref?: QueueItemRef | null,
): string {
if (ref?.serverId) return playbackCacheKeyForRef(ref);
if (track.serverId) {
return canonicalQueueServerKey(track.serverId) || track.serverId;
}
return getPlaybackCacheServerKey();
}
export function playbackProfileIdForTrack(
track: Track,
ref?: QueueItemRef | null,
): string {
const key = playbackCacheKeyForTrack(track, ref);
return resolveServerIdForIndexKey(key) || key || getPlaybackServerId();
}
/**
* 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(tracks?: Track[]): string {
if (usePlayerStore.getState().queueServerId == null) {
if (tracks?.length) bindQueueServerForTracks(tracks);
else bindQueueServerForPlayback();
}
return usePlayerStore.getState().queueServerId ?? '';
}
export function clearQueueServerForPlayback(): void {
usePlayerStore.setState({ queueServerId: null });
}
export function playbackServerDiffersFromActive(): boolean {
const activeSid = activeServerProfileId();
if (!activeSid) return false;
const playingRef = queueItemRefAt();
if (playingRef?.serverId) {
const playbackSid = getPlaybackServerId();
return Boolean(playbackSid) && playbackSid !== activeSid;
}
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) === 0 || !queueServerId) return false;
return resolveServerIdForIndexKey(queueServerId) !== activeSid;
}
export function queueIsMultiServer(): boolean {
return isMultiServerQueue(usePlayerStore.getState().queueItems);
}
/** Refs owned by the server that is currently playing (mixed-queue safe). */
export function filterQueueRefsForPlaybackServer(refs: QueueItemRef[]): QueueItemRef[] {
const playbackSid = getPlaybackServerId();
if (!playbackSid) return [];
return filterQueueRefsForServerProfile(refs, playbackSid);
}
/**
* 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));
}