mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
test(offline): mock useOfflineBrowseReloadToken submodule in useSongBrowseList
The offline move collapsed a single-module mock into a `@/features/offline` barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied on. Mock the reload-token submodule directly so the barrel re-exports the stub while the sibling context hook stays live.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicClient', () => ({
|
||||
api: vi.fn(),
|
||||
apiForServer: vi.fn(),
|
||||
libraryFilterParams: () => ({}),
|
||||
libraryFilterParamsForServer: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('./subsonicLibrary', () => ({
|
||||
filterSongsToActiveLibrary: async (songs: unknown[]) => songs,
|
||||
filterSongsToServerLibrary: async (songs: unknown[]) => songs,
|
||||
similarSongsRequestCount: (count: number) => count,
|
||||
}));
|
||||
|
||||
import { api } from './subsonicClient';
|
||||
import { fetchSimilarTracksRouted } from './subsonicArtists';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
const SID = 'srv-router';
|
||||
const apiMock = vi.mocked(api);
|
||||
|
||||
function seedServer(identity: Record<string, unknown>, probes: Record<string, unknown>) {
|
||||
useAuthStore.setState({
|
||||
activeServerId: SID,
|
||||
subsonicServerIdentityByServer: { [SID]: identity as never },
|
||||
audiomusePluginProbeByServer: {},
|
||||
instantMixProbeByServer: {},
|
||||
audiomuseNavidromeByServer: {},
|
||||
...probes,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const SONIC_RESPONSE = { sonicMatch: [{ entry: { id: 'sonic-1', title: 'Sonic' } }] };
|
||||
const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Legacy' }] } };
|
||||
|
||||
describe('fetchSimilarTracksRouted', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset();
|
||||
});
|
||||
|
||||
it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
});
|
||||
apiMock.mockImplementation(async (endpoint: string) =>
|
||||
(endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['sonic-1']);
|
||||
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
|
||||
it('falls back to legacy when sonic returns empty', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'present' },
|
||||
});
|
||||
apiMock.mockImplementation(async (endpoint: string) =>
|
||||
(endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['legacy-1']);
|
||||
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
|
||||
it('uses legacy only on Navidrome 0.62 without plugin', async () => {
|
||||
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
|
||||
audiomusePluginProbeByServer: { [SID]: 'absent' },
|
||||
});
|
||||
apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
|
||||
|
||||
const result = await fetchSimilarTracksRouted('seed', 10);
|
||||
expect(result.map(s => s.id)).toEqual(['legacy-1']);
|
||||
expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
|
||||
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient';
|
||||
import { filterSongsToServerLibrary } from './subsonicLibrary';
|
||||
import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary';
|
||||
import {
|
||||
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
|
||||
OP_SIMILAR_TRACKS,
|
||||
} from '../serverCapabilities/catalog';
|
||||
import { resolveCallRoutesForServer } from '../serverCapabilities/storeView';
|
||||
import type {
|
||||
SubsonicAlbum,
|
||||
SubsonicArtist,
|
||||
SubsonicArtistInfo,
|
||||
SubsonicSong,
|
||||
} from './subsonicTypes';
|
||||
|
||||
export async function getArtists(): Promise<SubsonicArtist[]> {
|
||||
type ArtistIndexEntry = { artist?: SubsonicArtist | SubsonicArtist[] };
|
||||
const data = await api<{ artists?: { index?: ArtistIndexEntry | ArtistIndexEntry[] } }>('getArtists.view', {
|
||||
...libraryFilterParams(),
|
||||
});
|
||||
const rawIdx = data.artists?.index;
|
||||
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
|
||||
const artists: SubsonicArtist[] = [];
|
||||
for (const idx of indices) {
|
||||
const rawArt = idx.artist;
|
||||
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
|
||||
artists.push(...arr);
|
||||
}
|
||||
return artists;
|
||||
}
|
||||
|
||||
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
const data = await api<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>('getArtist.view', { id });
|
||||
const { album, ...artist } = data.artist;
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistForServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
const data = await apiForServer<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(serverId, 'getArtist.view', { id });
|
||||
const { album, ...artist } = data.artist;
|
||||
return { artist, albums: album ?? [] };
|
||||
}
|
||||
|
||||
export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
|
||||
const count = options?.similarArtistCount ?? 5;
|
||||
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
|
||||
return data.artistInfo2 ?? {};
|
||||
}
|
||||
|
||||
export async function getArtistInfoForServer(
|
||||
serverId: string,
|
||||
id: string,
|
||||
options?: { similarArtistCount?: number },
|
||||
): Promise<SubsonicArtistInfo> {
|
||||
const count = options?.similarArtistCount ?? 5;
|
||||
const data = await apiForServer<{ artistInfo2: SubsonicArtistInfo }>(
|
||||
serverId,
|
||||
'getArtistInfo2.view',
|
||||
{ id, count, ...libraryFilterParamsForServer(serverId) },
|
||||
);
|
||||
return data.artistInfo2 ?? {};
|
||||
}
|
||||
|
||||
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
if (!activeServerId) return [];
|
||||
return getTopSongsForServer(activeServerId, artist);
|
||||
}
|
||||
|
||||
export async function getTopSongsForServer(serverId: string, artist: string): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const { musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const scoped = musicLibraryFilterByServer[serverId] && musicLibraryFilterByServer[serverId] !== 'all';
|
||||
const topCount = scoped ? 20 : 5;
|
||||
const data = await apiForServer<{ topSongs: { song: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getTopSongs.view',
|
||||
{ artist, count: topCount, ...libraryFilterParamsForServer(serverId) },
|
||||
);
|
||||
const raw = data.topSongs?.song ?? [];
|
||||
const filtered = await filterSongsToServerLibrary(raw, serverId);
|
||||
return filtered.slice(0, 5);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs2?.song ?? [];
|
||||
const filtered = await filterSongsToActiveLibrary(raw);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
|
||||
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
|
||||
const raw = data.similarSongs?.song;
|
||||
if (!raw) return [];
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
const filtered = await filterSongsToActiveLibrary(list);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sonic (audio-analysis) similar tracks via the OpenSubsonic `sonicSimilarity`
|
||||
* extension (Navidrome ≥ 0.62 + AudioMuse plugin). Returns `[]` when the server
|
||||
* has no provider (HTTP 404) so callers can fall back.
|
||||
*/
|
||||
export async function getSonicSimilarTracks(id: string, count = 50): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
|
||||
'getSonicSimilarTracks.view',
|
||||
{ id, count: requestCount, ...libraryFilterParams() },
|
||||
);
|
||||
const raw = data.sonicMatch;
|
||||
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e);
|
||||
if (songs.length === 0) return [];
|
||||
const filtered = await filterSongsToActiveLibrary(songs);
|
||||
return filtered.slice(0, count);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability-routed similar tracks for the active server. Prefers the sonic
|
||||
* similarity endpoint when the AudioMuse plugin is detected (Navidrome ≥ 0.62),
|
||||
* falling back to legacy `getSimilarSongs` on empty/unavailable.
|
||||
*/
|
||||
export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise<SubsonicSong[]> {
|
||||
const { activeServerId } = useAuthStore.getState();
|
||||
if (!activeServerId) return getSimilarSongs(songId, count);
|
||||
const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
|
||||
if (routes.length === 0) return getSimilarSongs(songId, count);
|
||||
for (const route of routes) {
|
||||
const songs = route.transport === 'opensubsonic'
|
||||
? await getSonicSimilarTracks(songId, count)
|
||||
: await getSimilarSongs(songId, count);
|
||||
if (songs.length > 0) return songs;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
||||
import {
|
||||
COVER_DENSE_ARTIST_LIST_CSS_PX,
|
||||
COVER_DENSE_GRID_MIN_CELL_CSS_PX,
|
||||
} from '../../cover/layoutSizes';
|
||||
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
import { nameColor, nameInitial } from '../../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
interface AvatarProps {
|
||||
artist: SubsonicArtist;
|
||||
showImages: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-sized artist avatar for the grid view. Falls back to a coloured
|
||||
* monogram (Catppuccin palette, hashed by name) when artist images are
|
||||
* disabled or the artist has no cover art.
|
||||
*/
|
||||
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && (artist.coverArt || artist.id)) {
|
||||
return (
|
||||
<div className="artist-card-avatar">
|
||||
<ArtistCoverArtImage
|
||||
artistId={artist.id}
|
||||
coverArt={artist.coverArt}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-sized artist avatar for the list view. Same fallback rules as the
|
||||
* card variant, but smaller layout px so list rows don't pull oversized images.
|
||||
*/
|
||||
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
|
||||
const color = nameColor(artist.name);
|
||||
if (showImages && (artist.coverArt || artist.id)) {
|
||||
return (
|
||||
<div className="artist-avatar">
|
||||
<ArtistCoverArtImage
|
||||
artistId={artist.id}
|
||||
coverArt={artist.coverArt}
|
||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
observeScrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
|
||||
<span style={{ color }}>{nameInitial(artist.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
/** Appended to `/artist/:id`, e.g. `lossless=1`. */
|
||||
linkQuery?: string;
|
||||
/** Search/browse rows: API `coverArt` only — no per-card library_resolve IPC. */
|
||||
libraryResolve?: boolean;
|
||||
}
|
||||
|
||||
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const coverServerScope = useMemo(
|
||||
() => coverServerScopeForServerId(artist.serverId),
|
||||
[artist.serverId],
|
||||
);
|
||||
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, coverServerScope, { libraryResolve });
|
||||
const artistLinkQuery = appendServerQuery(linkQuery, artist.serverId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigateToArtist(artist.id, artistLinkQuery ? { search: artistLinkQuery } : undefined)}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverRef ? (
|
||||
<CoverArtImage
|
||||
coverRef={coverRef}
|
||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||
surface="dense"
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement?.classList.add('fallback-visible');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Users size={32} color="var(--text-muted)" />
|
||||
)}
|
||||
</div>
|
||||
<div className="artist-card-info">
|
||||
<span className="artist-card-name">{artist.name}</span>
|
||||
{typeof artist.albumCount === 'number' && (
|
||||
<span className="artist-card-meta">
|
||||
{t('artists.albumCount', { count: artist.albumCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAlbumDetailBack } from '../../hooks/useAlbumDetailBack';
|
||||
import {
|
||||
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
|
||||
Loader2, Play, Radio, Share2, Shuffle, Users,
|
||||
} from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||
import { useOfflineStore } from '@/features/offline';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useArtistOfflineState } from '../../hooks/useArtistOfflineState';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||
import { useArtistBanner, useArtistFanart } from '../../cover/useArtistFanart';
|
||||
import { backdropFromConfig } from '../../cover/artistBackdrop';
|
||||
import { usePlaybackCoverArt } from '../../cover/usePlaybackCoverArt';
|
||||
import { useCachedUrl } from '@/ui/CachedImage';
|
||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
import type { CoverArtRef } from '../../cover/types';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import WikipediaIcon from '../WikipediaIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { tooltipAttrs } from '@/ui/tooltipAttrs';
|
||||
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
id: string | undefined;
|
||||
albums: SubsonicAlbum[];
|
||||
info: SubsonicArtistInfo | null;
|
||||
isStarred: boolean;
|
||||
artistEntityRating: number;
|
||||
handleArtistEntityRating: (rating: number) => Promise<void>;
|
||||
toggleStar: () => Promise<void>;
|
||||
handlePlayAll: () => void;
|
||||
handleShuffle: () => void;
|
||||
handleStartRadio: () => void;
|
||||
handleShareArtist: () => void;
|
||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||
playAllLoading: boolean;
|
||||
radioLoading: boolean;
|
||||
uploading: boolean;
|
||||
openedLink: string | null;
|
||||
openLink: (url: string, key: string) => void;
|
||||
coverId: string;
|
||||
coverRef: CoverArtRef | null;
|
||||
coverRevision: number;
|
||||
headerCoverFailed: boolean;
|
||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
actionPolicy?: OfflineActionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artist-detail header background (banner / fanart). Preloads the final image
|
||||
* and only then fades it in over the empty header — so the chosen image never
|
||||
* hard-cuts and no intermediate source flashes first. Reuses the shared
|
||||
* `album-detail-bg` / `-overlay` structure; the fade is a scoped inline opacity
|
||||
* so the class stays untouched for the album/playlist headers that share it.
|
||||
*
|
||||
* Mount with `key={url}` for a fresh element (and `loaded=false`) per source.
|
||||
* Both load paths are covered: `onLoad` for a network fetch, and the `ref`'s
|
||||
* `complete` check for an already-cached image whose `load` event can fire
|
||||
* before React attaches the handler.
|
||||
*/
|
||||
function ArtistHeaderBg({ url, position }: { url: string; position?: string }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<>
|
||||
{/* Hidden preloader — drives `loaded`; the visible background is CSS. */}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style={{ display: 'none' }}
|
||||
onLoad={() => setLoaded(true)}
|
||||
ref={(el) => {
|
||||
if (el?.complete) setLoaded(true);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="album-detail-bg"
|
||||
style={{
|
||||
backgroundImage: `url(${url})`,
|
||||
// Portrait-ish artist images (fanart / Navidrome) get a higher focal
|
||||
// point so the band's heads aren't cropped off the top on wide (2K+)
|
||||
// viewports, where `cover` scales the image up and overflows vertically.
|
||||
// The wide banner strip is left at the shared `center` (no override).
|
||||
...(position ? { backgroundPosition: position } : {}),
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 0.4s ease',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{loaded && <div className="album-detail-overlay" aria-hidden="true" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
|
||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
actionPolicy,
|
||||
}: Props) {
|
||||
const policy = actionPolicy ?? offlineActionPolicy('artistDetail', false);
|
||||
const { t } = useTranslation();
|
||||
const goBack = useAlbumDetailBack();
|
||||
const isMobile = useIsMobile();
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const downloadArtist = useOfflineStore(s => s.downloadArtist);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
||||
const artistAlbumIds = useMemo(() => albums.map(a => a.id), [albums]);
|
||||
const { status: artistOfflineStatus, progress: artistOfflineProgress } = useArtistOfflineState(
|
||||
id ?? '',
|
||||
activeServerId,
|
||||
artistAlbumIds,
|
||||
);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
|
||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: artist.name });
|
||||
|
||||
// Artist-detail header banner (§28, Option B): fanart.tv banner → the 16:9
|
||||
// fanart background cropped to the strip → empty (no regression when off).
|
||||
// Use the LOADED artist's id (not the route `id`), so the id, name and album
|
||||
// handed to the external-artwork hooks always describe the SAME artist. The
|
||||
// route `id` flips immediately on navigation while `artist`/`albums` refetch
|
||||
// a beat later — that mismatch previously wrote the previous artist's image
|
||||
// under the new artist's key (Sepultura's image under Lordi's id).
|
||||
const artistKey = artist.id;
|
||||
// An album from the artist's own list gives the §19 name→MusicBrainz fallback
|
||||
// the context it needs when the artist carries no Navidrome tag MBID.
|
||||
// Pick the first album that actually belongs to THIS artist. `albums` refetches
|
||||
// a beat after `artist` on navigation, so a stale album would run a mismatched
|
||||
// name→MusicBrainz query and could cache a wrong `no_mbid` for the new artist.
|
||||
const albumContext = albums.find((a) => a.artistId === artist.id)?.name;
|
||||
const banner = useArtistBanner(artistKey, {
|
||||
artistName: artist.name,
|
||||
albumTitle: albumContext,
|
||||
});
|
||||
const fanartBg = useArtistFanart(artistKey, {
|
||||
artistName: artist.name,
|
||||
albumTitle: albumContext,
|
||||
});
|
||||
// §28 stage 3: the Navidrome artist cover, the last fallback when neither an
|
||||
// external banner nor fanart exists. Resolved the same way the fullscreen
|
||||
// player resolves its artist background (`coverRef` is the artist cover ref).
|
||||
const ndArtist = usePlaybackCoverArt(coverRef ?? undefined, 2000, { fullRes: true });
|
||||
const ndArtistUrl = useCachedUrl(ndArtist.src, ndArtist.cacheKey, true);
|
||||
// Header background priority (§28): banner → fanart → Navidrome artist cover,
|
||||
// now user-configurable per surface. Shared with the mainstage hero via
|
||||
// backdropFromConfig so the two headers resolve and frame identically.
|
||||
const artistDetailBackdrop = useThemeStore((s) => s.backdrops.artistDetailHero);
|
||||
const headerBackdrop = backdropFromConfig(artistDetailBackdrop.sources, {
|
||||
banner,
|
||||
fanart: fanartBg,
|
||||
navidrome: ndArtistUrl,
|
||||
});
|
||||
const showHeaderBackdrop = artistDetailBackdrop.enabled;
|
||||
|
||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{lightbox}
|
||||
|
||||
{/* Same structure + classes as the album-detail header (AlbumHeader.tsx),
|
||||
with the fanart banner as the background instead of the album cover.
|
||||
`artist-detail-bleed` breaks out of the artist page's .content-body
|
||||
padding so it is full-bleed like the album page (flush .album-detail). */}
|
||||
<div className="album-detail-header artist-detail-bleed">
|
||||
{showHeaderBackdrop && (
|
||||
<ArtistHeaderBg key={headerBackdrop.url} url={headerBackdrop.url} position={headerBackdrop.position} />
|
||||
)}
|
||||
<div className="album-detail-content">
|
||||
<button className="btn btn-ghost album-detail-back" onClick={() => goBack()}>
|
||||
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
|
||||
</button>
|
||||
<div className="album-detail-hero">
|
||||
<div className="artist-detail-avatar" style={{ position: 'relative' }}>
|
||||
{coverId ? (
|
||||
<button
|
||||
className="artist-detail-avatar-btn"
|
||||
onClick={openLightbox}
|
||||
aria-label={`${artist.name} Bild vergrößern`}
|
||||
>
|
||||
{!headerCoverFailed ? (
|
||||
<ArtistHeroCover
|
||||
key={coverRevision}
|
||||
artistId={id ?? artist.id}
|
||||
artistInfo={info}
|
||||
coverFallback={coverRef}
|
||||
displayCssPx={300}
|
||||
surface="sparse"
|
||||
alt={artist.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setHeaderCoverFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" style={{ margin: 'auto', display: 'block' }} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<Users size={64} color="var(--text-muted)" />
|
||||
)}
|
||||
{/* Upload overlay */}
|
||||
<div
|
||||
className="artist-avatar-upload-overlay"
|
||||
onClick={e => { e.stopPropagation(); imageInputRef.current?.click(); }}
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 size={22} className="spin-slow" />
|
||||
: <Camera size={22} />}
|
||||
</div>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-meta">
|
||||
<h1 className="page-title" style={{ fontSize: '3rem', marginBottom: '0.25rem' }}>
|
||||
{artist.name}
|
||||
</h1>
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: '1rem', marginBottom: '1rem' }}>
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount ?? 0 })}
|
||||
</div>
|
||||
|
||||
<div className="artist-detail-entity-rating">
|
||||
<span className="artist-detail-entity-rating-label">{t('entityRating.artistShort')}</span>
|
||||
<StarRating
|
||||
value={artistEntityRating}
|
||||
onChange={handleArtistEntityRating}
|
||||
disabled={!policy.canRate || artistEntityRatingSupport === 'track_only'}
|
||||
labelKey="entityRating.artistAriaLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="compact-action-bar" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={() => openLink(info.lastFmUrl!, 'lastfm')}
|
||||
{...tooltipAttrs(t('artistDetail.lastfmTooltip'))}
|
||||
>
|
||||
<LastfmIcon size={14} />
|
||||
<span className="compact-btn-label">{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={() => openLink(wikiUrl, 'wiki')}
|
||||
{...tooltipAttrs(t('artistDetail.wikipediaTooltip'))}
|
||||
>
|
||||
<WikipediaIcon size={14} />
|
||||
<span className="compact-btn-label">{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{policy.canFavorite && (
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
|
||||
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
|
||||
>
|
||||
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
|
||||
<span className="compact-btn-label">{t('artistDetail.favorite')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="compact-action-bar" style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePlayAll}
|
||||
disabled={playAllLoading}
|
||||
{...tooltipAttrs(t('artistDetail.playAllTooltip'))}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
<span className="compact-btn-label">{t('artistDetail.playAll')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleShuffle}
|
||||
disabled={playAllLoading}
|
||||
{...tooltipAttrs(t('artistDetail.shuffleTooltip'))}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{!isMobile && <span className="compact-btn-label">{t('artistDetail.shuffle')}</span>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleStartRadio}
|
||||
disabled={radioLoading}
|
||||
{...tooltipAttrs(t('artistDetail.radioTooltip'))}
|
||||
>
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{!isMobile && <span className="compact-btn-label">{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}</span>}
|
||||
</button>
|
||||
{id && artist && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface"
|
||||
onClick={handleShareArtist}
|
||||
aria-label={t('artistDetail.shareArtist')}
|
||||
data-tooltip={t('artistDetail.shareArtist')}
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{policy.canCacheDiscography && albums.length > 0 && (
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
disabled={
|
||||
artistOfflineStatus === 'downloading'
|
||||
|| artistOfflineStatus === 'queued'
|
||||
|| artistOfflineStatus === 'cached'
|
||||
}
|
||||
onClick={() => {
|
||||
if (id && artist && artistOfflineStatus !== 'cached') {
|
||||
downloadArtist(id, artist.name, activeServerId);
|
||||
}
|
||||
}}
|
||||
data-tooltip={
|
||||
artistOfflineStatus === 'downloading' && artistOfflineProgress
|
||||
? t('artistDetail.offlineDownloading', {
|
||||
done: artistOfflineProgress.done,
|
||||
total: artistOfflineProgress.total,
|
||||
})
|
||||
: artistOfflineStatus === 'queued'
|
||||
? t('artistDetail.offlineQueued')
|
||||
: artistOfflineStatus === 'cached'
|
||||
? t('artistDetail.offlineCached')
|
||||
: t('artistDetail.cacheOffline')
|
||||
}
|
||||
>
|
||||
{artistOfflineStatus === 'downloading'
|
||||
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
: artistOfflineStatus === 'cached'
|
||||
? <Check size={16} />
|
||||
: <HardDriveDownload size={16} />}
|
||||
{!isMobile && (
|
||||
<span className="compact-btn-label">{
|
||||
artistOfflineStatus === 'downloading' && artistOfflineProgress
|
||||
? t('artistDetail.offlineDownloading', {
|
||||
done: artistOfflineProgress.done,
|
||||
total: artistOfflineProgress.total,
|
||||
})
|
||||
: artistOfflineStatus === 'queued'
|
||||
? t('artistDetail.offlineQueued')
|
||||
: artistOfflineStatus === 'cached'
|
||||
? t('artistDetail.offlineCached')
|
||||
: t('artistDetail.cacheOffline')
|
||||
}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
|
||||
interface Props {
|
||||
marginTop: string;
|
||||
showAudiomuseSimilar: boolean;
|
||||
showNetworkSimilar: boolean;
|
||||
similarLoading: boolean;
|
||||
similarArtists: SubsonicArtist[];
|
||||
serverSimilarArtists: SubsonicArtist[];
|
||||
similarCollapsed: boolean;
|
||||
setSimilarCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function ArtistDetailSimilarArtists({
|
||||
marginTop, showAudiomuseSimilar, showNetworkSimilar,
|
||||
similarLoading, similarArtists, serverSimilarArtists,
|
||||
similarCollapsed, setSimilarCollapsed,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop, marginBottom: '1rem' }}>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{t('artistDetail.similarArtists')}
|
||||
</h2>
|
||||
{isMobile && (() => {
|
||||
const list = showAudiomuseSimilar ? serverSimilarArtists : similarArtists;
|
||||
return list.length > 5 ? (
|
||||
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }} onClick={() => setSimilarCollapsed(v => !v)}>
|
||||
{similarCollapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
{similarCollapsed ? t('nowPlaying.readMore') : t('nowPlaying.showLess')}
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{showNetworkSimilar && similarLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
|
||||
{t('artistDetail.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists)
|
||||
.slice(0, isMobile && similarCollapsed ? 5 : undefined)
|
||||
.map((a, i) => (
|
||||
<button
|
||||
key={`${a.id}-${i}`}
|
||||
className="artist-ext-link"
|
||||
onClick={() => navigate(`/artist/${a.id}`)}
|
||||
>
|
||||
{a.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||
import { useOrbitSongRowBehavior } from '@/features/orbit';
|
||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
import ArtistTopTrackCover from './ArtistTopTrackCover';
|
||||
import { topSongAlbumForCover } from './topSongAlbumForCover';
|
||||
|
||||
interface Props {
|
||||
topSongs: SubsonicSong[];
|
||||
albums: SubsonicAlbum[];
|
||||
marginTop: string;
|
||||
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
|
||||
losslessOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function ArtistDetailTopTracks({
|
||||
topSongs, albums, marginTop, playTopSongWithContinuation, losslessOnly = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const previewingId = usePreviewStore(s => s.previewingId);
|
||||
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<h2 className="section-title" style={{ marginTop, marginBottom: '1rem' }}>
|
||||
{t(losslessOnly ? 'artistDetail.topTracksLossless' : 'artistDetail.topTracks')}
|
||||
</h2>
|
||||
<div className="tracklist" data-preview-loc="artist" style={{ padding: 0, marginBottom: '2rem' }}>
|
||||
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
|
||||
<div style={{ textAlign: 'center' }}>#</div>
|
||||
<div>{t('artistDetail.trackTitle')}</div>
|
||||
<div>{t('artistDetail.trackAlbum')}</div>
|
||||
<div style={{ textAlign: 'right' }}>{t('artistDetail.trackDuration')}</div>
|
||||
</div>
|
||||
{topSongs.map((song, idx) => {
|
||||
const track = songToTrack(song);
|
||||
return (
|
||||
<div
|
||||
key={`${song.id}-${idx}`}
|
||||
className="track-row track-row-with-actions"
|
||||
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
if (orbitActive) { queueHint(); return; }
|
||||
playTopSongWithContinuation(idx);
|
||||
}}
|
||||
onDoubleClick={orbitActive ? e => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input')) return;
|
||||
addTrackToOrbit(song.id);
|
||||
} : undefined}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, track, 'song');
|
||||
}}
|
||||
>
|
||||
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}>
|
||||
{currentTrack?.id === song.id && isPlaying ? (
|
||||
<span className="track-num-eq"><AudioLines className="eq-bars" size={14} /></span>
|
||||
) : (
|
||||
<span className="track-num-number">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="track-info track-info-suggestion" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-suggestion-play-btn"
|
||||
onClick={e => { e.stopPropagation(); if (orbitActive) { queueHint(); return; } playTopSongWithContinuation(idx); }}
|
||||
data-tooltip={t('common.play')}
|
||||
aria-label={t('common.play')}
|
||||
>
|
||||
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview(previewInputFromSong(song), 'artist'); }}
|
||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||
>
|
||||
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
|
||||
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
|
||||
</svg>
|
||||
{previewingId === song.id
|
||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||
</button>
|
||||
{(() => {
|
||||
const albumForCover = topSongAlbumForCover(song, albums);
|
||||
return albumForCover ? <ArtistTopTrackCover album={albumForCover} /> : null;
|
||||
})()}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div className="track-title">{song.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-album truncate" style={{ color: 'var(--text-secondary)', fontSize: '12px' }}>
|
||||
{song.album}
|
||||
</div>
|
||||
<div className="track-duration" style={{ textAlign: 'right' }}>
|
||||
{formatTrackTime(song.duration)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect } from 'react';
|
||||
import ArtistCardLocal from './ArtistCardLocal';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
artists: SubsonicArtist[];
|
||||
moreLink?: string;
|
||||
moreText?: string;
|
||||
artistLinkQuery?: string;
|
||||
/** Search results: use API coverArt ids only. */
|
||||
libraryResolve?: boolean;
|
||||
/** Restored horizontal scroll (e.g. Advanced Search session return). */
|
||||
restoreScrollLeft?: number;
|
||||
/** Parent stashes horizontal scroll when leaving the page. */
|
||||
onScrollLeftSnapshot?: (scrollLeft: number) => void;
|
||||
}
|
||||
|
||||
export default function ArtistRow({
|
||||
title, artists, moreLink, moreText, artistLinkQuery, libraryResolve = false,
|
||||
restoreScrollLeft,
|
||||
onScrollLeftSnapshot,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const [showLeft, setShowLeft] = useState(false);
|
||||
const [showRight, setShowRight] = useState(true);
|
||||
const scrollRestoreTargetRef = useRef(restoreScrollLeft);
|
||||
const scrollRestoreDoneRef = useRef(false);
|
||||
const rowResetKey = artists[0]?.id ?? '';
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setShowLeft(scrollLeft > 0);
|
||||
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
onScrollLeftSnapshot?.(scrollLeft);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreScrollLeft == null || restoreScrollLeft <= 0) return;
|
||||
scrollRestoreTargetRef.current = restoreScrollLeft;
|
||||
scrollRestoreDoneRef.current = false;
|
||||
}, [restoreScrollLeft]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRestoreDoneRef.current) return;
|
||||
const target = scrollRestoreTargetRef.current;
|
||||
if (target == null || target <= 0) {
|
||||
scrollRestoreDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const attempt = () => {
|
||||
if (cancelled || scrollRestoreDoneRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
if (++attempts < 12) requestAnimationFrame(attempt);
|
||||
else scrollRestoreDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth);
|
||||
const desired = Math.min(Math.max(0, target), maxScroll);
|
||||
el.scrollLeft = desired;
|
||||
handleScroll();
|
||||
|
||||
const stuck = Math.abs(el.scrollLeft - desired) <= 1;
|
||||
const layoutStillGrowing = desired > el.scrollLeft + 1 && maxScroll < target;
|
||||
if ((!stuck || layoutStillGrowing) && ++attempts < 12) {
|
||||
requestAnimationFrame(attempt);
|
||||
return;
|
||||
}
|
||||
scrollRestoreDoneRef.current = true;
|
||||
};
|
||||
|
||||
attempt();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// handleScroll is recreated each render but reads live refs; the restore pass
|
||||
// is intentionally keyed on the row identity, not on the callback identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowResetKey, artists.length]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
window.addEventListener('resize', handleScroll);
|
||||
return () => window.removeEventListener('resize', handleScroll);
|
||||
// handleScroll is recreated each render but reads live refs; the resize
|
||||
// listener is intentionally rebound only when the row data changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artists]);
|
||||
|
||||
const scroll = (dir: 'left' | 'right') => {
|
||||
if (!scrollRef.current) return;
|
||||
const amount = scrollRef.current.clientWidth * 0.75;
|
||||
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (artists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="album-row-section">
|
||||
<div className="album-row-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
|
||||
<div className="album-row-nav">
|
||||
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="album-grid-wrapper">
|
||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||
{artists.map(a => (
|
||||
<ArtistCardLocal
|
||||
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
|
||||
artist={a}
|
||||
linkQuery={artistLinkQuery}
|
||||
libraryResolve={libraryResolve}
|
||||
/>
|
||||
))}
|
||||
{moreLink && (
|
||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
|
||||
<ArrowRight size={24} />
|
||||
</div>
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '../../cover/layoutSizes';
|
||||
import type { TopSongAlbumCoverSource } from './topSongAlbumForCover';
|
||||
|
||||
/** 32px album thumb — same cover ref path as {@link AlbumCard} on artist pages. */
|
||||
export default function ArtistTopTrackCover({ album }: { album: TopSongAlbumCoverSource }) {
|
||||
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
|
||||
if (!coverRef) return null;
|
||||
|
||||
return (
|
||||
<CoverArtImage
|
||||
coverRef={coverRef}
|
||||
displayCssPx={COVER_ARTIST_TOP_TRACK_CSS_PX}
|
||||
surface="dense"
|
||||
ensurePriority="high"
|
||||
alt={`${album.name} Cover`}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { PlayerState } from '../../store/playerStoreTypes';
|
||||
import { ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
import { VirtualCardGrid } from '../VirtualCardGrid';
|
||||
import { ArtistCardAvatar } from './ArtistAvatars';
|
||||
|
||||
interface TileProps {
|
||||
artist: SubsonicArtist;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
type TilePropsShared = Omit<TileProps, 'artist'>;
|
||||
|
||||
function ArtistGridTile({ artist, ...rest }: TileProps) {
|
||||
return (
|
||||
<div
|
||||
className={`artist-card${rest.selectionMode ? ' artist-card--selectable' : ''}${rest.selectionMode && rest.selectedIds.has(artist.id) ? ' artist-card--selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (rest.selectionMode) {
|
||||
rest.toggleSelect(artist.id);
|
||||
} else {
|
||||
rest.onOpenArtist(artist.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (rest.selectionMode && rest.selectedIds.size > 0) {
|
||||
rest.openContextMenu(e.clientX, e.clientY, rest.selectedArtists, 'multi-artist');
|
||||
} else {
|
||||
rest.openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rest.selectionMode && (
|
||||
<div className={`artist-card-select-check${rest.selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
|
||||
{rest.selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
<ArtistCardAvatar artist={artist} showImages={rest.showArtistImages} />
|
||||
<div className="artist-card-info artist-card-info--center">
|
||||
<div className="artist-card-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-card-meta">{rest.t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: SubsonicArtist[];
|
||||
/** Plain CSS grid (canonical card layout) vs row virtualization for large catalogs. */
|
||||
disableVirtualization: boolean;
|
||||
/** Remount grid when browse filters change so virtualizer state cannot go stale. */
|
||||
layoutKey: string;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card grid for the artists page — same VirtualCardGrid path as Albums/Composers.
|
||||
*/
|
||||
export function ArtistsGridView({
|
||||
visible,
|
||||
disableVirtualization,
|
||||
layoutKey,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
}: Props) {
|
||||
const tilePropsShared: TilePropsShared = {
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
};
|
||||
|
||||
return (
|
||||
<VirtualCardGrid
|
||||
key={layoutKey}
|
||||
items={visible}
|
||||
itemKey={(artist) => artist.id}
|
||||
rowVariant="artist"
|
||||
disableVirtualization={disableVirtualization}
|
||||
layoutSignal={visible.length}
|
||||
scrollRootId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
wrapClassName={disableVirtualization ? 'album-grid-wrap album-grid-wrap--plain' : 'album-grid-wrap'}
|
||||
renderItem={artist => (
|
||||
<ArtistGridTile key={artist.id} artist={artist} {...tilePropsShared} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { PlayerState } from '../../store/playerStoreTypes';
|
||||
import { OTHER_BUCKET, type ArtistListFlatRow } from '../../utils/componentHelpers/artistsHelpers';
|
||||
import { ArtistRowAvatar } from './ArtistAvatars';
|
||||
|
||||
interface RowProps {
|
||||
artist: SubsonicArtist;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function ArtistListRow({
|
||||
artist,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
}: RowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
toggleSelect(artist.id);
|
||||
} else {
|
||||
onOpenArtist(artist.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (selectionMode && selectedIds.size > 0) {
|
||||
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
|
||||
} else {
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}
|
||||
}}
|
||||
id={`artist-${artist.id}`}
|
||||
style={selectionMode && selectedIds.has(artist.id) ? {
|
||||
background: 'var(--accent-dim)',
|
||||
color: 'var(--accent)',
|
||||
} : {}}
|
||||
>
|
||||
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div className="artist-name">{artist.name}</div>
|
||||
{artist.albumCount != null && (
|
||||
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
virtualized: boolean;
|
||||
groups: Record<string, SubsonicArtist[]>;
|
||||
letters: string[];
|
||||
artistListFlatRows: ArtistListFlatRow[];
|
||||
artistListVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
artistListWrapRef: React.RefObject<HTMLDivElement | null>;
|
||||
artistListScrollMargin: number;
|
||||
selectionMode: boolean;
|
||||
selectedIds: Set<string>;
|
||||
selectedArtists: SubsonicArtist[];
|
||||
showArtistImages: boolean;
|
||||
toggleSelect: (id: string) => void;
|
||||
onOpenArtist: (id: string) => void;
|
||||
openContextMenu: PlayerState['openContextMenu'];
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* List view for the artists page. Two render paths:
|
||||
* - Non-virtualized — emits one `<div class="artist-list">` per starting
|
||||
* letter, used when the `disableMainstageVirtualLists` perf flag is on
|
||||
* (mostly for low-end devices where translate-Y positioning costs more
|
||||
* than the saved DOM nodes).
|
||||
* - Virtualized — flat `letter / artist / artist / …` row stream sitting
|
||||
* on a single absolutely-positioned `<div>` whose height matches the
|
||||
* virtualizer's totalSize.
|
||||
*
|
||||
* Both paths share `ArtistListRow` so click + context-menu behaviour is
|
||||
* identical regardless of the rendering path.
|
||||
*/
|
||||
export function ArtistsListView({
|
||||
virtualized,
|
||||
groups,
|
||||
letters,
|
||||
artistListFlatRows,
|
||||
artistListVirtualizer,
|
||||
artistListWrapRef,
|
||||
artistListScrollMargin,
|
||||
selectionMode,
|
||||
selectedIds,
|
||||
selectedArtists,
|
||||
showArtistImages,
|
||||
toggleSelect,
|
||||
onOpenArtist,
|
||||
openContextMenu,
|
||||
t,
|
||||
}: Props) {
|
||||
const rowCommonProps = {
|
||||
selectionMode, selectedIds, selectedArtists, showArtistImages,
|
||||
toggleSelect, onOpenArtist, openContextMenu, t,
|
||||
};
|
||||
|
||||
if (!virtualized) {
|
||||
return (
|
||||
<>
|
||||
{letters.map(letter => (
|
||||
<div key={letter} style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 className="letter-heading">{letter === OTHER_BUCKET ? t('artists.other') : letter}</h3>
|
||||
<div className="artist-list">
|
||||
{groups[letter].map(artist => (
|
||||
<ArtistListRow key={artist.id} artist={artist} {...rowCommonProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={artistListWrapRef} style={{ position: 'relative', width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
height: artistListFlatRows.length === 0 ? 0 : artistListVirtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{artistListVirtualizer.getVirtualItems().map(vi => {
|
||||
const row = artistListFlatRows[vi.index];
|
||||
if (!row) return null;
|
||||
if (row.kind === 'letter') {
|
||||
return (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start - artistListScrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
<h3 className="letter-heading">{row.letter === OTHER_BUCKET ? t('artists.other') : row.letter}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const artist = row.artist;
|
||||
return (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start - artistListScrollMargin}px)`,
|
||||
paddingBottom: row.isLastInLetter ? '1.5rem' : undefined,
|
||||
}}
|
||||
>
|
||||
<ArtistListRow artist={artist} {...rowCommonProps} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||
|
||||
interface Props {
|
||||
refs: SubsonicOpenArtistRef[];
|
||||
/** Used when `refs` is empty (callers should normally avoid that). */
|
||||
fallbackName: string;
|
||||
/** Invoked with Subsonic artist id when a ref has an id. */
|
||||
onGoArtist: (artistId: string) => void;
|
||||
/** Wrapper element: `span` (default) or `fragment` children only. */
|
||||
as?: 'span' | 'none';
|
||||
/** `button` for album header; `span` matches dense player / track rows. */
|
||||
linkTag?: 'button' | 'span';
|
||||
outerClassName?: string;
|
||||
linkClassName?: string;
|
||||
separatorClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders OpenSubsonic `artists` / `albumArtists` refs as ·-separated names with
|
||||
* per-artist navigation when `id` is present (same interaction model as album
|
||||
* track rows).
|
||||
*/
|
||||
export function OpenArtistRefInline({
|
||||
refs,
|
||||
fallbackName,
|
||||
onGoArtist,
|
||||
as = 'span',
|
||||
linkTag = 'button',
|
||||
outerClassName,
|
||||
linkClassName,
|
||||
separatorClassName = 'open-artist-ref-sep',
|
||||
}: Props) {
|
||||
const list = refs.length > 0 ? refs : [{ name: fallbackName }];
|
||||
const inner = (
|
||||
<>
|
||||
{list.map((a, i) => (
|
||||
<Fragment key={a.id ?? `n:${a.name ?? ''}:${i}`}>
|
||||
{i > 0 && <span className={separatorClassName} aria-hidden="true"> · </span>}
|
||||
{a.id ? (
|
||||
linkTag === 'span' ? (
|
||||
<span
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className={linkClassName}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onGoArtist(a.id!);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onGoArtist(a.id!);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{a.name ?? fallbackName}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onGoArtist(a.id!);
|
||||
}}
|
||||
>
|
||||
{a.name ?? fallbackName}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span>{a.name ?? fallbackName}</span>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
if (as === 'none') return inner;
|
||||
return <span className={outerClassName}>{inner}</span>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { topSongAlbumForCover, topSongAlbumsForCoverWarm, artistDetailCoverWarmAlbums } from './topSongAlbumForCover';
|
||||
|
||||
describe('topSongAlbumForCover', () => {
|
||||
it('uses the artist album row when albumId matches', () => {
|
||||
expect(
|
||||
topSongAlbumForCover(
|
||||
{ albumId: 'al-1', album: 'Grid Name', coverArt: 'tr-1' },
|
||||
[{ id: 'al-1', name: 'Grid Name', coverArt: 'cov-grid' }],
|
||||
),
|
||||
).toEqual({ id: 'al-1', name: 'Grid Name', coverArt: 'cov-grid' });
|
||||
});
|
||||
|
||||
it('falls back to song fields when the album is not in the discography list', () => {
|
||||
expect(
|
||||
topSongAlbumForCover(
|
||||
{ albumId: 'al-feat', album: 'Compilation', coverArt: 'cov-feat' },
|
||||
[{ id: 'al-other', name: 'Other', coverArt: 'cov-other' }],
|
||||
),
|
||||
).toEqual({ id: 'al-feat', name: 'Compilation', coverArt: 'cov-feat' });
|
||||
});
|
||||
|
||||
it('returns null without albumId', () => {
|
||||
expect(topSongAlbumForCover({ albumId: '', album: 'X', coverArt: 'c' }, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('topSongAlbumsForCoverWarm', () => {
|
||||
it('dedupes by album id', () => {
|
||||
expect(
|
||||
topSongAlbumsForCoverWarm(
|
||||
[
|
||||
{ albumId: 'al-1', album: 'A', coverArt: 'c1' },
|
||||
{ albumId: 'al-1', album: 'A', coverArt: 'c1' },
|
||||
{ albumId: 'al-2', album: 'B', coverArt: 'c2' },
|
||||
],
|
||||
[{ id: 'al-1', name: 'A', coverArt: 'cov-a' }],
|
||||
),
|
||||
).toEqual([
|
||||
{ id: 'al-1', coverArt: 'cov-a' },
|
||||
{ id: 'al-2', coverArt: 'c2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('artistDetailCoverWarmAlbums', () => {
|
||||
it('lists top-track albums before discography and respects limit', () => {
|
||||
expect(
|
||||
artistDetailCoverWarmAlbums(
|
||||
[{ albumId: 'al-top', album: 'Hit', coverArt: 'c-top' }],
|
||||
[
|
||||
{ id: 'al-a', name: 'A', coverArt: 'cov-a' },
|
||||
{ id: 'al-b', name: 'B', coverArt: 'cov-b' },
|
||||
],
|
||||
2,
|
||||
),
|
||||
).toEqual([
|
||||
{ id: 'al-top', coverArt: 'c-top' },
|
||||
{ id: 'al-a', coverArt: 'cov-a' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
|
||||
export type TopSongAlbumCoverSource = Pick<SubsonicAlbum, 'id' | 'coverArt' | 'name'>;
|
||||
|
||||
export type AlbumCoverWarmRow = { id: string; coverArt?: string | null };
|
||||
|
||||
function pushAlbumWarmRow(
|
||||
out: AlbumCoverWarmRow[],
|
||||
seen: Set<string>,
|
||||
row: { id?: string | null; coverArt?: string | null } | null | undefined,
|
||||
limit: number,
|
||||
): void {
|
||||
const id = row?.id?.trim();
|
||||
if (!id || seen.has(id) || out.length >= limit) return;
|
||||
seen.add(id);
|
||||
out.push({ id, coverArt: row?.coverArt });
|
||||
}
|
||||
|
||||
/**
|
||||
* Album row for cover loading on artist top tracks — same `id` + `coverArt` as
|
||||
* {@link AlbumCard} when the album is in the artist discography; otherwise the
|
||||
* featured-album fallback shape (`albumId` + song `coverArt`).
|
||||
*/
|
||||
export function topSongAlbumForCover(
|
||||
song: Pick<SubsonicSong, 'albumId' | 'album' | 'coverArt'>,
|
||||
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
|
||||
): TopSongAlbumCoverSource | null {
|
||||
const albumId = song.albumId?.trim();
|
||||
if (!albumId) return null;
|
||||
|
||||
const fromList =
|
||||
albums.find(a => a.id === albumId)
|
||||
?? albums.find(a => a.name === song.album);
|
||||
if (fromList) return fromList;
|
||||
|
||||
return {
|
||||
id: albumId,
|
||||
name: song.album,
|
||||
coverArt: song.coverArt,
|
||||
};
|
||||
}
|
||||
|
||||
export function topSongAlbumsForCoverWarm(
|
||||
songs: ReadonlyArray<Pick<SubsonicSong, 'albumId' | 'album' | 'coverArt'>>,
|
||||
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
|
||||
): AlbumCoverWarmRow[] {
|
||||
const seen = new Set<string>();
|
||||
const out: AlbumCoverWarmRow[] = [];
|
||||
for (const song of songs) {
|
||||
pushAlbumWarmRow(out, seen, topSongAlbumForCover(song, albums), songs.length);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-track albums first, then discography — same warm list shape as All Albums grids.
|
||||
* Use {@link COVER_DENSE_GRID_MIN_CELL_CSS_PX} for peek/ensure tier (not the 32px thumb size).
|
||||
*/
|
||||
export function artistDetailCoverWarmAlbums(
|
||||
topSongs: ReadonlyArray<Pick<SubsonicSong, 'albumId' | 'album' | 'coverArt'>>,
|
||||
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
|
||||
limit: number,
|
||||
): AlbumCoverWarmRow[] {
|
||||
const seen = new Set<string>();
|
||||
const out: AlbumCoverWarmRow[] = [];
|
||||
for (const song of topSongs) {
|
||||
if (out.length >= limit) break;
|
||||
pushAlbumWarmRow(out, seen, topSongAlbumForCover(song, albums), limit);
|
||||
}
|
||||
for (const album of albums) {
|
||||
if (out.length >= limit) break;
|
||||
pushAlbumWarmRow(out, seen, album, limit);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Regression tests for the id-gating tuple pattern in `useArtistDetailData`.
|
||||
*
|
||||
* `info` (the SubsonicArtistInfo returned by getArtistInfo) is held as a
|
||||
* `{ id, value }` tuple internally and gated on id-match at the return
|
||||
* statement. Without this gate, navigating between /artist/A → /artist/B
|
||||
* would render one frame with A's `largeImageUrl` paired with B's id —
|
||||
* exactly the cache-mismatch shape that PR #732 fixed for the queue info
|
||||
* panel and that the shared ArtistCard (now used on this page) would
|
||||
* otherwise persist into IndexedDB.
|
||||
*/
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
|
||||
vi.mock('../api/subsonicArtists');
|
||||
vi.mock('../api/subsonicSearch');
|
||||
|
||||
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import { useArtistDetailData } from './useArtistDetailData';
|
||||
|
||||
const mockArtistInfo = vi.mocked(getArtistInfo) as unknown as {
|
||||
mockImplementation: (impl: (id: string) => Promise<SubsonicArtistInfo | null>) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(search).mockResolvedValue({ songs: [], albums: [], artists: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function routerWrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(MemoryRouter, null, children);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('useArtistDetailData — id-gated info', () => {
|
||||
it('returns null info when id changes before the new fetch resolves', async () => {
|
||||
vi.mocked(getArtist).mockImplementation(async (id) => (
|
||||
{ artist: { id, name: id }, albums: [] }
|
||||
));
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
const b = deferred<SubsonicArtistInfo | null>();
|
||||
mockArtistInfo.mockImplementation(async (id) => {
|
||||
if (id === 'A') return a.promise;
|
||||
if (id === 'B') return b.promise;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useArtistDetailData(id),
|
||||
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
|
||||
);
|
||||
|
||||
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
||||
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'A.jpg' }));
|
||||
|
||||
// Switch to artist B. info must flip to null until B's fetch resolves —
|
||||
// it must never carry A's largeImageUrl paired with B's id, since the
|
||||
// shared ArtistCard would build a `coverArtCacheKey(B, 80)` from `id`
|
||||
// and pair it with A's URL inside CachedImage.
|
||||
rerender({ id: 'B' });
|
||||
expect(result.current.info).toBeNull();
|
||||
|
||||
await act(async () => { b.resolve({ largeImageUrl: 'B.jpg' } as SubsonicArtistInfo); });
|
||||
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' }));
|
||||
});
|
||||
|
||||
it('keeps the album-artist credit on featured compilation albums', async () => {
|
||||
// "Also featured on" synthesises albums from search3 child songs. A
|
||||
// compilation has no flat `albumArtist` on the child — the credit lives in
|
||||
// OpenSubsonic's structured `albumArtists` (and/or `displayAlbumArtist`).
|
||||
// Dropping it made the card render "—" instead of "Various Artists".
|
||||
vi.mocked(getArtist).mockResolvedValue({ artist: { id: 'A', name: 'A' }, albums: [] });
|
||||
vi.mocked(search).mockResolvedValue({
|
||||
artists: [],
|
||||
albums: [],
|
||||
songs: [
|
||||
{
|
||||
id: 's1', title: 'Track', artistId: 'A', artist: 'A',
|
||||
album: 'A Compilation', albumId: 'comp1', coverArt: 'c1', duration: 100,
|
||||
albumArtists: [{ id: 'va', name: 'Various Artists' }],
|
||||
},
|
||||
{
|
||||
id: 's2', title: 'Other', artistId: 'A', artist: 'A',
|
||||
album: 'Display Only', albumId: 'comp2', coverArt: 'c2', duration: 90,
|
||||
displayAlbumArtist: 'Various Artists',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useArtistDetailData('A'), { wrapper: routerWrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.featuredAlbums).toHaveLength(2));
|
||||
const structured = result.current.featuredAlbums.find(a => a.id === 'comp1');
|
||||
const displayOnly = result.current.featuredAlbums.find(a => a.id === 'comp2');
|
||||
expect(structured?.artists).toEqual([{ id: 'va', name: 'Various Artists' }]);
|
||||
expect(displayOnly?.artist).toBe('Various Artists');
|
||||
});
|
||||
|
||||
it('ignores a late-arriving resolve for a stale id', async () => {
|
||||
vi.mocked(getArtist).mockImplementation(async (id) => (
|
||||
{ artist: { id, name: id }, albums: [] }
|
||||
));
|
||||
const a = deferred<SubsonicArtistInfo | null>();
|
||||
mockArtistInfo.mockImplementation(async (id) => {
|
||||
if (id === 'A') return a.promise;
|
||||
if (id === 'B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ id }: { id: string }) => useArtistDetailData(id),
|
||||
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
|
||||
);
|
||||
|
||||
rerender({ id: 'B' });
|
||||
await waitFor(() => expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' }));
|
||||
|
||||
// A's late resolve must not overwrite B's info.
|
||||
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
||||
expect(result.current.info).toEqual({ largeImageUrl: 'B.jpg' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||
import type {
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
|
||||
} from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
import { loadArtistFromLibraryIndex } from '@/features/offline';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '@/features/offline';
|
||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
|
||||
export interface UseArtistDetailDataOptions {
|
||||
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
|
||||
losslessOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ArtistDetailDataResult {
|
||||
artist: SubsonicArtist | null;
|
||||
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
|
||||
albums: SubsonicAlbum[];
|
||||
topSongs: SubsonicSong[];
|
||||
info: SubsonicArtistInfo | null;
|
||||
featuredAlbums: SubsonicAlbum[];
|
||||
loading: boolean;
|
||||
artistInfoLoading: boolean;
|
||||
featuredLoading: boolean;
|
||||
isStarred: boolean;
|
||||
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
losslessOnly: boolean;
|
||||
}
|
||||
|
||||
function filterNetworkArtistToLossless(
|
||||
albums: SubsonicAlbum[],
|
||||
songs: SubsonicSong[],
|
||||
): { albums: SubsonicAlbum[]; songs: SubsonicSong[] } {
|
||||
const losslessSongs = songs.filter(s => isLosslessSuffix(s.suffix));
|
||||
const albumIds = new Set(losslessSongs.map(s => s.albumId).filter(Boolean));
|
||||
return {
|
||||
albums: albums.filter(a => albumIds.has(a.id)),
|
||||
songs: losslessSongs,
|
||||
};
|
||||
}
|
||||
|
||||
export function useArtistDetailData(
|
||||
id: string | undefined,
|
||||
options: UseArtistDetailDataOptions = {},
|
||||
): ArtistDetailDataResult {
|
||||
const losslessOnly = options.losslessOnly ?? false;
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const [searchParams] = useSearchParams();
|
||||
const serverId = readDetailServerId(searchParams, activeServerId);
|
||||
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
|
||||
const { status: connStatus } = useConnectionStatus();
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId;
|
||||
const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId);
|
||||
const preferLocalArtist = preferLocalBytesOnly
|
||||
|| (connStatus === 'disconnected' && favoritesOfflineEnabled && !!serverId);
|
||||
|
||||
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [featuredAlbums, setFeaturedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [topSongs, setTopSongs] = useState<SubsonicSong[]>([]);
|
||||
const [infoEntry, setInfoEntry] = useState<{ id: string; value: SubsonicArtistInfo | null } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
|
||||
const [featuredLoading, setFeaturedLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true);
|
||||
setInfoEntry(null);
|
||||
setTopSongs([]);
|
||||
setFeaturedAlbums([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (offlineBrowseActive && !preferLocalBytesOnly) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (preferLocalArtist && serverId && id) {
|
||||
const local = preferLocalBytesOnly
|
||||
? await loadArtistFromLocalPlayback(serverId, id)
|
||||
: await loadArtistFromLibraryIndex(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setArtist(local.artist);
|
||||
setIsStarred(!!local.artist.starred);
|
||||
setAlbums(local.albums);
|
||||
setTopSongs([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (preferLocalBytesOnly) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (losslessOnly && serverId) {
|
||||
const local = await runLocalArtistLosslessBrowse(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
const artistData = serverId
|
||||
? await getArtistForServer(serverId, id).catch(() => null)
|
||||
: await getArtist(id).catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (artistData) {
|
||||
setArtist(artistData.artist);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
}
|
||||
setAlbums(local.albums);
|
||||
setTopSongs([...local.songs].sort((a, b) => (b.playCount ?? 0) - (a.playCount ?? 0)));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const artistData = serverId
|
||||
? await getArtistForServer(serverId, id)
|
||||
: await getArtist(id);
|
||||
if (cancelled) return;
|
||||
setArtist(artistData.artist);
|
||||
let nextAlbums = artistData.albums;
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
setLoading(false);
|
||||
|
||||
const songsData = await getTopSongs(artistData.artist.name).catch(() => [] as SubsonicSong[]);
|
||||
if (cancelled) return;
|
||||
let nextSongs = songsData ?? [];
|
||||
if (losslessOnly) {
|
||||
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
|
||||
}
|
||||
setAlbums(nextAlbums);
|
||||
setTopSongs(nextSongs);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (preferLocalArtist && serverId && id) {
|
||||
try {
|
||||
const local = preferLocalBytesOnly
|
||||
? await loadArtistFromLocalPlayback(serverId, id)
|
||||
: await loadArtistFromLibraryIndex(serverId, id);
|
||||
if (cancelled) return;
|
||||
if (local) {
|
||||
setArtist(local.artist);
|
||||
setIsStarred(!!local.artist.starred);
|
||||
setAlbums(local.albums);
|
||||
setTopSongs([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, losslessOnly, serverId, offlineBrowseActive, preferLocalArtist, preferLocalBytesOnly, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || preferLocalArtist) return;
|
||||
let cancelled = false;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setArtistInfoLoading(true);
|
||||
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
|
||||
.then(artistInfo => {
|
||||
if (!cancelled) setInfoEntry({ id, value: artistInfo ?? null });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInfoEntry({ id, value: null });
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setArtistInfoLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !artist || preferLocalArtist) return;
|
||||
const ownAlbumIds = new Set(albums.map(a => a.id));
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFeaturedLoading(true);
|
||||
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
|
||||
.catch(() => ({ songs: [], albums: [], artists: [] }))
|
||||
.then(searchResults => {
|
||||
let featuredSongs = (searchResults.songs ?? []).filter(
|
||||
song => song.artistId === id && !ownAlbumIds.has(song.albumId),
|
||||
);
|
||||
if (losslessOnly) {
|
||||
featuredSongs = featuredSongs.filter(s => isLosslessSuffix(s.suffix));
|
||||
}
|
||||
const albumMap = new Map<string, SubsonicAlbum>();
|
||||
featuredSongs.forEach(song => {
|
||||
if (!albumMap.has(song.albumId)) {
|
||||
albumMap.set(song.albumId, {
|
||||
id: song.albumId,
|
||||
name: song.album,
|
||||
// search3 children carry the album-artist credit in OpenSubsonic's
|
||||
// structured `albumArtists` / `displayAlbumArtist` (e.g. "Various
|
||||
// Artists" on compilations), not the flat `albumArtist` field — keep
|
||||
// all of them so the card resolves a name instead of "—".
|
||||
artist: song.albumArtist ?? song.displayAlbumArtist ?? '',
|
||||
artistId: '',
|
||||
artists: song.albumArtists,
|
||||
coverArt: song.coverArt,
|
||||
songCount: 1,
|
||||
duration: song.duration,
|
||||
year: song.year,
|
||||
});
|
||||
} else {
|
||||
const a = albumMap.get(song.albumId)!;
|
||||
a.songCount++;
|
||||
a.duration += song.duration;
|
||||
}
|
||||
});
|
||||
setFeaturedAlbums([...albumMap.values()]);
|
||||
setFeaturedLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
|
||||
|
||||
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
|
||||
|
||||
return {
|
||||
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||
loading, artistInfoLoading, featuredLoading,
|
||||
isStarred, setIsStarred,
|
||||
losslessOnly,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getArtistInfoForServer } from '../api/subsonicArtists';
|
||||
import type { SubsonicArtistInfo, SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||
import { makeCache } from '../utils/cache/nowPlayingCache';
|
||||
|
||||
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
|
||||
|
||||
function cacheKey(serverId: string, artistId: string): string {
|
||||
return `${serverId}:${artistId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `getArtistInfo` for each ref with an id. Returns `undefined` for ids
|
||||
* still loading, `null` when fetch finished with no info.
|
||||
*/
|
||||
export function useArtistInfoBatch(
|
||||
serverId: string | undefined,
|
||||
refs: SubsonicOpenArtistRef[],
|
||||
similarArtistCount?: number,
|
||||
): Record<string, SubsonicArtistInfo | null | undefined> {
|
||||
const ids = useMemo(
|
||||
() => [...new Set(refs.map(r => r.id?.trim()).filter((id): id is string => Boolean(id)))],
|
||||
[refs],
|
||||
);
|
||||
const idsKey = ids.join('\x1e');
|
||||
|
||||
const [byId, setById] = useState<Record<string, SubsonicArtistInfo | null | undefined>>(() => {
|
||||
if (!serverId || ids.length === 0) return {};
|
||||
const seed: Record<string, SubsonicArtistInfo | null | undefined> = {};
|
||||
for (const id of ids) {
|
||||
const cached = artistInfoCache.get(cacheKey(serverId, id));
|
||||
if (cached !== undefined) seed[id] = cached;
|
||||
}
|
||||
return seed;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || ids.length === 0) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setById({});
|
||||
return;
|
||||
}
|
||||
|
||||
const next: Record<string, SubsonicArtistInfo | null | undefined> = {};
|
||||
const pending: string[] = [];
|
||||
for (const id of ids) {
|
||||
const cached = artistInfoCache.get(cacheKey(serverId, id));
|
||||
if (cached !== undefined) {
|
||||
next[id] = cached;
|
||||
} else {
|
||||
next[id] = undefined;
|
||||
pending.push(id);
|
||||
}
|
||||
}
|
||||
setById(next);
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
pending.map(async id => {
|
||||
try {
|
||||
const info = await getArtistInfoForServer(serverId, id, {
|
||||
similarArtistCount: similarArtistCount,
|
||||
});
|
||||
artistInfoCache.set(cacheKey(serverId, id), info ?? null);
|
||||
return [id, info ?? null] as const;
|
||||
} catch {
|
||||
artistInfoCache.set(cacheKey(serverId, id), null);
|
||||
return [id, null] as const;
|
||||
}
|
||||
}),
|
||||
).then(results => {
|
||||
if (cancelled) return;
|
||||
setById(prev => {
|
||||
const merged = { ...prev };
|
||||
for (const [id, info] of results) merged[id] = info;
|
||||
return merged;
|
||||
});
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// Keyed on idsKey (the stable string form of `ids`); depending on the ids
|
||||
// array directly would re-fetch on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId, idsKey, similarArtistCount]);
|
||||
|
||||
return byId;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||
import { useOfflineJobStore } from '@/features/offline';
|
||||
import { useArtistOfflineState } from './useArtistOfflineState';
|
||||
|
||||
describe('useArtistOfflineState', () => {
|
||||
beforeEach(() => {
|
||||
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
|
||||
useLocalPlaybackStore.setState({ entries: {} });
|
||||
});
|
||||
|
||||
it('reports cached when every album is pinned', () => {
|
||||
useLocalPlaybackStore.setState({
|
||||
entries: {
|
||||
'srv:al-1': {
|
||||
serverIndexKey: 'srv',
|
||||
trackId: 't1',
|
||||
localPath: '/x',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'artist', sourceId: 'al-1' },
|
||||
},
|
||||
'srv:al-2': {
|
||||
serverIndexKey: 'srv',
|
||||
trackId: 't2',
|
||||
localPath: '/y',
|
||||
layoutFingerprint: 'fp',
|
||||
sizeBytes: 1,
|
||||
tier: 'library',
|
||||
cachedAt: 1,
|
||||
suffix: 'mp3',
|
||||
pinSource: { kind: 'artist', sourceId: 'al-2' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
|
||||
);
|
||||
expect(result.current.status).toBe('cached');
|
||||
});
|
||||
|
||||
it('reports queued when bulk progress is active but albums only wait in pin queue', () => {
|
||||
useOfflineJobStore.setState({
|
||||
bulkProgress: { 'artist-1': { done: 0, total: 2 } },
|
||||
pinQueue: [
|
||||
{ albumId: 'al-1', albumName: 'One', pinKind: 'artist', status: 'queued', queuedAt: 1 },
|
||||
{ albumId: 'al-2', albumName: 'Two', pinKind: 'artist', status: 'queued', queuedAt: 2 },
|
||||
],
|
||||
jobs: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
|
||||
);
|
||||
expect(result.current.status).toBe('queued');
|
||||
expect(result.current.progress).toEqual({ done: 0, total: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
|
||||
import { useOfflineJobStore } from '@/features/offline';
|
||||
import { isOfflinePinComplete } from '@/features/offline';
|
||||
|
||||
export type ArtistOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
|
||||
|
||||
interface UseArtistOfflineStateResult {
|
||||
status: ArtistOfflineStatus;
|
||||
progress: { done: number; total: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline discography status for an artist page. Uses persisted library pins
|
||||
* (not ephemeral bulkProgress) so "Discography cached" survives navigation.
|
||||
*/
|
||||
export function useArtistOfflineState(
|
||||
artistId: string,
|
||||
serverId: string,
|
||||
albumIds: string[],
|
||||
): UseArtistOfflineStateResult {
|
||||
useLocalPlaybackStore(s => s.entries);
|
||||
|
||||
const allPinned = albumIds.length > 0
|
||||
&& albumIds.every(id => isOfflinePinComplete(id, serverId));
|
||||
|
||||
const bulkDone = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.done : undefined));
|
||||
const bulkTotal = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.total : undefined));
|
||||
const hasQueuedAlbums = useOfflineJobStore(s =>
|
||||
albumIds.length > 0
|
||||
&& albumIds.some(id => s.pinQueue.some(p => p.albumId === id && p.status === 'queued')),
|
||||
);
|
||||
const hasDownloadingAlbums = useOfflineJobStore(s =>
|
||||
albumIds.length > 0
|
||||
&& albumIds.some(id =>
|
||||
s.pinQueue.some(p => p.albumId === id && p.status === 'downloading')
|
||||
|| s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading')),
|
||||
),
|
||||
);
|
||||
|
||||
const bulkActive = bulkTotal !== undefined && bulkDone !== undefined && bulkDone < bulkTotal;
|
||||
const waitingInQueue = bulkActive && hasQueuedAlbums && !hasDownloadingAlbums;
|
||||
|
||||
const status: ArtistOfflineStatus = allPinned
|
||||
? 'cached'
|
||||
: hasDownloadingAlbums || (bulkActive && !waitingInQueue)
|
||||
? 'downloading'
|
||||
: waitingInQueue
|
||||
? 'queued'
|
||||
: 'none';
|
||||
|
||||
const progress = bulkActive && bulkDone !== undefined && bulkTotal !== undefined
|
||||
? { done: bulkDone, total: bulkTotal }
|
||||
: null;
|
||||
|
||||
return { status, progress };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMusicNetworkRuntime } from '../music-network';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import type { SubsonicArtist, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export interface ArtistSimilarArtistsResult {
|
||||
similarArtists: SubsonicArtist[];
|
||||
similarLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the "Similar Artists" list for the current artist:
|
||||
* - Default: Last.fm getSimilar → server search for each name → keep first exact match.
|
||||
* - With audiomuseNavidromeEnabled on: prefer info.similarArtist; fall back to Last.fm
|
||||
* when the server returns nothing and Last.fm is configured.
|
||||
*/
|
||||
export function useArtistSimilarArtists(
|
||||
artist: SubsonicArtist | null,
|
||||
info: SubsonicArtistInfo | null,
|
||||
artistInfoLoading: boolean,
|
||||
): ArtistSimilarArtistsResult {
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const enrichmentConfigured = useAuthStore(s => s.enrichmentPrimaryId !== null);
|
||||
|
||||
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [similarLoading, setSimilarLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!artist || audiomuseNavidromeEnabled || !enrichmentConfigured) return;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled, enrichmentConfigured]);
|
||||
|
||||
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
|
||||
useEffect(() => {
|
||||
if (!artist || !audiomuseNavidromeEnabled || !enrichmentConfigured) return;
|
||||
if (artistInfoLoading) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) return;
|
||||
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(true);
|
||||
getMusicNetworkRuntime().getSimilarArtists(artist.name).then(async names => {
|
||||
if (names.length === 0) { setSimilarLoading(false); return; }
|
||||
const results = await Promise.all(
|
||||
names.slice(0, 30).map(name =>
|
||||
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
|
||||
)
|
||||
);
|
||||
const seen = new Set<string>([artist.id]);
|
||||
const found: SubsonicArtist[] = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const targetName = names[i].toLowerCase();
|
||||
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
|
||||
if (match && !seen.has(match.id)) {
|
||||
seen.add(match.id);
|
||||
found.push(match);
|
||||
}
|
||||
}
|
||||
setSimilarArtists(found);
|
||||
setSimilarLoading(false);
|
||||
}).catch(() => setSimilarLoading(false));
|
||||
// Keyed on artist?.id / artist?.name; depending on the `artist` object would
|
||||
// re-run on every render when its identity changes but its id/name do not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
artist?.id,
|
||||
artist?.name,
|
||||
musicLibraryFilterVersion,
|
||||
audiomuseNavidromeEnabled,
|
||||
artistInfoLoading,
|
||||
info?.similarArtist?.length,
|
||||
enrichmentConfigured,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audiomuseNavidromeEnabled) return;
|
||||
if ((info?.similarArtist?.length ?? 0) > 0) {
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSimilarArtists([]);
|
||||
setSimilarLoading(false);
|
||||
}
|
||||
}, [artist?.id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
|
||||
|
||||
return { similarArtists, similarLoading };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { getArtists } from '../api/subsonicArtists';
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import {
|
||||
fetchLocalArtistCatalogChunk,
|
||||
fetchNetworkStarredArtists,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { useOfflineBrowseReloadToken } from '@/features/offline';
|
||||
import {
|
||||
fetchOfflineLocalArtistCatalogChunk,
|
||||
fetchOfflineLocalStarredArtists,
|
||||
offlineLocalBrowseEnabled,
|
||||
} from '@/features/offline';
|
||||
|
||||
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
|
||||
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
|
||||
|
||||
export type ArtistsBrowseMode = 'slice' | 'network';
|
||||
|
||||
export type UseArtistsBrowseCatalogArgs = {
|
||||
serverId: string | null | undefined;
|
||||
indexEnabled: boolean;
|
||||
starredOnly: boolean;
|
||||
musicLibraryFilterVersion: number;
|
||||
};
|
||||
|
||||
export function useArtistsBrowseCatalog({
|
||||
serverId,
|
||||
indexEnabled,
|
||||
starredOnly,
|
||||
musicLibraryFilterVersion,
|
||||
}: UseArtistsBrowseCatalogArgs) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
|
||||
const [catalogArtists, setCatalogArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [catalogHasMore, setCatalogHasMore] = useState(false);
|
||||
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
|
||||
const [browseMode, setBrowseMode] = useState<ArtistsBrowseMode>('network');
|
||||
|
||||
const loadGenerationRef = useRef(0);
|
||||
const catalogOffsetRef = useRef(0);
|
||||
const catalogLoadingRef = useRef(false);
|
||||
|
||||
const loadCatalogChunk = useCallback(async (append: boolean) => {
|
||||
if (!serverId || catalogLoadingRef.current) return;
|
||||
const generation = loadGenerationRef.current;
|
||||
catalogLoadingRef.current = true;
|
||||
setCatalogLoadingMore(true);
|
||||
try {
|
||||
if (offlineBrowseActive) {
|
||||
if (!offlineLocalBrowseEnabled(serverId)) return;
|
||||
const chunk = await fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (append) {
|
||||
setCatalogArtists(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.artists]);
|
||||
catalogOffsetRef.current = merged.length;
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
setCatalogArtists(chunk.artists);
|
||||
catalogOffsetRef.current = chunk.artists.length;
|
||||
}
|
||||
setCatalogHasMore(chunk.hasMore);
|
||||
return;
|
||||
}
|
||||
const chunk = await fetchLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
catalogOffsetRef.current,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current || chunk == null) return;
|
||||
if (append) {
|
||||
setCatalogArtists(prev => {
|
||||
const merged = dedupeById([...prev, ...chunk.artists]);
|
||||
catalogOffsetRef.current = merged.length;
|
||||
return merged;
|
||||
});
|
||||
} else {
|
||||
setCatalogArtists(chunk.artists);
|
||||
catalogOffsetRef.current = chunk.artists.length;
|
||||
}
|
||||
setCatalogHasMore(chunk.hasMore);
|
||||
setBrowseMode('slice');
|
||||
} finally {
|
||||
catalogLoadingRef.current = false;
|
||||
if (generation === loadGenerationRef.current) {
|
||||
setCatalogLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [offlineBrowseActive, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const generation = ++loadGenerationRef.current;
|
||||
catalogOffsetRef.current = 0;
|
||||
catalogLoadingRef.current = false;
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setCatalogArtists([]);
|
||||
setCatalogHasMore(false);
|
||||
setCatalogLoadingMore(false);
|
||||
setBrowseMode('network');
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (offlineBrowseActive) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []);
|
||||
} else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) {
|
||||
const first = await fetchOfflineLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
0,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
setCatalogArtists(first?.artists ?? []);
|
||||
catalogOffsetRef.current = first?.artists.length ?? 0;
|
||||
setCatalogHasMore(first?.hasMore ?? false);
|
||||
} else {
|
||||
setCatalogArtists([]);
|
||||
setCatalogHasMore(false);
|
||||
}
|
||||
setBrowseMode('slice');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (starredOnly) {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setCatalogArtists(await fetchNetworkStarredArtists());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (indexEnabled && serverId) {
|
||||
const first = await fetchLocalArtistCatalogChunk(
|
||||
serverId,
|
||||
0,
|
||||
ARTIST_CATALOG_CHUNK_SIZE,
|
||||
);
|
||||
if (cancelled || generation !== loadGenerationRef.current) return;
|
||||
if (first != null) {
|
||||
setBrowseMode('slice');
|
||||
setCatalogArtists(first.artists);
|
||||
catalogOffsetRef.current = first.artists.length;
|
||||
setCatalogHasMore(first.hasMore);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setCatalogArtists(await getArtists());
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
if (!cancelled && generation === loadGenerationRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
|
||||
|
||||
return {
|
||||
catalogArtists,
|
||||
loading,
|
||||
catalogHasMore,
|
||||
catalogLoadingMore,
|
||||
browseMode,
|
||||
loadCatalogChunk,
|
||||
catalogLoadingRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import {
|
||||
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
type ArtistBrowseReturnState,
|
||||
type ArtistBrowseViewMode,
|
||||
isArtistsBrowsePath,
|
||||
useArtistBrowseSessionStore,
|
||||
} from '../store/artistBrowseSessionStore';
|
||||
import { isArtistDetailPath } from '../store/albumBrowseSessionStore';
|
||||
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '../store/liveSearchScopeStore';
|
||||
|
||||
export type ArtistBrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
function returnStateForNavigation(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): ArtistBrowseReturnState {
|
||||
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) {
|
||||
return DEFAULT_ARTIST_BROWSE_RETURN_STATE;
|
||||
}
|
||||
return (
|
||||
useArtistBrowseSessionStore.getState().peekReturnStash(serverId)
|
||||
?? DEFAULT_ARTIST_BROWSE_RETURN_STATE
|
||||
);
|
||||
}
|
||||
|
||||
export function useArtistsBrowseFilters(
|
||||
serverId: string,
|
||||
scrollSnapshotRef?: RefObject<ArtistBrowseScrollSnapshot>,
|
||||
) {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
|
||||
const [letterFilter, setLetterFilter] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).letterFilter,
|
||||
);
|
||||
const [starredOnly, setStarredOnly] = useState(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).starredOnly,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<ArtistBrowseViewMode>(
|
||||
() => returnStateForNavigation(serverId, navigationType, location.state).viewMode,
|
||||
);
|
||||
|
||||
const browseStateRef = useRef<ArtistBrowseReturnState>(DEFAULT_ARTIST_BROWSE_RETURN_STATE);
|
||||
const restoredFromStashRef = useRef(false);
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
browseStateRef.current = {
|
||||
filter: useLiveSearchScopeStore.getState().query,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
showArtistImages,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
|
||||
if (shouldRestoreArtistBrowseSession(navigationType, location.state)) {
|
||||
restoredFromStashRef.current = true;
|
||||
const restored = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (restored) {
|
||||
useLiveSearchScopeStore.getState().setQuery(restored.filter);
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLetterFilter(restored.letterFilter);
|
||||
setStarredOnly(restored.starredOnly);
|
||||
setViewMode(restored.viewMode);
|
||||
setShowArtistImages(restored.showArtistImages);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
useLiveSearchScopeStore.getState().setQuery('');
|
||||
setLetterFilter(DEFAULT_ARTIST_BROWSE_RETURN_STATE.letterFilter);
|
||||
setStarredOnly(false);
|
||||
setViewMode('grid');
|
||||
}, [serverId, navigationType, location.state, setShowArtistImages]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!serverId) return;
|
||||
const path = window.location.pathname;
|
||||
if (isArtistDetailPath(path)) {
|
||||
// Read at cleanup time on purpose: we want the scroll snapshot as it is
|
||||
// at navigation-away. Copying it at effect setup would stash a stale value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const snapshot = scrollSnapshotRef?.current;
|
||||
useArtistBrowseSessionStore.getState().stashReturnState(serverId, {
|
||||
...browseStateRef.current,
|
||||
scrollTop: snapshot?.scrollTop,
|
||||
visibleCount: snapshot?.visibleCount,
|
||||
});
|
||||
} else if (!isArtistsBrowsePath(path)) {
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}
|
||||
};
|
||||
}, [serverId, scrollSnapshotRef]);
|
||||
|
||||
return {
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
type BrowseScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
scrollSnapshotRef: RefObject<BrowseScrollSnapshot>;
|
||||
getScrollRoot: () => HTMLElement | null;
|
||||
isScrollRestorePending: boolean;
|
||||
resetKey: string;
|
||||
viewMode: 'grid' | 'list';
|
||||
listVirtualize: boolean;
|
||||
listVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
};
|
||||
|
||||
/** Scroll to top when browse filters shrink the list (e.g. scoped text search). */
|
||||
export function useArtistsBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey,
|
||||
viewMode,
|
||||
listVirtualize,
|
||||
listVirtualizer,
|
||||
}: Args): void {
|
||||
const prevResetKeyRef = useRef(resetKey);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isScrollRestorePending) return;
|
||||
if (prevResetKeyRef.current === resetKey) return;
|
||||
prevResetKeyRef.current = resetKey;
|
||||
|
||||
const el = getScrollRoot();
|
||||
if (!el) return;
|
||||
|
||||
if (el.scrollTop !== 0) {
|
||||
el.scrollTop = 0;
|
||||
el.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
}
|
||||
scrollSnapshotRef.current.scrollTop = 0;
|
||||
|
||||
if (listVirtualize && viewMode === 'list') listVirtualizer.scrollToOffset(0);
|
||||
}, [
|
||||
resetKey,
|
||||
isScrollRestorePending,
|
||||
getScrollRoot,
|
||||
scrollSnapshotRef,
|
||||
viewMode,
|
||||
listVirtualize,
|
||||
listVirtualizer,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import {
|
||||
peekArtistBrowseScrollRestore,
|
||||
useArtistBrowseSessionStore,
|
||||
} from '../store/artistBrowseSessionStore';
|
||||
import { shouldRestoreArtistBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
type PendingScroll = {
|
||||
scrollTop: number;
|
||||
visibleCount: number;
|
||||
};
|
||||
|
||||
export type UseArtistsBrowseScrollRestoreArgs = {
|
||||
serverId: string;
|
||||
scrollBodyEl: HTMLElement | null;
|
||||
visibleCount: number;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
};
|
||||
|
||||
export type UseArtistsBrowseScrollRestoreResult = {
|
||||
isScrollRestorePending: boolean;
|
||||
};
|
||||
|
||||
function readPendingScrollRestore(
|
||||
serverId: string,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): PendingScroll | null {
|
||||
if (!shouldRestoreArtistBrowseSession(navigationType, locationState) || !serverId) return null;
|
||||
return peekArtistBrowseScrollRestore(serverId);
|
||||
}
|
||||
|
||||
/** Restore Artists in-page scroll after returning from artist detail. */
|
||||
export function useArtistsBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
}: UseArtistsBrowseScrollRestoreArgs): UseArtistsBrowseScrollRestoreResult {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const initRef = useRef(false);
|
||||
const pendingRef = useRef<PendingScroll | null>(null);
|
||||
const doneRef = useRef(false);
|
||||
|
||||
// React Compiler refs rule: ref used as a once-only init guard (checked before first assignment); not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
if (!initRef.current) {
|
||||
initRef.current = true;
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
pendingRef.current = readPendingScrollRestore(serverId, navigationType, location.state);
|
||||
}
|
||||
|
||||
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
|
||||
() => readPendingScrollRestore(serverId, navigationType, location.state) !== null,
|
||||
);
|
||||
|
||||
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingRef.current;
|
||||
if (doneRef.current || !pending) return;
|
||||
if (!scrollBodyEl || loading) return;
|
||||
|
||||
const needsMore = visibleCount < pending.visibleCount && hasMore;
|
||||
if (needsMore) {
|
||||
if (!loadingMore) loadMore();
|
||||
return;
|
||||
}
|
||||
if (loadingMore) return;
|
||||
|
||||
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
||||
// eslint-disable-next-line react-hooks/immutability
|
||||
scrollBodyEl.scrollTop = pending.scrollTop;
|
||||
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
|
||||
pendingRef.current = null;
|
||||
doneRef.current = true;
|
||||
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsScrollRestorePending(false);
|
||||
useArtistBrowseSessionStore.getState().clearReturnStash(serverId);
|
||||
}, [
|
||||
scrollBodyEl,
|
||||
visibleCount,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
return { isScrollRestorePending };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { ALL_SENTINEL, artistLetterBucket, compareBuckets, type ArtistListFlatRow } from '../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
interface UseArtistsFilteringArgs {
|
||||
artists: SubsonicArtist[];
|
||||
filter: string;
|
||||
letterFilter: string;
|
||||
starredOnly: boolean;
|
||||
visibleCount: number;
|
||||
viewMode: 'grid' | 'list';
|
||||
/** Server `ignoredArticles` when known (local index); omit for Navidrome default. */
|
||||
ignoredArticles?: string | null;
|
||||
}
|
||||
|
||||
interface UseArtistsFilteringResult {
|
||||
filtered: SubsonicArtist[];
|
||||
visible: SubsonicArtist[];
|
||||
hasMore: boolean;
|
||||
groups: Record<string, SubsonicArtist[]>;
|
||||
letters: string[];
|
||||
artistListFlatRows: ArtistListFlatRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoised filter + group pipeline for the artists page. Reading
|
||||
* `starredOverrides` here keeps the star-toggle reactive without
|
||||
* dragging the full player store through Artists.tsx props.
|
||||
*
|
||||
* Walking 5000+ artists per render was measurable — every cheap state
|
||||
* update (selection mode, view mode, page size) used to re-filter the
|
||||
* whole list. With this hook the three artist arrays
|
||||
* (filtered → visible → flat-rows) only recompute when their explicit
|
||||
* deps change.
|
||||
*
|
||||
* Group-by-letter and flat-row construction short-circuit when the user
|
||||
* is on the grid view, since neither output is needed there.
|
||||
*/
|
||||
export function useArtistsFiltering({
|
||||
artists,
|
||||
filter,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
visibleCount,
|
||||
viewMode,
|
||||
ignoredArticles,
|
||||
}: UseArtistsFilteringArgs): UseArtistsFilteringResult {
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let out = artists;
|
||||
if (letterFilter !== ALL_SENTINEL) {
|
||||
out = out.filter(a => artistLetterBucket(a, ignoredArticles) === letterFilter);
|
||||
}
|
||||
if (filter) {
|
||||
const needle = filter.toLowerCase();
|
||||
out = out.filter(a => a.name.toLowerCase().includes(needle));
|
||||
}
|
||||
if (starredOnly) {
|
||||
out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred);
|
||||
}
|
||||
return out;
|
||||
}, [artists, letterFilter, filter, starredOnly, starredOverrides, ignoredArticles]);
|
||||
|
||||
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount]);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
const { groups, letters } = useMemo(() => {
|
||||
if (viewMode !== 'list') return { groups: {} as Record<string, SubsonicArtist[]>, letters: [] as string[] };
|
||||
const g: Record<string, SubsonicArtist[]> = {};
|
||||
for (const a of visible) {
|
||||
const key = artistLetterBucket(a, ignoredArticles);
|
||||
if (!g[key]) g[key] = [];
|
||||
g[key].push(a);
|
||||
}
|
||||
return { groups: g, letters: Object.keys(g).sort(compareBuckets) };
|
||||
}, [visible, viewMode, ignoredArticles]);
|
||||
|
||||
const artistListFlatRows = useMemo((): ArtistListFlatRow[] => {
|
||||
if (viewMode !== 'list') return [];
|
||||
const out: ArtistListFlatRow[] = [];
|
||||
for (const letter of letters) {
|
||||
out.push({ kind: 'letter', letter });
|
||||
const group = groups[letter];
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
out.push({ kind: 'artist', artist: group[i], isLastInLetter: i === group.length - 1 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [viewMode, letters, groups]);
|
||||
|
||||
return { filtered, visible, hasMore, groups, letters, artistListFlatRows };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @deprecated Import {@link useClientSliceInfiniteScroll} instead.
|
||||
* Kept as a thin alias for existing call sites.
|
||||
*/
|
||||
export {
|
||||
useClientSliceInfiniteScroll as useArtistsInfiniteScroll,
|
||||
type UseClientSliceInfiniteScrollArgs as UseArtistsInfiniteScrollArgs,
|
||||
type UseClientSliceInfiniteScrollResult as UseArtistsInfiniteScrollResult,
|
||||
} from './useClientSliceInfiniteScroll';
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { SubsonicArtist } from '../api/subsonicTypes';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||
BROWSE_TEXT_DEBOUNCE_RACE_MS,
|
||||
browseRaceCountsArtists,
|
||||
raceBrowseWithLocalFallback,
|
||||
runLocalBrowseArtists,
|
||||
runNetworkBrowseArtists,
|
||||
type LibrarySearchSurface,
|
||||
} from '../utils/library/browseTextSearch';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { offlineLocalBrowseEnabled, searchOfflineLocalArtists } from '@/features/offline';
|
||||
|
||||
/**
|
||||
* Debounced artist/composer name search with local-vs-network race when the
|
||||
* library index is enabled. Returns `textSearchArtists` when a raced query is
|
||||
* active; callers should pass `effectiveFilter` (empty while raced) into their
|
||||
* local filter hook so the query is not applied twice.
|
||||
*/
|
||||
export function useBrowseArtistTextSearch(
|
||||
filter: string,
|
||||
indexEnabled: boolean,
|
||||
serverId: string | null | undefined,
|
||||
surface: LibrarySearchSurface = 'artists_browse',
|
||||
) {
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const [debouncedFilter, setDebouncedFilter] = useState('');
|
||||
const [textSearchArtists, setTextSearchArtists] = useState<SubsonicArtist[] | null>(null);
|
||||
const [textSearchLoading, setTextSearchLoading] = useState(false);
|
||||
const searchGenRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const ms = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
|
||||
const timer = window.setTimeout(() => setDebouncedFilter(filter.trim()), ms);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [filter, indexEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = debouncedFilter;
|
||||
if (!q || !indexEnabled || !serverId) {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTextSearchArtists(null);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const gen = ++searchGenRef.current;
|
||||
const isStale = () => gen !== searchGenRef.current;
|
||||
setTextSearchLoading(true);
|
||||
|
||||
void (async () => {
|
||||
if (offlineBrowseActive) {
|
||||
const artists = offlineLocalBrowseEnabled(serverId)
|
||||
? await searchOfflineLocalArtists(serverId, q)
|
||||
: [];
|
||||
if (isStale()) return;
|
||||
setTextSearchArtists(artists);
|
||||
setTextSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
isStale,
|
||||
() => runLocalBrowseArtists(serverId, q),
|
||||
() => runNetworkBrowseArtists(q),
|
||||
{
|
||||
surface,
|
||||
query: q,
|
||||
indexEnabled,
|
||||
counts: browseRaceCountsArtists,
|
||||
},
|
||||
);
|
||||
if (isStale()) return;
|
||||
setTextSearchArtists(outcome?.result ?? null);
|
||||
setTextSearchLoading(false);
|
||||
})();
|
||||
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]);
|
||||
|
||||
const effectiveFilter = textSearchArtists != null ? '' : filter;
|
||||
return { textSearchArtists, textSearchLoading, effectiveFilter };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigateToArtistDetail } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to artist detail, remembering the current page for the back button. */
|
||||
export function useNavigateToArtist() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return useCallback(
|
||||
(artistId: string, opts?: { search?: string }) => {
|
||||
navigateToArtistDetail(navigate, location, artistId, opts);
|
||||
},
|
||||
[navigate, location],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useCoverArt } from '../cover/useCoverArt';
|
||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||
import type { SubsonicArtist, SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { useEffect, useState, Fragment, useMemo } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { ArrowDownUp } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useArtistLayoutStore, type ArtistSectionId } from '../store/artistLayoutStore';
|
||||
import {
|
||||
DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
useArtistAlbumYearSortStore,
|
||||
} from '../store/artistAlbumYearSortStore';
|
||||
|
||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||
import {
|
||||
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||
} from '../utils/componentHelpers/runArtistDetailPlay';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { offlineActionPolicy } from '@/features/offline';
|
||||
import {
|
||||
runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload,
|
||||
} from '../utils/componentHelpers/runArtistDetailActions';
|
||||
import ArtistDetailHero from '../components/artistDetail/ArtistDetailHero';
|
||||
import ArtistDetailTopTracks from '../components/artistDetail/ArtistDetailTopTracks';
|
||||
import ArtistDetailSimilarArtists from '../components/artistDetail/ArtistDetailSimilarArtists';
|
||||
import { ArtistCard } from '@/features/nowPlaying';
|
||||
import LosslessModeBanner from '../components/LosslessModeBanner';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX, GRID_COVER_WARM_LIMIT } from '../cover/layoutSizes';
|
||||
import { artistDetailCoverWarmAlbums } from '../components/artistDetail/topSongAlbumForCover';
|
||||
import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
|
||||
import { useWarmGridCovers } from '../hooks/useWarmGridCovers';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
import { sortArtistAlbumsByYear } from '../utils/library/sortArtistAlbums';
|
||||
import { readDetailServerId } from '../utils/navigation/detailServerScope';
|
||||
|
||||
|
||||
export default function ArtistDetail() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const losslessOnly = searchParams.get('lossless') === '1';
|
||||
const {
|
||||
artist, setArtist, albums, topSongs, info, featuredAlbums,
|
||||
loading, artistInfoLoading, featuredLoading,
|
||||
isStarred, setIsStarred,
|
||||
} = useArtistDetailData(id, { losslessOnly });
|
||||
const [radioLoading, setRadioLoading] = useState(false);
|
||||
const [playAllLoading, setPlayAllLoading] = useState(false);
|
||||
const [openedLink, setOpenedLink] = useState<string | null>(null);
|
||||
const { similarArtists, similarLoading } = useArtistSimilarArtists(artist, info, artistInfoLoading);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [similarCollapsed, setSimilarCollapsed] = useState(true);
|
||||
const [coverRevision, setCoverRevision] = useState(0);
|
||||
/** True after header cover onError — avoid `display:none` on the img (breaks recovery). */
|
||||
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
|
||||
|
||||
const playTrack = usePlayerStore(state => state.playTrack);
|
||||
const enqueue = usePlayerStore(state => state.enqueue);
|
||||
const authActiveServerId = useAuthStore(s => s.activeServerId);
|
||||
const activeServerId = readDetailServerId(searchParams, authActiveServerId) ?? '';
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(activeServerId && s.audiomuseNavidromeByServer[activeServerId]),
|
||||
);
|
||||
const enrichmentConfigured = useAuthStore(s => s.enrichmentPrimaryId !== null);
|
||||
const albumYearOrder = useArtistAlbumYearSortStore(
|
||||
s => s.orderByServer[activeServerId] ?? DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
);
|
||||
const toggleAlbumYearOrder = useArtistAlbumYearSortStore(s => s.toggleYearOrder);
|
||||
// MUST stay above the loading / !artist early returns or React's hook
|
||||
// call order will mismatch between renders.
|
||||
const sectionConfig = useArtistLayoutStore(s => s.sections);
|
||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||
const offlineCtx = useOfflineBrowseContext();
|
||||
const artistActionPolicy = offlineActionPolicy('artistDetail', offlineCtx.active);
|
||||
|
||||
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
||||
// Keyed on the artist's id / userRating primitives; depending on the `artist`
|
||||
// object would re-run on every render when its identity changes but those do not.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, artist?.id, artist?.userRating]);
|
||||
|
||||
const handleArtistEntityRating = (rating: number) => runArtistEntityRating({
|
||||
artist, id, rating, artistEntityRatingSupport, activeServerId, t,
|
||||
setArtistEntityRating, setArtist,
|
||||
});
|
||||
|
||||
const openLink = (url: string, key: string) => {
|
||||
open(url);
|
||||
setOpenedLink(key);
|
||||
setTimeout(() => setOpenedLink(null), 2500);
|
||||
};
|
||||
|
||||
const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred });
|
||||
|
||||
const handlePlayAll = () => runArtistDetailPlayAll({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||
});
|
||||
const handleShuffle = () => runArtistDetailShuffle({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||
});
|
||||
const handleStartRadio = () => {
|
||||
if (!artist) return;
|
||||
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
||||
};
|
||||
|
||||
const handleShareArtist = () => {
|
||||
if (!id || !artist) return;
|
||||
return runArtistShare({ artist, t });
|
||||
};
|
||||
|
||||
const playTopSongWithContinuation = (startIndex: number) => runArtistDetailPlayTopSong({
|
||||
topSongs,
|
||||
albums,
|
||||
serverId: activeServerId,
|
||||
startIndex,
|
||||
setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => runArtistImageUpload({
|
||||
e, artist, t, setUploading, setCoverRevision,
|
||||
});
|
||||
|
||||
// Cover URLs — must run every render (before early returns) or hook order breaks.
|
||||
const coverId = artist ? (artist.coverArt || artist.id) : '';
|
||||
const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, {
|
||||
libraryResolve: true,
|
||||
});
|
||||
const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' });
|
||||
|
||||
const groupedAlbums = useMemo(() => {
|
||||
if (albums.length === 0) return [];
|
||||
const RELEASE_TYPE_ORDER = ['album', 'ep', 'single', 'compilation', 'live', 'soundtrack', 'remix', 'other'];
|
||||
const defaultKey = 'album';
|
||||
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||
const translateType = (tag: string) =>
|
||||
t(`artistDetail.releaseTypes.${tag}`, { defaultValue: titleCase(tag) });
|
||||
|
||||
const groups = new Map<string, SubsonicAlbum[]>();
|
||||
for (const album of albums) {
|
||||
const key = album.releaseTypes?.length
|
||||
? album.releaseTypes.map(r => r.toLowerCase()).join(' · ')
|
||||
: defaultKey;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(album);
|
||||
}
|
||||
|
||||
const sortGroup = (group: SubsonicAlbum[]) =>
|
||||
sortArtistAlbumsByYear(group, albumYearOrder);
|
||||
|
||||
if (groups.size === 1 && groups.has(defaultKey)) {
|
||||
return [[translateType(defaultKey), sortGroup(albums)] as const];
|
||||
}
|
||||
|
||||
const sortKey = (key: string) => {
|
||||
const idx = RELEASE_TYPE_ORDER.indexOf(key.split(' · ')[0]);
|
||||
return idx >= 0 ? idx : RELEASE_TYPE_ORDER.length;
|
||||
};
|
||||
|
||||
return [...groups.entries()]
|
||||
.sort((a, b) => sortKey(a[0]) - sortKey(b[0]) || a[0].localeCompare(b[0]))
|
||||
.map(([key, group]) => [
|
||||
key.split(' · ').map(translateType).join(' · '),
|
||||
sortGroup(group),
|
||||
] as const);
|
||||
}, [albums, albumYearOrder, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHeaderCoverFailed(false);
|
||||
}, [coverId, coverRevision, id]);
|
||||
|
||||
const artistCoverWarmAlbums = useMemo(
|
||||
() => artistDetailCoverWarmAlbums(topSongs, albums, GRID_COVER_WARM_LIMIT),
|
||||
[topSongs, albums],
|
||||
);
|
||||
useWarmGridCovers(artistCoverWarmAlbums, COVER_DENSE_GRID_MIN_CELL_CSS_PX, {
|
||||
enabled: artistCoverWarmAlbums.length > 0,
|
||||
limit: GRID_COVER_WARM_LIMIT,
|
||||
surface: 'dense',
|
||||
});
|
||||
useLibraryCoverPrefetch(
|
||||
[
|
||||
{
|
||||
albums: artistCoverWarmAlbums.slice(0, 24),
|
||||
limit: 24,
|
||||
priority: 'high',
|
||||
surface: 'dense',
|
||||
},
|
||||
],
|
||||
[artistCoverWarmAlbums],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artist) {
|
||||
return (
|
||||
<div className="content-body">
|
||||
<div style={{ textAlign: 'center', padding: '4rem', color: 'var(--text-muted)' }}>
|
||||
{t('artistDetail.notFound')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
albumCount: sa.albumCount,
|
||||
}));
|
||||
const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0;
|
||||
const showNetworkSimilar =
|
||||
enrichmentConfigured &&
|
||||
(!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) &&
|
||||
(similarLoading || similarArtists.length > 0);
|
||||
const showSimilarSection = showAudiomuseSimilar || showNetworkSimilar;
|
||||
|
||||
// ── User-customisable section order + visibility ────────────────────────────
|
||||
// (`sectionConfig` is read at the top of the component — see comment there)
|
||||
const sectionHasData = (id: ArtistSectionId): boolean => {
|
||||
switch (id) {
|
||||
case 'bio': return !!info?.biography;
|
||||
case 'topTracks': return topSongs.length > 0;
|
||||
case 'similar': return showSimilarSection;
|
||||
case 'albums': return true; // always renders (empty state included)
|
||||
case 'featured': return featuredLoading || featuredAlbums.length > 0;
|
||||
}
|
||||
};
|
||||
// The order the user actually sees: hidden-via-toggle and empty sections
|
||||
// are filtered out, so the "first rendered section gets marginTop: 0" rule
|
||||
// works regardless of the configured order.
|
||||
const renderableSectionIds = sectionConfig
|
||||
.filter(s => s.visible)
|
||||
.map(s => s.id)
|
||||
.filter(sectionHasData);
|
||||
const sectionMt = (id: ArtistSectionId) => renderableSectionIds[0] === id ? '0' : '2rem';
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<ArtistDetailHero
|
||||
artist={artist}
|
||||
id={id}
|
||||
albums={albums}
|
||||
info={info}
|
||||
isStarred={isStarred}
|
||||
artistEntityRating={artistEntityRating}
|
||||
handleArtistEntityRating={handleArtistEntityRating}
|
||||
toggleStar={toggleStar}
|
||||
handlePlayAll={handlePlayAll}
|
||||
handleShuffle={handleShuffle}
|
||||
handleStartRadio={handleStartRadio}
|
||||
handleShareArtist={handleShareArtist}
|
||||
handleImageUpload={handleImageUpload}
|
||||
playAllLoading={playAllLoading}
|
||||
radioLoading={radioLoading}
|
||||
uploading={uploading}
|
||||
openedLink={openedLink}
|
||||
openLink={openLink}
|
||||
coverId={coverId}
|
||||
coverRef={artistCoverRefResolved}
|
||||
coverRevision={coverRevision}
|
||||
headerCoverFailed={headerCoverFailed}
|
||||
setHeaderCoverFailed={setHeaderCoverFailed}
|
||||
actionPolicy={artistActionPolicy}
|
||||
/>
|
||||
|
||||
{losslessOnly && <LosslessModeBanner />}
|
||||
|
||||
{/* User-reorderable sections — order + visibility configured in Settings.
|
||||
* Each case renders the same JSX it did pre-refactor; only `marginTop`
|
||||
* (now derived from the actual render order) and the outer wrapper changed. */}
|
||||
{renderableSectionIds.map(sectionId => {
|
||||
switch (sectionId) {
|
||||
case 'bio': return (
|
||||
<div key="bio" style={{ marginTop: sectionMt('bio') }}>
|
||||
<ArtistCard
|
||||
artistName={artist.name}
|
||||
artistId={id}
|
||||
artistInfo={info}
|
||||
hideArtistName
|
||||
hideSimilar
|
||||
coverFallback={coverId ? { src: artistCoverFallback.src, cacheKey: artistCoverFallback.cacheKey } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'topTracks': return (
|
||||
<ArtistDetailTopTracks
|
||||
key="topTracks"
|
||||
topSongs={topSongs}
|
||||
albums={albums}
|
||||
marginTop={sectionMt('topTracks')}
|
||||
playTopSongWithContinuation={playTopSongWithContinuation}
|
||||
losslessOnly={losslessOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'similar': return (
|
||||
<ArtistDetailSimilarArtists
|
||||
key="similar"
|
||||
marginTop={sectionMt('similar')}
|
||||
showAudiomuseSimilar={showAudiomuseSimilar}
|
||||
showNetworkSimilar={showNetworkSimilar}
|
||||
similarLoading={similarLoading}
|
||||
similarArtists={similarArtists}
|
||||
serverSimilarArtists={serverSimilarArtists}
|
||||
similarCollapsed={similarCollapsed}
|
||||
setSimilarCollapsed={setSimilarCollapsed}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'albums': return (
|
||||
<Fragment key="albums">
|
||||
<div
|
||||
style={{
|
||||
marginTop: sectionMt('albums'),
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
{losslessOnly
|
||||
? t('artistDetail.albumsByLossless', { name: artist.name })
|
||||
: t('artistDetail.albumsBy', { name: artist.name })}
|
||||
</h2>
|
||||
{albums.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface btn-sort-active"
|
||||
onClick={() => toggleAlbumYearOrder(activeServerId)}
|
||||
aria-label={t('artistDetail.sortYearToggleAria')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<ArrowDownUp size={14} />
|
||||
{albumYearOrder === 'yearDesc'
|
||||
? t('artistDetail.sortYearDesc')
|
||||
: t('artistDetail.sortYearAsc')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{albums.length > 0 ? (
|
||||
groupedAlbums.length === 1 ? (
|
||||
<VirtualCardGrid
|
||||
items={groupedAlbums[0][1]}
|
||||
itemKey={(a, i) => `${a.id}-${i}`}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={groupedAlbums[0][1].length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : groupedAlbums.map(([label, group]) => (
|
||||
<div key={label} className="artist-release-group">
|
||||
<div className="artist-release-group__header">
|
||||
<h3>{label}</h3>
|
||||
<span className="artist-release-group__count">{group.length}</span>
|
||||
</div>
|
||||
<VirtualCardGrid
|
||||
items={group}
|
||||
itemKey={(a, i) => `${a.id}-${i}`}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={group.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-muted)' }}>{t('artistDetail.noAlbums')}</p>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
case 'featured': return (
|
||||
<Fragment key="featured">
|
||||
<h2 className="section-title" style={{ marginTop: sectionMt('featured'), marginBottom: '1rem' }}>
|
||||
{t('artistDetail.featuredOn')}
|
||||
</h2>
|
||||
{featuredLoading ? (
|
||||
<div className="album-grid-wrap">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<VirtualCardGrid
|
||||
items={featuredAlbums}
|
||||
itemKey={(a, i) => `${a.id}-${i}`}
|
||||
rowVariant="album"
|
||||
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
||||
layoutSignal={featuredAlbums.length}
|
||||
wrapClassName="album-grid-wrap album-grid-wrap--artist"
|
||||
wrapStyle={{ animation: 'fadeIn 0.3s ease' }}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => <AlbumCard album={a} />}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { LayoutGrid, List, Images } from 'lucide-react';
|
||||
import SelectionToggleButton from '../components/SelectionToggleButton';
|
||||
import StarFilterButton from '../components/StarFilterButton';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID, ARTISTS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { useElementClientHeightById, useElementClientHeightForElement } from '../hooks/useResizeClientHeight';
|
||||
import { useVirtualizerScrollMargin } from '../hooks/useVirtualizerScrollMargin';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import {
|
||||
ALL_SENTINEL,
|
||||
ALPHABET,
|
||||
OTHER_BUCKET,
|
||||
ARTIST_LIST_LAST_IN_LETTER_EST,
|
||||
ARTIST_LIST_LETTER_ROW_EST,
|
||||
ARTIST_LIST_ROW_EST,
|
||||
} from '../utils/componentHelpers/artistsHelpers';
|
||||
import { useArtistsFiltering } from '../hooks/useArtistsFiltering';
|
||||
import { useLibraryIgnoredArticles } from '../hooks/useLibraryIgnoredArticles';
|
||||
import { useArtistsBrowseCatalog } from '../hooks/useArtistsBrowseCatalog';
|
||||
import { useBrowseArtistTextSearch } from '../hooks/useBrowseArtistTextSearch';
|
||||
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
||||
import { useClientSliceInfiniteScroll } from '../hooks/useClientSliceInfiniteScroll';
|
||||
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
|
||||
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
||||
import { ArtistsGridView } from '../components/artists/ArtistsGridView';
|
||||
import { ArtistsListView } from '../components/artists/ArtistsListView';
|
||||
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
||||
import { useArtistsBrowseFilters, type ArtistBrowseScrollSnapshot } from '../hooks/useArtistsBrowseFilters';
|
||||
import { useArtistsBrowseScrollRestore } from '../hooks/useArtistsBrowseScrollRestore';
|
||||
import { useArtistsBrowseScrollReset } from '../hooks/useArtistsBrowseScrollReset';
|
||||
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
|
||||
import { peekArtistBrowseScrollRestore } from '../store/artistBrowseSessionStore';
|
||||
import { readArtistBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
|
||||
export default function Artists() {
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const { t } = useTranslation();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
|
||||
const scrollSnapshotRef = useRef<ArtistBrowseScrollSnapshot>({ scrollTop: 0, visibleCount: 0 });
|
||||
const restoreVisibleCountRef = useRef<number | undefined>(
|
||||
peekArtistBrowseScrollRestore(serverId)?.visibleCount,
|
||||
);
|
||||
|
||||
const {
|
||||
letterFilter,
|
||||
setLetterFilter,
|
||||
starredOnly,
|
||||
setStarredOnly,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
} = useArtistsBrowseFilters(serverId, scrollSnapshotRef);
|
||||
|
||||
const artistsSearchQuery = useScopedBrowseSearchQuery('artists');
|
||||
|
||||
const {
|
||||
scrollBodyEl: artistsScrollBodyEl,
|
||||
bindScrollBody: bindArtistsScrollBody,
|
||||
getScrollRoot: getArtistsScrollRoot,
|
||||
} = useInpageScrollViewport();
|
||||
|
||||
const showArtistImages = useAuthStore(s => s.showArtistImages);
|
||||
const PAGE_SIZE = showArtistImages ? 50 : 100; // Smaller with images to reduce I/O
|
||||
const navigateToArtist = useNavigateToArtist();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
||||
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
|
||||
|
||||
const {
|
||||
catalogArtists,
|
||||
loading: catalogLoading,
|
||||
catalogHasMore,
|
||||
catalogLoadingMore,
|
||||
browseMode,
|
||||
loadCatalogChunk,
|
||||
catalogLoadingRef,
|
||||
} = useArtistsBrowseCatalog({
|
||||
serverId,
|
||||
indexEnabled,
|
||||
starredOnly,
|
||||
musicLibraryFilterVersion,
|
||||
});
|
||||
|
||||
const { textSearchArtists, textSearchLoading, effectiveFilter } = useBrowseArtistTextSearch(
|
||||
artistsSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
);
|
||||
const artists = textSearchArtists ?? catalogArtists;
|
||||
const loading = catalogLoading || textSearchLoading;
|
||||
const textSearchActive = textSearchArtists != null;
|
||||
/** Scoped/plain text filter — canonical CSS grid, not row virtualization (small result sets). */
|
||||
const artistBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive
|
||||
|| artistsSearchQuery.trim().length > 0;
|
||||
|
||||
const {
|
||||
visibleCount,
|
||||
loadingMore: sliceLoadingMore,
|
||||
loadMore: sliceLoadMore,
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: PAGE_SIZE,
|
||||
resetDeps: [artistsSearchQuery, letterFilter, starredOnly, viewMode, musicLibraryFilterVersion, serverId],
|
||||
getScrollRoot: getArtistsScrollRoot,
|
||||
scrollRootEl: artistsScrollBodyEl,
|
||||
restoreDisplayCount: restoreVisibleCountRef.current,
|
||||
});
|
||||
|
||||
// ── Multi-selection ──────────────────────────────────────────────────────
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setSelectionMode(v => !v);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
|
||||
|
||||
const ignoredArticles = useLibraryIgnoredArticles(serverId, indexEnabled);
|
||||
|
||||
const {
|
||||
filtered, visible, hasMore, groups, letters, artistListFlatRows,
|
||||
} = useArtistsFiltering({ artists, filter: effectiveFilter, letterFilter, starredOnly, visibleCount, viewMode, ignoredArticles });
|
||||
|
||||
const pendingLetterMatch =
|
||||
browseMode === 'slice'
|
||||
&& !textSearchActive
|
||||
&& !starredOnly
|
||||
&& letterFilter !== ALL_SENTINEL
|
||||
&& filtered.length === 0
|
||||
&& catalogHasMore;
|
||||
|
||||
const gridHasMore =
|
||||
hasMore
|
||||
|| (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore);
|
||||
const gridLoadingMore = sliceLoadingMore || catalogLoadingMore;
|
||||
|
||||
const loadMoreRef = useRef<() => void>(() => {});
|
||||
const sentinelIntersectingRef = useRef(false);
|
||||
|
||||
const loadMoreGrid = useCallback(() => {
|
||||
if (hasMore) {
|
||||
sliceLoadMore();
|
||||
return;
|
||||
}
|
||||
if (browseMode === 'slice' && !textSearchActive && !starredOnly && catalogHasMore && !catalogLoadingRef.current) {
|
||||
void loadCatalogChunk(true);
|
||||
}
|
||||
}, [
|
||||
hasMore,
|
||||
sliceLoadMore,
|
||||
browseMode,
|
||||
textSearchActive,
|
||||
starredOnly,
|
||||
catalogHasMore,
|
||||
loadCatalogChunk,
|
||||
catalogLoadingRef,
|
||||
]);
|
||||
|
||||
loadMoreRef.current = loadMoreGrid;
|
||||
|
||||
scrollSnapshotRef.current = {
|
||||
scrollTop: artistsScrollBodyEl?.scrollTop ?? 0,
|
||||
visibleCount,
|
||||
};
|
||||
|
||||
const { isScrollRestorePending } = useArtistsBrowseScrollRestore({
|
||||
serverId,
|
||||
scrollBodyEl: artistsScrollBodyEl,
|
||||
visibleCount,
|
||||
loading: loading || pendingLetterMatch,
|
||||
loadingMore: gridLoadingMore,
|
||||
hasMore: gridHasMore,
|
||||
loadMore: loadMoreGrid,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isScrollRestorePending || !readArtistBrowseRestore(location.state)) return;
|
||||
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
|
||||
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingLetterMatch || catalogLoadingRef.current) return;
|
||||
void loadCatalogChunk(true);
|
||||
}, [pendingLetterMatch, loadCatalogChunk, catalogLoadingRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (browseMode !== 'slice' || textSearchActive || starredOnly) return;
|
||||
if (!sentinelIntersectingRef.current) return;
|
||||
if (visibleCount < filtered.length - PAGE_SIZE) return;
|
||||
if (!catalogHasMore || catalogLoadingRef.current) return;
|
||||
void loadCatalogChunk(true);
|
||||
}, [
|
||||
browseMode,
|
||||
textSearchActive,
|
||||
starredOnly,
|
||||
visibleCount,
|
||||
filtered.length,
|
||||
catalogHasMore,
|
||||
loadCatalogChunk,
|
||||
catalogLoadingRef,
|
||||
PAGE_SIZE,
|
||||
]);
|
||||
|
||||
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
||||
active: gridHasMore,
|
||||
getScrollRoot: getArtistsScrollRoot,
|
||||
scrollRootEl: artistsScrollBodyEl,
|
||||
onIntersect: () => loadMoreRef.current(),
|
||||
drainSignal: gridLoadingMore,
|
||||
intersectingRef: sentinelIntersectingRef,
|
||||
});
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(artistsScrollBodyEl, [
|
||||
artistsSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const mainScrollViewportHeight = useElementClientHeightById(APP_MAIN_SCROLL_VIEWPORT_ID);
|
||||
const artistsInpageScrollHeight = useElementClientHeightForElement(
|
||||
artistsScrollBodyEl,
|
||||
mainScrollViewportHeight,
|
||||
);
|
||||
|
||||
const getInpageScrollElement = useCallback(
|
||||
() =>
|
||||
getArtistsScrollRoot()
|
||||
?? (document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID) as HTMLElement | null),
|
||||
[getArtistsScrollRoot],
|
||||
);
|
||||
|
||||
const artistListOverscan = Math.max(
|
||||
12,
|
||||
Math.ceil(artistsInpageScrollHeight / ARTIST_LIST_ROW_EST),
|
||||
);
|
||||
|
||||
const artistListWrapRef = useRef<HTMLDivElement>(null);
|
||||
const artistListScrollMargin = useVirtualizerScrollMargin(
|
||||
artistListWrapRef,
|
||||
getInpageScrollElement,
|
||||
{
|
||||
active: !artistBrowsePlainLayout && viewMode === 'list',
|
||||
deps: [artistListFlatRows.length],
|
||||
},
|
||||
);
|
||||
|
||||
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const artistListVirtualizer = useVirtualizer({
|
||||
count:
|
||||
artistBrowsePlainLayout || viewMode !== 'list' ? 0 : artistListFlatRows.length,
|
||||
getScrollElement: getInpageScrollElement,
|
||||
estimateSize: index => {
|
||||
const row = artistListFlatRows[index];
|
||||
if (!row) return ARTIST_LIST_ROW_EST;
|
||||
if (row.kind === 'letter') return ARTIST_LIST_LETTER_ROW_EST;
|
||||
return row.isLastInLetter ? ARTIST_LIST_LAST_IN_LETTER_EST : ARTIST_LIST_ROW_EST;
|
||||
},
|
||||
getItemKey: index => {
|
||||
const row = artistListFlatRows[index];
|
||||
if (!row) return index;
|
||||
if (row.kind === 'letter') return `letter:${row.letter}`;
|
||||
return `artist:${row.artist.id}`;
|
||||
},
|
||||
overscan: artistListOverscan,
|
||||
scrollMargin: artistListScrollMargin,
|
||||
});
|
||||
|
||||
const browseScrollResetKey = [
|
||||
artistsSearchQuery,
|
||||
letterFilter,
|
||||
starredOnly,
|
||||
viewMode,
|
||||
serverId,
|
||||
musicLibraryFilterVersion,
|
||||
textSearchArtists?.length ?? '',
|
||||
textSearchArtists?.[0]?.id ?? '',
|
||||
].join('\0');
|
||||
|
||||
useArtistsBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot: getArtistsScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: browseScrollResetKey,
|
||||
viewMode,
|
||||
listVirtualize: !artistBrowsePlainLayout,
|
||||
listVirtualizer: artistListVirtualizer,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}
|
||||
>
|
||||
<div className="mainstage-inpage-toolbar">
|
||||
<div className="page-sticky-header">
|
||||
<div className="mainstage-inpage-toolbar-row">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
||||
{selectionMode && selectedIds.size > 0
|
||||
? t('artists.selectionCount', { count: selectedIds.size })
|
||||
: t('artists.title')}
|
||||
</h1>
|
||||
{textSearchLoading && (
|
||||
<div className="spinner" style={{ width: 16, height: 16, flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{!(selectionMode && selectedIds.size > 0) && (<>
|
||||
<StarFilterButton size="compact" active={starredOnly} onChange={setStarredOnly} />
|
||||
<button
|
||||
className={`btn btn-surface`}
|
||||
onClick={() => setShowArtistImages(!showArtistImages)}
|
||||
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
|
||||
data-tooltip-wrap
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<Images size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.gridView')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--text-on-accent)', padding: '0.5rem' } : { padding: '0.5rem' }}
|
||||
data-tooltip={t('artists.listView')}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<SelectionToggleButton
|
||||
active={selectionMode}
|
||||
onToggle={toggleSelectionMode}
|
||||
selectLabel={t('artists.select')}
|
||||
cancelLabel={t('artists.cancelSelect')}
|
||||
startTooltip={t('artists.startSelect')}
|
||||
iconSize={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mainstage-inpage-toolbar-alpha-row">
|
||||
{ALPHABET.map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLetterFilter(l)}
|
||||
className={`artists-alpha-btn${letterFilter === l ? ' artists-alpha-btn--active' : ''}`}
|
||||
>
|
||||
{l === ALL_SENTINEL ? t('artists.all') : l === OTHER_BUCKET ? t('artists.other') : l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OverlayScrollArea
|
||||
className="mainstage-inpage-scroll"
|
||||
viewportClassName="mainstage-inpage-scroll__viewport"
|
||||
viewportId={ARTISTS_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
viewportRef={bindArtistsScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loading,
|
||||
viewMode,
|
||||
visible.length,
|
||||
artistListFlatRows.length,
|
||||
filtered.length,
|
||||
gridHasMore,
|
||||
selectionMode,
|
||||
]}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}><div className="spinner" /></div>}
|
||||
|
||||
{!loading && pendingLetterMatch && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !pendingLetterMatch && viewMode === 'grid' && (
|
||||
<ArtistsGridView
|
||||
visible={visible}
|
||||
disableVirtualization={artistBrowsePlainLayout}
|
||||
layoutKey={browseScrollResetKey}
|
||||
selectionMode={selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
selectedArtists={selectedArtists}
|
||||
showArtistImages={showArtistImages}
|
||||
toggleSelect={toggleSelect}
|
||||
onOpenArtist={navigateToArtist}
|
||||
openContextMenu={openContextMenu}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !pendingLetterMatch && viewMode === 'list' && (
|
||||
<ArtistsListView
|
||||
virtualized={!artistBrowsePlainLayout}
|
||||
groups={groups}
|
||||
letters={letters}
|
||||
artistListFlatRows={artistListFlatRows}
|
||||
artistListVirtualizer={artistListVirtualizer}
|
||||
artistListWrapRef={artistListWrapRef}
|
||||
artistListScrollMargin={artistListScrollMargin}
|
||||
selectionMode={selectionMode}
|
||||
selectedIds={selectedIds}
|
||||
selectedArtists={selectedArtists}
|
||||
showArtistImages={showArtistImages}
|
||||
toggleSelect={toggleSelect}
|
||||
onOpenArtist={navigateToArtist}
|
||||
openContextMenu={openContextMenu}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && gridHasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={gridLoadingMore} />
|
||||
)}
|
||||
|
||||
{!loading && !pendingLetterMatch && filtered.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
{t('artists.notFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isScrollRestorePending && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
useArtistAlbumYearSortStore,
|
||||
} from './artistAlbumYearSortStore';
|
||||
|
||||
describe('artistAlbumYearSortStore', () => {
|
||||
beforeEach(() => {
|
||||
useArtistAlbumYearSortStore.setState({ orderByServer: {} });
|
||||
});
|
||||
|
||||
it('defaults to newest first per server', () => {
|
||||
expect(useArtistAlbumYearSortStore.getState().yearOrderFor('s1')).toBe(
|
||||
DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles between newest and oldest for the same server', () => {
|
||||
const { toggleYearOrder, yearOrderFor } = useArtistAlbumYearSortStore.getState();
|
||||
toggleYearOrder('s1');
|
||||
expect(yearOrderFor('s1')).toBe('yearAsc');
|
||||
toggleYearOrder('s1');
|
||||
expect(yearOrderFor('s1')).toBe('yearDesc');
|
||||
});
|
||||
|
||||
it('keeps order per server independently', () => {
|
||||
const { toggleYearOrder, yearOrderFor } = useArtistAlbumYearSortStore.getState();
|
||||
toggleYearOrder('s1');
|
||||
expect(yearOrderFor('s1')).toBe('yearAsc');
|
||||
expect(yearOrderFor('s2')).toBe('yearDesc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { ArtistAlbumYearOrder } from '../utils/library/sortArtistAlbums';
|
||||
|
||||
export const DEFAULT_ARTIST_ALBUM_YEAR_ORDER: ArtistAlbumYearOrder = 'yearDesc';
|
||||
|
||||
interface ArtistAlbumYearSortStore {
|
||||
orderByServer: Record<string, ArtistAlbumYearOrder>;
|
||||
yearOrderFor: (serverId: string) => ArtistAlbumYearOrder;
|
||||
toggleYearOrder: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export const useArtistAlbumYearSortStore = create<ArtistAlbumYearSortStore>((set, get) => ({
|
||||
orderByServer: {},
|
||||
|
||||
yearOrderFor: (serverId) =>
|
||||
get().orderByServer[serverId] ?? DEFAULT_ARTIST_ALBUM_YEAR_ORDER,
|
||||
|
||||
toggleYearOrder: (serverId) => {
|
||||
if (!serverId) return;
|
||||
const current = get().yearOrderFor(serverId);
|
||||
const next: ArtistAlbumYearOrder = current === 'yearDesc' ? 'yearAsc' : 'yearDesc';
|
||||
set(s => ({
|
||||
orderByServer: { ...s.orderByServer, [serverId]: next },
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
peekArtistBrowseScrollRestore,
|
||||
useArtistBrowseSessionStore,
|
||||
} from './artistBrowseSessionStore';
|
||||
|
||||
describe('artistBrowseSessionStore', () => {
|
||||
beforeEach(() => {
|
||||
useArtistBrowseSessionStore.setState({ returnStashByServer: {} });
|
||||
});
|
||||
|
||||
it('stashes and peeks return state per server', () => {
|
||||
const { stashReturnState, peekReturnStash } = useArtistBrowseSessionStore.getState();
|
||||
stashReturnState('s1', {
|
||||
...DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
filter: 'mozart',
|
||||
letterFilter: 'M',
|
||||
viewMode: 'list',
|
||||
scrollTop: 240,
|
||||
visibleCount: 120,
|
||||
});
|
||||
expect(peekReturnStash('s1')).toEqual({
|
||||
filter: 'mozart',
|
||||
letterFilter: 'M',
|
||||
starredOnly: false,
|
||||
viewMode: 'list',
|
||||
showArtistImages: true,
|
||||
scrollTop: 240,
|
||||
visibleCount: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears return stash for a server', () => {
|
||||
const { stashReturnState, clearReturnStash, peekReturnStash } = useArtistBrowseSessionStore.getState();
|
||||
stashReturnState('s1', DEFAULT_ARTIST_BROWSE_RETURN_STATE);
|
||||
clearReturnStash('s1');
|
||||
expect(peekReturnStash('s1')).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes scroll restore target when scroll fields are present', () => {
|
||||
useArtistBrowseSessionStore.getState().stashReturnState('s1', {
|
||||
...DEFAULT_ARTIST_BROWSE_RETURN_STATE,
|
||||
scrollTop: 512,
|
||||
visibleCount: 80,
|
||||
});
|
||||
expect(peekArtistBrowseScrollRestore('s1')).toEqual({ scrollTop: 512, visibleCount: 80 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import { ALL_SENTINEL } from '../utils/componentHelpers/artistsHelpers';
|
||||
|
||||
export type ArtistBrowseViewMode = 'grid' | 'list';
|
||||
|
||||
/** Browse state restored when returning to Artists via back from artist detail. */
|
||||
export interface ArtistBrowseReturnState {
|
||||
filter: string;
|
||||
letterFilter: string;
|
||||
starredOnly: boolean;
|
||||
viewMode: ArtistBrowseViewMode;
|
||||
showArtistImages: boolean;
|
||||
scrollTop?: number;
|
||||
visibleCount?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ARTIST_BROWSE_RETURN_STATE: ArtistBrowseReturnState = {
|
||||
filter: '',
|
||||
letterFilter: ALL_SENTINEL,
|
||||
starredOnly: false,
|
||||
viewMode: 'grid',
|
||||
showArtistImages: true,
|
||||
};
|
||||
|
||||
interface ArtistBrowseSessionStore {
|
||||
returnStashByServer: Record<string, ArtistBrowseReturnState>;
|
||||
stashReturnState: (serverId: string, state: ArtistBrowseReturnState) => void;
|
||||
clearReturnStash: (serverId: string) => void;
|
||||
peekReturnStash: (serverId: string) => ArtistBrowseReturnState | null;
|
||||
}
|
||||
|
||||
export const useArtistBrowseSessionStore = create<ArtistBrowseSessionStore>((set, get) => ({
|
||||
returnStashByServer: {},
|
||||
|
||||
stashReturnState: (serverId, state) => {
|
||||
if (!serverId) return;
|
||||
set((s) => ({
|
||||
returnStashByServer: {
|
||||
...s.returnStashByServer,
|
||||
[serverId]: {
|
||||
filter: state.filter,
|
||||
letterFilter: state.letterFilter,
|
||||
starredOnly: state.starredOnly,
|
||||
viewMode: state.viewMode,
|
||||
showArtistImages: state.showArtistImages,
|
||||
...(typeof state.scrollTop === 'number' ? { scrollTop: state.scrollTop } : {}),
|
||||
...(typeof state.visibleCount === 'number' ? { visibleCount: state.visibleCount } : {}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearReturnStash: (serverId) => {
|
||||
if (!serverId) return;
|
||||
const next = { ...get().returnStashByServer };
|
||||
delete next[serverId];
|
||||
set({ returnStashByServer: next });
|
||||
},
|
||||
|
||||
peekReturnStash: (serverId) => {
|
||||
if (!serverId) return null;
|
||||
const stash = get().returnStashByServer[serverId];
|
||||
if (!stash) return null;
|
||||
return {
|
||||
filter: stash.filter,
|
||||
letterFilter: stash.letterFilter,
|
||||
starredOnly: stash.starredOnly,
|
||||
viewMode: stash.viewMode,
|
||||
showArtistImages: stash.showArtistImages,
|
||||
...(typeof stash.scrollTop === 'number' ? { scrollTop: stash.scrollTop } : {}),
|
||||
...(typeof stash.visibleCount === 'number' ? { visibleCount: stash.visibleCount } : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
export function peekArtistBrowseScrollRestore(
|
||||
serverId: string,
|
||||
): { scrollTop: number; visibleCount: number } | null {
|
||||
const stash = useArtistBrowseSessionStore.getState().peekReturnStash(serverId);
|
||||
if (!stash) return null;
|
||||
if (typeof stash.scrollTop !== 'number' || typeof stash.visibleCount !== 'number') return null;
|
||||
return {
|
||||
scrollTop: Math.max(0, stash.scrollTop),
|
||||
visibleCount: Math.max(0, stash.visibleCount),
|
||||
};
|
||||
}
|
||||
|
||||
/** True when pathname is the Artists browse route (`/artists`). */
|
||||
export function isArtistsBrowsePath(pathname: string): boolean {
|
||||
return pathname === '/artists';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type ArtistSectionId = 'bio' | 'topTracks' | 'similar' | 'albums' | 'featured';
|
||||
|
||||
export interface ArtistSectionConfig {
|
||||
id: ArtistSectionId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default order matches the historical layout of `pages/ArtistDetail.tsx` so
|
||||
* existing users see no change until they explicitly customise it.
|
||||
*/
|
||||
export const DEFAULT_ARTIST_SECTIONS: ArtistSectionConfig[] = [
|
||||
{ id: 'bio', visible: true },
|
||||
{ id: 'topTracks', visible: true },
|
||||
{ id: 'similar', visible: true },
|
||||
{ id: 'albums', visible: true },
|
||||
{ id: 'featured', visible: true },
|
||||
];
|
||||
|
||||
interface ArtistLayoutStore {
|
||||
sections: ArtistSectionConfig[];
|
||||
setSections: (sections: ArtistSectionConfig[]) => void;
|
||||
toggleSection: (id: ArtistSectionId) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useArtistLayoutStore = create<ArtistLayoutStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sections: DEFAULT_ARTIST_SECTIONS,
|
||||
|
||||
setSections: (sections) => set({ sections }),
|
||||
|
||||
toggleSection: (id) => set((s) => ({
|
||||
sections: s.sections.map(sec => sec.id === id ? { ...sec, visible: !sec.visible } : sec),
|
||||
})),
|
||||
|
||||
reset: () => set({ sections: DEFAULT_ARTIST_SECTIONS }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_artist_layout',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
// Sanitize: drop null/corrupt entries, append any new sections that
|
||||
// were added in a later release so they don't silently disappear.
|
||||
const knownIds = new Set(DEFAULT_ARTIST_SECTIONS.map(s => s.id));
|
||||
const safe = (state.sections ?? [])
|
||||
.filter((s): s is ArtistSectionConfig => s != null && typeof s.id === 'string' && knownIds.has(s.id as ArtistSectionId));
|
||||
const seen = new Set(safe.map(s => s.id));
|
||||
const missing = DEFAULT_ARTIST_SECTIONS.filter(s => !seen.has(s.id));
|
||||
state.sections = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
artistBucketKey,
|
||||
artistLetterBucket,
|
||||
compareBuckets,
|
||||
DEFAULT_IGNORED_ARTICLES,
|
||||
OTHER_BUCKET,
|
||||
ALPHABET,
|
||||
sortKeyFromDisplayName,
|
||||
stripLeadingArticles,
|
||||
} from './artistsHelpers';
|
||||
|
||||
describe('stripLeadingArticles', () => {
|
||||
it('strips The from Beatles', () => {
|
||||
expect(stripLeadingArticles('The Beatles', DEFAULT_IGNORED_ARTICLES)).toBe('Beatles');
|
||||
});
|
||||
|
||||
it('strips The from Kinks', () => {
|
||||
expect(stripLeadingArticles('The Kinks', DEFAULT_IGNORED_ARTICLES)).toBe('Kinks');
|
||||
});
|
||||
|
||||
it('honours a custom ignoredArticles list', () => {
|
||||
expect(stripLeadingArticles('Los Lobos', 'Los')).toBe('Lobos');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortKeyFromDisplayName', () => {
|
||||
it('strips articles and lowercases', () => {
|
||||
expect(sortKeyFromDisplayName('The Beatles')).toBe('beatles');
|
||||
});
|
||||
});
|
||||
|
||||
describe('artistLetterBucket', () => {
|
||||
it('buckets a browse row by its display name, ignoring stale nameSort', () => {
|
||||
const artist = { id: '1', name: 'The Chemical Brothers', nameSort: 'the chemical brothers' };
|
||||
expect(artistLetterBucket(artist)).toBe('C');
|
||||
});
|
||||
|
||||
it('uses a server ignoredArticles override when supplied', () => {
|
||||
const artist = { id: '2', name: 'Los Lobos' };
|
||||
expect(artistLetterBucket(artist, 'Los')).toBe('L');
|
||||
});
|
||||
});
|
||||
|
||||
describe('screenshot T-filter artists (Navidrome article rules)', () => {
|
||||
it('keeps Theme/Tossers/Temper/Tiger under T after stripping The', () => {
|
||||
expect(artistBucketKey('The Theme Guys')).toBe('T');
|
||||
expect(artistBucketKey('The Tossers')).toBe('T');
|
||||
expect(artistBucketKey('The Temper Trap')).toBe('T');
|
||||
expect(artistBucketKey('The Tiger Lillies')).toBe('T');
|
||||
expect(artistBucketKey('TV Themes')).toBe('T');
|
||||
expect(artistBucketKey('Tribute To The N...')).toBe('T');
|
||||
});
|
||||
|
||||
it('moves Beatles/Chemical/Cure/Doors out of T', () => {
|
||||
expect(artistBucketKey('The Beatles')).toBe('B');
|
||||
expect(artistBucketKey('The Chemical Brothers')).toBe('C');
|
||||
expect(artistBucketKey('The Cure')).toBe('C');
|
||||
expect(artistBucketKey('The Doors')).toBe('D');
|
||||
expect(artistBucketKey('The Fat Rat')).toBe('F');
|
||||
});
|
||||
|
||||
it('keeps glued TheFatRat under T (no space after article — Navidrome parity)', () => {
|
||||
expect(artistBucketKey('TheFatRat')).toBe('T');
|
||||
});
|
||||
});
|
||||
|
||||
describe('artistBucketKey', () => {
|
||||
it('buckets A–Z names by their uppercased first letter', () => {
|
||||
expect(artistBucketKey('Adele')).toBe('A');
|
||||
expect(artistBucketKey('zz top')).toBe('Z');
|
||||
expect(artistBucketKey('mGla')).toBe('M');
|
||||
});
|
||||
|
||||
it('puts The Beatles under B', () => {
|
||||
expect(artistBucketKey('The Beatles')).toBe('B');
|
||||
expect(artistBucketKey('The Kinks')).toBe('K');
|
||||
});
|
||||
|
||||
it('puts digit-leading names in #', () => {
|
||||
expect(artistBucketKey('2Pac')).toBe('#');
|
||||
expect(artistBucketKey('50 Cent')).toBe('#');
|
||||
expect(artistBucketKey('999')).toBe('#');
|
||||
});
|
||||
|
||||
it('puts accented Latin and non-Latin scripts in Other (not #)', () => {
|
||||
expect(artistBucketKey('Ärzte')).toBe(OTHER_BUCKET);
|
||||
expect(artistBucketKey('Øde')).toBe(OTHER_BUCKET);
|
||||
expect(artistBucketKey('Å-band')).toBe(OTHER_BUCKET);
|
||||
expect(artistBucketKey('이영지')).toBe(OTHER_BUCKET); // Korean
|
||||
expect(artistBucketKey('くるり')).toBe(OTHER_BUCKET); // Japanese
|
||||
expect(artistBucketKey('Кино')).toBe(OTHER_BUCKET); // Cyrillic
|
||||
expect(artistBucketKey('王菲')).toBe(OTHER_BUCKET); // Chinese
|
||||
});
|
||||
|
||||
it('puts symbol-leading and empty names in Other', () => {
|
||||
expect(artistBucketKey('!!!')).toBe(OTHER_BUCKET);
|
||||
expect(artistBucketKey(' ')).toBe(OTHER_BUCKET);
|
||||
expect(artistBucketKey('')).toBe(OTHER_BUCKET);
|
||||
});
|
||||
|
||||
it('ignores leading whitespace', () => {
|
||||
expect(artistBucketKey(' Beatles')).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareBuckets', () => {
|
||||
it('orders # first, then A–Z, then Other last', () => {
|
||||
const shuffled = ['OTHER', 'M', '#', 'A', 'Z'];
|
||||
expect([...shuffled].sort(compareBuckets)).toEqual(['#', 'A', 'M', 'Z', 'OTHER']);
|
||||
});
|
||||
|
||||
it('ALPHABET ends with the Other bucket', () => {
|
||||
expect(ALPHABET[ALPHABET.length - 1]).toBe(OTHER_BUCKET);
|
||||
expect(ALPHABET).toContain('#');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
|
||||
export const ALL_SENTINEL = 'ALL';
|
||||
/** Catch-all bucket for names that start with neither an A–Z letter nor a digit
|
||||
* (accented Latin like Æ/Ø/Å, and non-Latin scripts: CJK, Cyrillic, …). */
|
||||
export const OTHER_BUCKET = 'OTHER';
|
||||
export const ALPHABET = [ALL_SENTINEL, '#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), OTHER_BUCKET];
|
||||
|
||||
/** Navidrome default (`IgnoredArticles` when the server omits the field). */
|
||||
export const DEFAULT_IGNORED_ARTICLES = 'The El La Los Las Le Les Os As O A';
|
||||
|
||||
/** Stable ordering index for a bucket key — '#' first, A–Z, then 'Other' last. */
|
||||
const BUCKET_ORDER = new Map(ALPHABET.map((l, i) => [l, i]));
|
||||
|
||||
/** Strip leading articles for sort/bucket keys (Navidrome `RemoveArticle` parity). */
|
||||
export function stripLeadingArticles(
|
||||
name: string,
|
||||
ignoredArticles = DEFAULT_IGNORED_ARTICLES,
|
||||
): string {
|
||||
const trimmed = name.trim();
|
||||
for (const article of ignoredArticles.split(' ').filter(Boolean)) {
|
||||
const prefix = `${article} `;
|
||||
if (
|
||||
trimmed.length >= prefix.length
|
||||
&& trimmed.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase()
|
||||
) {
|
||||
return trimmed.slice(prefix.length).trimStart();
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Sort key from display name — article strip + lowercase (Navidrome parity). */
|
||||
export function sortKeyFromDisplayName(
|
||||
displayName: string,
|
||||
ignoredArticles?: string | null,
|
||||
): string {
|
||||
const articles = ignoredArticles?.trim() || DEFAULT_IGNORED_ARTICLES;
|
||||
return stripLeadingArticles(displayName, articles).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket an artist name into the alphabet index (after article stripping):
|
||||
* - `#` → starts with a digit (0–9)
|
||||
* - `A`–`Z` → starts with an ASCII letter on the sort key
|
||||
* - `OTHER` → anything else (accents, CJK, Cyrillic, symbols, empty)
|
||||
*
|
||||
* Buckets always derive from the display `name` + `ignoredArticles`, never the
|
||||
* persisted `nameSort` (which can lag a renamed artist until the next reconcile).
|
||||
*/
|
||||
export function artistBucketKey(
|
||||
name: string,
|
||||
ignoredArticles?: string | null,
|
||||
): string {
|
||||
const sortKey = sortKeyFromDisplayName(name, ignoredArticles);
|
||||
const first = sortKey?.[0];
|
||||
if (!first) return OTHER_BUCKET;
|
||||
if (/^[0-9]$/.test(first)) return '#';
|
||||
const up = first.toUpperCase();
|
||||
return /^[A-Z]$/.test(up) ? up : OTHER_BUCKET;
|
||||
}
|
||||
|
||||
/** Letter bucket for a browse row — uses the server's `ignoredArticles` when known. */
|
||||
export function artistLetterBucket(
|
||||
artist: SubsonicArtist,
|
||||
ignoredArticles?: string | null,
|
||||
): string {
|
||||
return artistBucketKey(artist.name, ignoredArticles);
|
||||
}
|
||||
|
||||
/** Sort comparator for bucket keys following ALPHABET order (unknown keys last). */
|
||||
export function compareBuckets(a: string, b: string): number {
|
||||
return (BUCKET_ORDER.get(a) ?? 999) - (BUCKET_ORDER.get(b) ?? 999);
|
||||
}
|
||||
|
||||
/** Virtual row height guesses — letter heading vs dense rows vs last row in section (group gap). */
|
||||
export const ARTIST_LIST_LETTER_ROW_EST = 48;
|
||||
export const ARTIST_LIST_ROW_EST = 64;
|
||||
export const ARTIST_LIST_LAST_IN_LETTER_EST = 88;
|
||||
|
||||
export type ArtistListFlatRow =
|
||||
| { kind: 'letter'; letter: string }
|
||||
| { kind: 'artist'; artist: SubsonicArtist; isLastInLetter: boolean };
|
||||
|
||||
// Catppuccin accent colors — one is picked deterministically from the artist name
|
||||
const CTP_COLORS = [
|
||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
||||
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
|
||||
'var(--ctp-blue)', 'var(--ctp-lavender)',
|
||||
];
|
||||
|
||||
export function nameColor(name: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return CTP_COLORS[h % CTP_COLORS.length];
|
||||
}
|
||||
|
||||
export function nameInitial(name: string): string {
|
||||
// \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
|
||||
const letter = name.match(/\p{L}/u)?.[0];
|
||||
if (letter) return letter.toUpperCase();
|
||||
const alnum = name.match(/[0-9]/)?.[0];
|
||||
return alnum ?? '?';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceOpenArtistRefs } from './openArtistRefs';
|
||||
|
||||
describe('coerceOpenArtistRefs', () => {
|
||||
it('returns an empty array for nullish input', () => {
|
||||
expect(coerceOpenArtistRefs(undefined)).toEqual([]);
|
||||
expect(coerceOpenArtistRefs(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes through arrays', () => {
|
||||
const refs = [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }];
|
||||
expect(coerceOpenArtistRefs(refs)).toBe(refs);
|
||||
});
|
||||
|
||||
it('wraps a single ref object from Subsonic JSON', () => {
|
||||
const ref = { id: 'a1', name: 'Solo' };
|
||||
expect(coerceOpenArtistRefs(ref)).toEqual([ref]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||
|
||||
/** Subsonic JSON may return one ref object instead of a one-element array. */
|
||||
export function coerceOpenArtistRefs(
|
||||
refs: SubsonicOpenArtistRef[] | SubsonicOpenArtistRef | undefined | null,
|
||||
): SubsonicOpenArtistRef[] {
|
||||
if (refs == null) return [];
|
||||
if (Array.isArray(refs)) return refs;
|
||||
if (typeof refs === 'object') return [refs];
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { uploadArtistImage } from '../../api/subsonicPlaylists';
|
||||
import { setRating, star, unstar } from '../../api/subsonicStarRating';
|
||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { copyEntityShareLink } from '../share/copyEntityShareLink';
|
||||
import { invalidateCoverArt } from '../imageCache';
|
||||
import { showToast } from '../ui/toast';
|
||||
|
||||
export interface RunArtistEntityRatingDeps {
|
||||
artist: SubsonicArtist | null;
|
||||
id: string | undefined;
|
||||
rating: number;
|
||||
artistEntityRatingSupport: string;
|
||||
activeServerId: string;
|
||||
t: TFunction;
|
||||
setArtistEntityRating: (v: number) => void;
|
||||
setArtist: React.Dispatch<React.SetStateAction<SubsonicArtist | null>>;
|
||||
}
|
||||
|
||||
export async function runArtistEntityRating(deps: RunArtistEntityRatingDeps): Promise<void> {
|
||||
const { artist, id, rating, artistEntityRatingSupport, activeServerId, t, setArtistEntityRating, setArtist } = deps;
|
||||
if (!artist || artist.id !== id) return;
|
||||
const artistId = artist.id;
|
||||
const ratingAtStart = artist.userRating ?? 0;
|
||||
|
||||
setArtistEntityRating(rating);
|
||||
|
||||
if (artistEntityRatingSupport !== 'full') return;
|
||||
|
||||
try {
|
||||
await setRating(artistId, rating);
|
||||
setArtist(a => (a && a.id === artistId ? { ...a, userRating: rating } : a));
|
||||
} catch (err) {
|
||||
setArtistEntityRating(ratingAtStart);
|
||||
useAuthStore.getState().setEntityRatingSupport(activeServerId, 'track_only');
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
|
||||
4500,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistToggleStarDeps {
|
||||
artist: SubsonicArtist | null;
|
||||
isStarred: boolean;
|
||||
setIsStarred: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promise<void> {
|
||||
const { artist, isStarred, setIsStarred } = deps;
|
||||
if (!artist) return;
|
||||
const currentlyStarred = isStarred;
|
||||
setIsStarred(!currentlyStarred);
|
||||
try {
|
||||
const meta = {
|
||||
serverId: artist.serverId,
|
||||
name: artist.name,
|
||||
albumCount: artist.albumCount,
|
||||
};
|
||||
if (currentlyStarred) await unstar(artist.id, 'artist', meta);
|
||||
else await star(artist.id, 'artist', meta);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e);
|
||||
setIsStarred(currentlyStarred);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistShareDeps {
|
||||
artist: SubsonicArtist;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export async function runArtistShare(deps: RunArtistShareDeps): Promise<void> {
|
||||
const { artist, t } = deps;
|
||||
try {
|
||||
const ok = await copyEntityShareLink('artist', artist.id);
|
||||
if (ok) showToast(t('contextMenu.shareCopied'));
|
||||
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
} catch {
|
||||
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunArtistImageUploadDeps {
|
||||
e: React.ChangeEvent<HTMLInputElement>;
|
||||
artist: SubsonicArtist | null;
|
||||
t: TFunction;
|
||||
setUploading: (v: boolean) => void;
|
||||
setCoverRevision: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export async function runArtistImageUpload(deps: RunArtistImageUploadDeps): Promise<void> {
|
||||
const { e, artist, t, setUploading, setCoverRevision } = deps;
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file || !artist) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadArtistImage(artist.id, file);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
await invalidateCoverArt(coverId);
|
||||
// Also invalidate with bare artist.id in case coverArt differs
|
||||
if (artist.coverArt && artist.coverArt !== artist.id) {
|
||||
await invalidateCoverArt(artist.id);
|
||||
}
|
||||
setCoverRevision(r => r + 1);
|
||||
showToast(t('artistDetail.uploadImage'));
|
||||
} catch (err) {
|
||||
showToast(
|
||||
typeof err === 'string' ? err : err instanceof Error ? err.message : t('artistDetail.uploadImageError'),
|
||||
4000,
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import * as offlineMediaResolve from '@/features/offline';
|
||||
import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from './runArtistDetailPlay';
|
||||
|
||||
vi.mock('@/features/offline', () => ({
|
||||
resolveAlbum: vi.fn(),
|
||||
resolveMediaServerId: vi.fn((id?: string | null) => id ?? 'srv-1'),
|
||||
}));
|
||||
|
||||
const resolveAlbumMock = vi.mocked(offlineMediaResolve.resolveAlbum);
|
||||
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: 'al-2', name: 'B', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2001 },
|
||||
{ id: 'al-1', name: 'A', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2000 },
|
||||
];
|
||||
|
||||
describe('fetchArtistDetailTracks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads albums via resolveAlbum when serverId is set', async () => {
|
||||
resolveAlbumMock
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[1],
|
||||
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 2 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[0],
|
||||
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
|
||||
});
|
||||
|
||||
const tracks = await fetchArtistDetailTracks(albums, 'srv-1');
|
||||
expect(tracks.map(t => t.id)).toEqual(['t1', 't2']);
|
||||
expect(resolveAlbumMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns empty when no server scope', async () => {
|
||||
vi.mocked(offlineMediaResolve.resolveMediaServerId).mockReturnValueOnce(null);
|
||||
|
||||
const tracks = await fetchArtistDetailTracks(albums, null);
|
||||
expect(tracks).toEqual([]);
|
||||
expect(resolveAlbumMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runArtistDetailPlayTopSong', () => {
|
||||
const topSongs = [
|
||||
{ id: 'top-1', title: 'Hit', artist: 'A', album: 'Greatest', albumId: 'al-x', duration: 200 },
|
||||
{ id: 'top-2', title: 'Hit 2', artist: 'A', album: 'Greatest 2', albumId: 'al-y', duration: 180 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('plays top tracks when the artist has no albums on the page', async () => {
|
||||
const playTrack = vi.fn();
|
||||
const setPlayAllLoading = vi.fn();
|
||||
|
||||
await runArtistDetailPlayTopSong({
|
||||
topSongs,
|
||||
albums: [],
|
||||
serverId: 'srv-1',
|
||||
startIndex: 1,
|
||||
setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
|
||||
expect(resolveAlbumMock).not.toHaveBeenCalled();
|
||||
expect(playTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'top-2' }),
|
||||
[expect.objectContaining({ id: 'top-2' })],
|
||||
);
|
||||
expect(setPlayAllLoading).toHaveBeenCalledWith(true);
|
||||
expect(setPlayAllLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('continues into album tracks after the top-song block', async () => {
|
||||
resolveAlbumMock.mockResolvedValueOnce({
|
||||
album: albums[1],
|
||||
songs: [{ id: 'top-1', title: 'Dup', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 1 }],
|
||||
});
|
||||
const playTrack = vi.fn();
|
||||
|
||||
await runArtistDetailPlayTopSong({
|
||||
topSongs: [topSongs[0]],
|
||||
albums: [albums[1]],
|
||||
serverId: 'srv-1',
|
||||
startIndex: 0,
|
||||
setPlayAllLoading: vi.fn(),
|
||||
playTrack,
|
||||
});
|
||||
|
||||
expect(playTrack).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'top-1' }),
|
||||
[expect.objectContaining({ id: 'top-1' })],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
|
||||
|
||||
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
|
||||
export async function fetchArtistDetailTracks(
|
||||
albums: SubsonicAlbum[],
|
||||
serverId?: string | null,
|
||||
): Promise<Track[]> {
|
||||
const sid = resolveMediaServerId(serverId ?? albums[0]?.serverId);
|
||||
if (!sid) return [];
|
||||
|
||||
const loaded = await Promise.all(albums.map(a => resolveAlbum(sid, a.id)));
|
||||
const sorted = loaded
|
||||
.filter((r): r is NonNullable<typeof r> => r != null)
|
||||
.sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
|
||||
return sorted.flatMap(r =>
|
||||
[...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RunArtistDetailPlayDeps {
|
||||
albums: SubsonicAlbum[];
|
||||
serverId?: string | null;
|
||||
setPlayAllLoading: (v: boolean) => void;
|
||||
playTrack: (track: Track, queue: Track[]) => void;
|
||||
}
|
||||
|
||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkPlayAll({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailPlayTopSongDeps {
|
||||
topSongs: SubsonicSong[];
|
||||
albums: SubsonicAlbum[];
|
||||
serverId?: string | null;
|
||||
startIndex: number;
|
||||
setPlayAllLoading: (v: boolean) => void;
|
||||
playTrack: (track: Track, queue: Track[]) => void;
|
||||
}
|
||||
|
||||
/** Play from a top-track row, then continue with the rest of the artist catalog when available. */
|
||||
export async function runArtistDetailPlayTopSong(deps: RunArtistDetailPlayTopSongDeps): Promise<void> {
|
||||
const { topSongs, albums, serverId, startIndex, setPlayAllLoading, playTrack } = deps;
|
||||
if (topSongs.length === 0 || startIndex < 0 || startIndex >= topSongs.length) return;
|
||||
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
||||
const topSongIds = new Set(topSongs.map(s => s.id));
|
||||
|
||||
let remainingTracks: Track[] = [];
|
||||
if (albums.length > 0) {
|
||||
const allTracks = await fetchArtistDetailTracks(albums, serverId);
|
||||
remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id));
|
||||
}
|
||||
|
||||
const queue = [...topTracksFromIndex, ...remainingTracks];
|
||||
if (queue.length > 0) playTrack(queue[0], queue);
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkShuffle({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
playTrack,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailStartRadioDeps {
|
||||
artist: SubsonicArtist;
|
||||
t: TFunction;
|
||||
setRadioLoading: (v: boolean) => void;
|
||||
playTrack: (track: Track, queue: Track[]) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
}
|
||||
|
||||
export async function runArtistDetailStartRadio(deps: RunArtistDetailStartRadioDeps): Promise<void> {
|
||||
const { artist, t, setRadioLoading, playTrack, enqueue } = deps;
|
||||
setRadioLoading(true);
|
||||
try {
|
||||
// Fire both fetches in parallel
|
||||
const topPromise = getTopSongs(artist.name);
|
||||
const similarPromise = getSimilarSongs2(artist.id, 50);
|
||||
|
||||
// Start playing as soon as top songs arrive
|
||||
const top = await topPromise;
|
||||
if (top.length > 0) {
|
||||
const firstTrack = songToTrack(top[0]);
|
||||
playTrack(firstTrack, [firstTrack]);
|
||||
setRadioLoading(false);
|
||||
// Enqueue remaining tracks when similar songs arrive
|
||||
const similar = await similarPromise;
|
||||
const remaining = [...top.slice(1), ...similar].map(songToTrack);
|
||||
if (remaining.length > 0) enqueue(remaining);
|
||||
} else {
|
||||
// No top songs — fall back to similar
|
||||
const similar = await similarPromise;
|
||||
if (similar.length > 0) {
|
||||
const tracks = similar.map(songToTrack);
|
||||
playTrack(tracks[0], tracks);
|
||||
} else {
|
||||
alert(t('artistDetail.noRadio'));
|
||||
}
|
||||
setRadioLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Radio start failed', e);
|
||||
setRadioLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { sortArtistAlbumsByYear } from './sortArtistAlbums';
|
||||
|
||||
const album = (id: string, name: string, year?: number): SubsonicAlbum => ({
|
||||
id,
|
||||
name,
|
||||
artist: 'A',
|
||||
artistId: 'a',
|
||||
songCount: 1,
|
||||
duration: 1,
|
||||
year,
|
||||
});
|
||||
|
||||
describe('sortArtistAlbumsByYear', () => {
|
||||
const albums = [
|
||||
album('3', 'Gamma', 2000),
|
||||
album('1', 'Alpha', 1990),
|
||||
album('2', 'Beta', 2000),
|
||||
];
|
||||
|
||||
it('sorts by year descending then name', () => {
|
||||
expect(sortArtistAlbumsByYear(albums, 'yearDesc').map(a => a.id)).toEqual(['2', '3', '1']);
|
||||
});
|
||||
|
||||
it('sorts by year ascending then name', () => {
|
||||
expect(sortArtistAlbumsByYear(albums, 'yearAsc').map(a => a.id)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
|
||||
export type ArtistAlbumYearOrder = 'yearDesc' | 'yearAsc';
|
||||
|
||||
export function sortArtistAlbumsByYear(
|
||||
albums: SubsonicAlbum[],
|
||||
order: ArtistAlbumYearOrder,
|
||||
): SubsonicAlbum[] {
|
||||
const out = [...albums];
|
||||
out.sort((a, b) => {
|
||||
const ay = a.year ?? 0;
|
||||
const by = b.year ?? 0;
|
||||
if (ay !== by) return order === 'yearDesc' ? by - ay : ay - by;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user