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:
Psychotoxical
2026-06-30 01:08:35 +02:00
parent 321d5ff6ab
commit 17ea96dcdb
45 changed files with 4 additions and 1 deletions
@@ -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());
});
});
+159
View File
@@ -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 effects 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 effects 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 effects 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],
);
}
+445
View File
@@ -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 effects 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>
);
}
+480
View File
@@ -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 AZ 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 AZ, 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('#');
});
});
+105
View File
@@ -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 AZ 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, AZ, 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 (09)
* - `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;
}