mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
cb1a110afb
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { coverServerScopeForServerId } from './serverScope';
|
|
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
|
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
|
|
import {
|
|
albumHasDistinctDiscCovers,
|
|
coverEntryToRef,
|
|
resolveAlbumCoverEntry,
|
|
resolveArtistCoverEntry,
|
|
resolveTrackCoverEntry,
|
|
} from './resolveEntry';
|
|
|
|
export type { CoverEntry } from './resolveEntry';
|
|
export { albumHasDistinctDiscCovers } from './resolveEntry';
|
|
|
|
export type AlbumCoverRefOptions = {
|
|
serverScope?: CoverServerScope;
|
|
distinctDiscCovers?: boolean;
|
|
};
|
|
|
|
const albumDistinctDiscCoversByAlbumId = new Map<string, boolean>();
|
|
|
|
export function rememberAlbumDistinctDiscCovers(
|
|
albumId: string,
|
|
songs: ReadonlyArray<Pick<SubsonicSong, 'discNumber' | 'coverArt' | 'id' | 'albumId'>>,
|
|
): void {
|
|
const id = albumId.trim();
|
|
if (!id) return;
|
|
albumDistinctDiscCoversByAlbumId.set(id, albumHasDistinctDiscCovers(songs));
|
|
}
|
|
|
|
export function forgetAlbumDistinctDiscCovers(albumId: string): void {
|
|
albumDistinctDiscCoversByAlbumId.delete(albumId.trim());
|
|
}
|
|
|
|
export type DistinctDiscCoversHint = Pick<
|
|
SubsonicSong,
|
|
'discNumber' | 'coverArt' | 'id' | 'albumId'
|
|
>;
|
|
|
|
/**
|
|
* Whether per-disc `mf-*` cache slots apply — from album tracklist memory or song hints
|
|
* when the album page has not been opened yet.
|
|
*/
|
|
export function resolveDistinctDiscCoversForAlbum(
|
|
albumId: string,
|
|
fetchCoverArtId?: string | null,
|
|
songHint?: DistinctDiscCoversHint,
|
|
): boolean {
|
|
const album = albumId.trim();
|
|
if (!album) return false;
|
|
|
|
const known = albumDistinctDiscCoversByAlbumId.get(album);
|
|
if (known === true) return true;
|
|
if (known === false) return false;
|
|
|
|
if (songHint) {
|
|
const cover = songHint.coverArt?.trim();
|
|
if ((songHint.discNumber ?? 1) > 1 && Boolean(cover && cover !== album)) return true;
|
|
}
|
|
|
|
const fetch = fetchCoverArtId?.trim();
|
|
if (fetch && fetch !== album && fetch.startsWith('mf-')) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
function resolveAlbumCoverRefOptions(
|
|
third?: CoverServerScope | AlbumCoverRefOptions,
|
|
): { serverScope: CoverServerScope; distinctDiscCovers: boolean } {
|
|
if (!third || 'kind' in third) {
|
|
return { serverScope: third ?? { kind: 'active' }, distinctDiscCovers: false };
|
|
}
|
|
return {
|
|
serverScope: third.serverScope ?? { kind: 'active' },
|
|
distinctDiscCovers: third.distinctDiscCovers ?? false,
|
|
};
|
|
}
|
|
|
|
/** @deprecated Use {@link resolveAlbumCoverEntry}. */
|
|
export function resolveAlbumCoverCacheEntityId(
|
|
albumId: string,
|
|
fetchCoverArtId?: string | null,
|
|
distinctDiscCovers = false,
|
|
): string {
|
|
return resolveAlbumCoverEntry(albumId, fetchCoverArtId, distinctDiscCovers)?.cacheEntityId ?? '';
|
|
}
|
|
|
|
/**
|
|
* Sync fallback for cover identity — UI should prefer {@link useAlbumCoverRef} /
|
|
* {@link AlbumCoverArtImage}; async paths should use {@link resolveAlbumCoverRefFromLibrary}.
|
|
*/
|
|
export function albumCoverRef(
|
|
albumId: string,
|
|
fetchCoverArtId?: string | null,
|
|
scopeOrOpts: CoverServerScope | AlbumCoverRefOptions = { kind: 'active' },
|
|
): CoverArtRef {
|
|
const { serverScope, distinctDiscCovers } = resolveAlbumCoverRefOptions(scopeOrOpts);
|
|
const entry = resolveAlbumCoverEntry(albumId, fetchCoverArtId, distinctDiscCovers);
|
|
if (!entry) {
|
|
const id = (fetchCoverArtId ?? albumId).trim();
|
|
return coverEntryToRef(
|
|
{ cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id },
|
|
serverScope,
|
|
);
|
|
}
|
|
return coverEntryToRef(entry, serverScope);
|
|
}
|
|
|
|
export function albumCoverRefForSong(
|
|
song: Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
|
|
distinctDiscCovers?: boolean,
|
|
serverScope: CoverServerScope = { kind: 'active' },
|
|
): CoverArtRef | undefined {
|
|
const albumId = song.albumId?.trim();
|
|
const distinct =
|
|
distinctDiscCovers
|
|
?? (albumId ? resolveDistinctDiscCoversForAlbum(albumId, song.coverArt, song) : false);
|
|
const entry = resolveTrackCoverEntry(song, distinct);
|
|
return entry ? coverEntryToRef(entry, serverScope) : undefined;
|
|
}
|
|
|
|
export function albumCoverRefForPlayback(
|
|
track: Pick<SubsonicSong, 'coverArt' | 'id' | 'discNumber'> & { albumId?: string | null },
|
|
serverScope: CoverServerScope = resolvePlaybackCoverScope(),
|
|
): CoverArtRef | undefined {
|
|
const albumId = track.albumId?.trim();
|
|
if (!albumId) return undefined;
|
|
const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(
|
|
albumId,
|
|
track.coverArt,
|
|
{ ...track, albumId } as DistinctDiscCoversHint,
|
|
);
|
|
return albumCoverRefForSong(
|
|
{ ...track, albumId } as Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
|
|
distinctDiscCovers,
|
|
serverScope,
|
|
);
|
|
}
|
|
|
|
export function artistCoverRef(
|
|
artistId: string,
|
|
fetchCoverArtId?: string | null,
|
|
serverScope: CoverServerScope = { kind: 'active' },
|
|
): CoverArtRef {
|
|
const entry = resolveArtistCoverEntry(artistId, fetchCoverArtId);
|
|
if (!entry) {
|
|
const id = (fetchCoverArtId ?? artistId).trim();
|
|
return coverEntryToRef(
|
|
{ cacheKind: 'artist', cacheEntityId: id, fetchCoverArtId: id },
|
|
serverScope,
|
|
);
|
|
}
|
|
return coverEntryToRef(entry, serverScope);
|
|
}
|
|
|
|
export function coverRefFromEntity(
|
|
cacheKind: CoverCacheKind,
|
|
cacheEntityId: string,
|
|
fetchCoverArtId?: string | null,
|
|
serverScope: CoverServerScope = { kind: 'active' },
|
|
): CoverArtRef {
|
|
const entry =
|
|
cacheKind === 'artist'
|
|
? resolveArtistCoverEntry(cacheEntityId, fetchCoverArtId)
|
|
: resolveAlbumCoverEntry(cacheEntityId, fetchCoverArtId);
|
|
if (!entry) {
|
|
const id = (fetchCoverArtId ?? cacheEntityId).trim();
|
|
return coverEntryToRef(
|
|
{ cacheKind, cacheEntityId: id, fetchCoverArtId: id },
|
|
serverScope,
|
|
);
|
|
}
|
|
return coverEntryToRef(entry, serverScope);
|
|
}
|
|
|
|
/** @deprecated Prefer entity helpers in {@link resolveEntry}. */
|
|
export function coverArtRef(
|
|
coverArtId: CoverArtId,
|
|
serverScope: CoverServerScope = { kind: 'active' },
|
|
): CoverArtRef {
|
|
const id = coverArtId.trim();
|
|
if (id.startsWith('ar-')) return artistCoverRef(id, id, serverScope);
|
|
return albumCoverRef(id, id, serverScope);
|
|
}
|
|
|
|
export function resolvePlaybackCoverScope(): CoverServerScope {
|
|
const playbackSid = getPlaybackServerId();
|
|
if (!playbackSid) return { kind: 'playback' };
|
|
const activeSid = useAuthStore.getState().activeServerId;
|
|
if (playbackSid === activeSid) return { kind: 'playback' };
|
|
return coverServerScopeForServerId(playbackSid);
|
|
}
|