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,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;
}