mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-28 10:06:47 +00:00
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
|
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
|
import {
|
|
albumCoverRef,
|
|
albumCoverRefForPlayback,
|
|
albumCoverRefForSong,
|
|
artistCoverRef,
|
|
resolveDistinctDiscCoversForAlbum,
|
|
resolvePlaybackCoverScope,
|
|
} from './ref';
|
|
import { coverServerScopeForServerId } from './serverScope';
|
|
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
|
import { queueItemRefMatchesTrack } from '@/features/playback/utils/playback/queueIdentity';
|
|
import {
|
|
resolveAlbumCoverRefFromLibrary,
|
|
resolveArtistCoverRefFromLibrary,
|
|
resolveTrackCoverRefFromLibrary,
|
|
} from './resolveEntryLibrary';
|
|
import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types';
|
|
|
|
function coverRefsEqual(a: CoverArtRef, b: CoverArtRef): boolean {
|
|
return (
|
|
a.cacheKind === b.cacheKind
|
|
&& a.cacheEntityId === b.cacheEntityId
|
|
&& a.fetchCoverArtId === b.fetchCoverArtId
|
|
&& coverScopeKey(a.serverScope) === coverScopeKey(b.serverScope)
|
|
);
|
|
}
|
|
|
|
function applySyncRef<T extends CoverArtRef | null | undefined>(
|
|
setRef: Dispatch<SetStateAction<T>>,
|
|
syncRef: T,
|
|
): void {
|
|
setRef(prev => {
|
|
if (!syncRef) return syncRef;
|
|
if (prev && coverRefsEqual(prev, syncRef)) return prev;
|
|
return syncRef;
|
|
});
|
|
}
|
|
|
|
export type LibraryCoverRefOptions = {
|
|
/**
|
|
* When false, use API/index `coverArt` only — no per-mount `library_resolve_cover_entry`.
|
|
* Default for browse/search grids is false at the component layer; enable on album/artist
|
|
* detail headers and queue rows that need per-disc slots from SQLite.
|
|
*/
|
|
libraryResolve?: boolean;
|
|
};
|
|
|
|
/** Album grid / card — sync fallback, then local library index when indexed. */
|
|
export function useAlbumCoverRef(
|
|
albumId: string | null | undefined,
|
|
fallbackCoverArt?: string | null,
|
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
|
options?: LibraryCoverRefOptions,
|
|
): CoverArtRef | null {
|
|
const libraryResolve = options?.libraryResolve !== false;
|
|
const scopeKey = coverScopeKey(serverScope);
|
|
const distinctDiscCovers = useMemo(
|
|
() => resolveDistinctDiscCoversForAlbum(albumId ?? ''),
|
|
[albumId],
|
|
);
|
|
const syncRef = useMemo(() => {
|
|
const id = albumId?.trim();
|
|
if (!id) return null;
|
|
return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers });
|
|
// `serverScope` is keyed via stable `scopeKey` — see effect deps below.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [albumId, fallbackCoverArt, scopeKey, distinctDiscCovers]);
|
|
|
|
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
|
|
|
useEffect(() => {
|
|
applySyncRef(setRef, syncRef);
|
|
if (!libraryResolve) return;
|
|
const id = albumId?.trim();
|
|
if (!id) return;
|
|
let cancelled = false;
|
|
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
|
|
if (!cancelled) {
|
|
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// serverScope is keyed via the stable `scopeKey` string (and via syncRef);
|
|
// depending on the object directly would re-resolve from SQLite on every
|
|
// render when the scope identity changes but its content does not.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
|
|
|
|
return libraryResolve ? ref : syncRef;
|
|
}
|
|
|
|
/**
|
|
* Browse track-list rows — **library fetch id first**, then mount CoverArtImage.
|
|
* SQLite often stores per-track `mf-*` in `cover_art_id`; the sync album ref would
|
|
* race ensure with the wrong id before `library_resolve_cover_entry` returns the
|
|
* album row's `al-*` (or album id fallback).
|
|
*/
|
|
export function useBrowseListAlbumCoverRef(
|
|
albumId: string | null | undefined,
|
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
|
): CoverArtRef | null {
|
|
const scopeKey = coverScopeKey(serverScope);
|
|
const id = albumId?.trim() ?? '';
|
|
const [ref, setRef] = useState<CoverArtRef | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!id) {
|
|
// React Compiler set-state-in-effect rule: clear stale ref when album id disappears.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setRef(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setRef(null);
|
|
void resolveAlbumCoverRefFromLibrary(id, id, serverScope).then(next => {
|
|
if (!cancelled) setRef(next);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// serverScope keyed via scopeKey — see useAlbumCoverRef.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [id, scopeKey]);
|
|
|
|
return ref;
|
|
}
|
|
|
|
/** Multi-disc browse rows — library-resolved per-disc slot before ensure. */
|
|
export function useBrowseListTrackCoverRef(
|
|
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | null | undefined,
|
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
|
): CoverArtRef | undefined {
|
|
const scopeKey = coverScopeKey(serverScope);
|
|
const songId = song?.id?.trim() ?? '';
|
|
const albumId = song?.albumId?.trim() ?? '';
|
|
const coverArt = song?.coverArt;
|
|
const discNumber = song?.discNumber;
|
|
const [ref, setRef] = useState<CoverArtRef | undefined>(undefined);
|
|
|
|
useEffect(() => {
|
|
if (!songId || !albumId || !song) {
|
|
// React Compiler set-state-in-effect rule: clear stale ref when track identity clears.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setRef(undefined);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setRef(undefined);
|
|
void resolveTrackCoverRefFromLibrary(
|
|
{ id: songId, albumId, coverArt, discNumber },
|
|
serverScope,
|
|
).then(next => {
|
|
if (!cancelled) setRef(next ?? undefined);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// serverScope keyed via scopeKey — see useTrackCoverRef.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [songId, albumId, coverArt, discNumber, scopeKey]);
|
|
|
|
return ref;
|
|
}
|
|
|
|
/** Artist grid — sync fallback, then library index. */
|
|
export function useArtistCoverRef(
|
|
artistId: string | null | undefined,
|
|
fallbackCoverArt?: string | null,
|
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
|
options?: LibraryCoverRefOptions,
|
|
): CoverArtRef | null {
|
|
const libraryResolve = options?.libraryResolve !== false;
|
|
const scopeKey = coverScopeKey(serverScope);
|
|
const syncRef = useMemo(() => {
|
|
const id = artistId?.trim();
|
|
if (!id) return null;
|
|
return artistCoverRef(id, fallbackCoverArt, serverScope);
|
|
// `serverScope` is keyed via stable `scopeKey` — see effect deps below.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [artistId, fallbackCoverArt, scopeKey]);
|
|
|
|
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
|
|
|
|
useEffect(() => {
|
|
applySyncRef(setRef, syncRef);
|
|
if (!libraryResolve) return;
|
|
const id = artistId?.trim();
|
|
if (!id) return;
|
|
let cancelled = false;
|
|
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
|
|
if (!cancelled) {
|
|
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// serverScope is keyed via the stable `scopeKey` string (and via syncRef);
|
|
// depending on the object directly would re-resolve from SQLite on every
|
|
// render when the scope identity changes but its content does not.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
|
|
|
|
return libraryResolve ? ref : syncRef;
|
|
}
|
|
|
|
/** Track row / song card — album-scoped; multi-CD from library when indexed. */
|
|
export function useTrackCoverRef(
|
|
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | null | undefined,
|
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
|
options?: LibraryCoverRefOptions,
|
|
): CoverArtRef | undefined {
|
|
const libraryResolve = options?.libraryResolve !== false;
|
|
const scopeKey = coverScopeKey(serverScope);
|
|
const songId = song?.id;
|
|
const albumId = song?.albumId;
|
|
const coverArt = song?.coverArt;
|
|
const discNumber = song?.discNumber;
|
|
|
|
const distinctDiscCovers = useMemo(
|
|
() => (albumId?.trim() ? resolveDistinctDiscCoversForAlbum(albumId) : false),
|
|
[albumId],
|
|
);
|
|
|
|
const syncRef = useMemo(() => {
|
|
if (!songId?.trim() || !albumId?.trim()) return undefined;
|
|
return albumCoverRefForSong(
|
|
{ id: songId, albumId, coverArt, discNumber },
|
|
distinctDiscCovers,
|
|
serverScope,
|
|
);
|
|
// `serverScope` is keyed via stable `scopeKey`.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers, scopeKey]);
|
|
|
|
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
|
|
|
|
useEffect(() => {
|
|
applySyncRef(setRef, syncRef);
|
|
if (!libraryResolve) return;
|
|
const trackId = songId?.trim();
|
|
const al = albumId?.trim();
|
|
if (!trackId || !al || !song) return;
|
|
let cancelled = false;
|
|
void resolveTrackCoverRefFromLibrary(
|
|
{ ...song, id: trackId, albumId: al },
|
|
serverScope,
|
|
distinctDiscCovers,
|
|
).then(next => {
|
|
if (!cancelled) {
|
|
setRef(prev => {
|
|
if (!next) return undefined;
|
|
if (
|
|
prev
|
|
&& prev.cacheKind === 'album'
|
|
&& next.cacheKind === 'album'
|
|
&& al
|
|
&& next.cacheEntityId === al
|
|
&& prev.cacheEntityId !== al
|
|
&& prev.fetchCoverArtId !== next.fetchCoverArtId
|
|
) {
|
|
return prev;
|
|
}
|
|
if (prev && coverRefsEqual(prev, next)) return prev;
|
|
return next;
|
|
});
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// serverScope is keyed via the stable `scopeKey` string; depending on the
|
|
// object directly would re-resolve from SQLite on every render when the
|
|
// scope identity changes but its content does not.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers]);
|
|
|
|
return libraryResolve ? ref : syncRef;
|
|
}
|
|
|
|
/** Now playing / queue — playback server scope + library-backed multi-CD. */
|
|
export function usePlaybackTrackCoverRef(
|
|
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
|
|
): CoverArtRef | undefined {
|
|
const queueServerId = usePlayerStore(s => s.queueServerId);
|
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
|
const queueItems = usePlayerStore(s => s.queueItems);
|
|
const queueLength = usePlayerStore(s => s.queueItems.length);
|
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
|
const serversFingerprint = useAuthStore(s =>
|
|
s.servers
|
|
.map(srv => `${srv.id}\u0001${srv.url}\u0001${srv.username}\u0001${srv.password}`)
|
|
.join('\u0002'),
|
|
);
|
|
|
|
const scope = useMemo(() => {
|
|
if (track?.id) {
|
|
const ref = queueItems[queueIndex];
|
|
if (ref && queueItemRefMatchesTrack(ref, track)) {
|
|
const profileId = resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
|
|
return coverServerScopeForServerId(profileId);
|
|
}
|
|
const scopedTrack = track as { serverId?: string };
|
|
if (scopedTrack.serverId) {
|
|
return coverServerScopeForServerId(scopedTrack.serverId);
|
|
}
|
|
}
|
|
return resolvePlaybackCoverScope();
|
|
// queueServerId/queueLength/activeServerId/serversFingerprint look unused but
|
|
// are intentional recompute triggers: resolvePlaybackCoverScope() and
|
|
// resolveServerIdForIndexKey() read global server/queue state, so the scope
|
|
// must re-derive when those change.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [track, queueItems, queueIndex, queueServerId, queueLength, activeServerId, serversFingerprint]);
|
|
const scopeKey = coverScopeKey(scope);
|
|
|
|
const trackId = track?.id;
|
|
const albumId = track?.albumId;
|
|
const coverArt = track?.coverArt;
|
|
const discNumber = track?.discNumber;
|
|
|
|
const syncRef = useMemo(() => {
|
|
if (!albumId?.trim() || !track) return undefined;
|
|
return albumCoverRefForPlayback(track, scope);
|
|
// `scope` is keyed via the stable `scopeKey` string; the primitive track
|
|
// fields recompute the ref when the playing track changes.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [track, trackId, albumId, coverArt, discNumber, scopeKey]);
|
|
|
|
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
|
|
|
|
useEffect(() => {
|
|
applySyncRef(setRef, syncRef);
|
|
const tid = trackId?.trim();
|
|
const al = albumId?.trim();
|
|
if (!tid || !al || !track) return;
|
|
let cancelled = false;
|
|
const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(al);
|
|
void resolveTrackCoverRefFromLibrary(
|
|
{ ...track, id: tid, albumId: al } as Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>,
|
|
scope,
|
|
distinctDiscCovers,
|
|
).then(next => {
|
|
if (!cancelled) {
|
|
setRef(prev => {
|
|
if (!next) return prev ?? next;
|
|
if (
|
|
prev
|
|
&& prev.cacheKind === 'album'
|
|
&& next.cacheKind === 'album'
|
|
&& next.cacheEntityId === al
|
|
&& prev.cacheEntityId !== al
|
|
&& prev.fetchCoverArtId !== next.fetchCoverArtId
|
|
) {
|
|
return prev;
|
|
}
|
|
if (prev && coverRefsEqual(prev, next)) return prev;
|
|
return next;
|
|
});
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// `scope` is keyed via the stable `scopeKey` string; depending on the object
|
|
// directly would re-resolve from SQLite on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [track, trackId, albumId, coverArt, discNumber, scopeKey, syncRef]);
|
|
|
|
return ref;
|
|
}
|