mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
fc34a0ec59
* feat(offline): local-bytes browse for artists and albums Make Artists, All Albums, and artist/album detail pages work offline from the library index limited to on-disk library and favorite-auto tracks. Add a DEV header toggle to simulate offline browse for testing. * feat(offline): reactive DEV offline toggle with full disconnect simulation Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI refreshes on toggle. DEV force-offline now blocks server probes, reports disconnected status, and gates Subsonic like real offline for player parity. * feat(offline): bytes-first favorites when offline browse is active Load Favorites from local playback bytes, filter starred tracks client-side, and restrict album-level star queries to local album ids. Drop interim perf attempts (lean SQL, progressive load, connection singleton, prefetch UX). * feat(offline): tracks, help, player stats; suspend library picker offline - Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics - Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches - Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected) - Unified isOfflineSidebarNavAllowed for library + system entries * feat(offline): fork disconnect navigation by offline browse capability When the server drops: stay on the page if nothing is browsable offline; reload in place on offline-capable routes; otherwise redirect to All Albums instead of the old /offline or /favorites bounce. * feat(offline): browse cached playlists when the server is down List and open manually pinned regular playlists from local library-tier bytes offline, with sidebar/nav routing and read-only playlist UI. * feat(offline): read-only artist detail and local play-all paths Hide favorites and discography offline actions when browse is offline; load Play All, Shuffle, and top-track continuation from local album bytes. * feat(offline): read-only album detail and enqueue from local bytes Hide favorites, download, and cache-offline actions on album pages when offline browse is active. Favorites album cards enqueue via the same resolveAlbumForServer path as play, including local playback bytes. * chore: remove unused import in AlbumCard after enqueue refactor * feat(offline): unify browse integration contract across the app Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy; wire shell nav to a single capability source; migrate play/enqueue and context-menu paths off raw getAlbum; replace readOnly with action policy on detail surfaces. Tests updated for the media-resolve facade. * feat(offline): close browse contract gaps and fix offline Home feed Split offline browse modules, align favorites capability across servers, wire action policy on context menus, migrate hooks to useOfflineBrowseContext, and preserve stale Home feed cache when offline so the UI does not empty. * fix(offline): block playbar stars, close audit gaps, trim dead exports Hide star rating and favorite in PlayerBar when offline browse is active via offlineActionPolicy playerBar surface. Wire stay-reload token into browse hooks, migrate hooks to context.active, guard rating prefetch network calls, and route playlist load through resolvePlaylist. * docs: add CHANGELOG and credits for offline browse PR #1017 * fix(offline): stop DEV connection probe regression in tests React to devForceOffline transitions only in useConnectionStatus so mount does not double-fire check() or ignore disableBackgroundPolling. Add pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
483 lines
20 KiB
TypeScript
483 lines
20 KiB
TypeScript
import { uploadArtistImage } from '../api/subsonicPlaylists';
|
|
import { useCoverArt } from '../cover/useCoverArt';
|
|
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
|
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
|
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
|
import { songToTrack } from '../utils/playback/songToTrack';
|
|
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import AlbumCard from '../components/AlbumCard';
|
|
import { ArrowLeft, Users, ExternalLink, Heart, Play, Square, Shuffle, Radio, HardDriveDownload, Check, Camera, Loader2, ChevronDown, ChevronRight, ChevronUp, Share2, AudioLines, ArrowDownUp } from 'lucide-react';
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
|
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
|
import { open } from '@tauri-apps/plugin-shell';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import { usePreviewStore } from '../store/previewStore';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { lastfmIsConfigured } from '../api/lastfm';
|
|
import LastfmIcon from '../components/LastfmIcon';
|
|
import { invalidateCoverArt } from '../utils/imageCache';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import { copyEntityShareLink } from '../utils/share/copyEntityShareLink';
|
|
import StarRating from '../components/StarRating';
|
|
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 {
|
|
fetchArtistDetailTracks,
|
|
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
|
} from '../utils/componentHelpers/runArtistDetailPlay';
|
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
|
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
|
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 '../components/nowPlaying/ArtistCard';
|
|
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 navigate = useNavigate();
|
|
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 isMobile = useIsMobile();
|
|
const [coverRevision, setCoverRevision] = useState(0);
|
|
/** True after header cover onError — avoid `display:none` on the img (breaks recovery). */
|
|
const [headerCoverFailed, setHeaderCoverFailed] = useState(false);
|
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const playTrack = usePlayerStore(state => state.playTrack);
|
|
const enqueue = usePlayerStore(state => state.enqueue);
|
|
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
|
|
const clearQueue = usePlayerStore(state => state.clearQueue);
|
|
const openContextMenu = usePlayerStore(state => state.openContextMenu);
|
|
const currentTrack = usePlayerStore(state => state.currentTrack);
|
|
const isPlaying = usePlayerStore(state => state.isPlaying);
|
|
const previewingId = usePreviewStore(s => s.previewingId);
|
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
|
const authActiveServerId = useAuthStore(s => s.activeServerId);
|
|
const activeServerId = readDetailServerId(searchParams, authActiveServerId) ?? '';
|
|
const audiomuseNavidromeEnabled = useAuthStore(
|
|
s => !!(activeServerId && s.audiomuseNavidromeByServer[activeServerId]),
|
|
);
|
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
|
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 setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
|
|
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
|
const offlineCtx = useOfflineBrowseContext();
|
|
const artistActionPolicy = offlineActionPolicy('artistDetail', offlineCtx.active);
|
|
|
|
const [artistEntityRating, setArtistEntityRating] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
|
|
}, [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 = async (startIndex: number) => {
|
|
if (!artist || albums.length === 0) return;
|
|
setPlayAllLoading(true);
|
|
try {
|
|
// Get all artist tracks ordered by album and track number
|
|
const allTracks = await fetchArtistDetailTracks(albums, activeServerId);
|
|
|
|
// Top songs from clicked index onward
|
|
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
|
|
|
|
// Track IDs for deduplication
|
|
const topSongIds = new Set(topSongs.map(s => s.id));
|
|
|
|
// Filter remaining tracks to exclude top songs (prevent duplicates)
|
|
const remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id));
|
|
|
|
// Build queue: remaining top songs + rest of artist catalog
|
|
const queue = [...topTracksFromIndex, ...remainingTracks];
|
|
|
|
if (queue.length > 0) {
|
|
playTrack(queue[0], queue);
|
|
}
|
|
} finally {
|
|
setPlayAllLoading(false);
|
|
}
|
|
};
|
|
|
|
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(() => {
|
|
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 wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
|
|
|
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
|
|
id: sa.id,
|
|
name: sa.name,
|
|
albumCount: sa.albumCount,
|
|
}));
|
|
const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0;
|
|
const showLastfmSimilar =
|
|
lastfmIsConfigured() &&
|
|
(!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) &&
|
|
(similarLoading || similarArtists.length > 0);
|
|
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
|
|
|
|
// ── 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}
|
|
showLastfmSimilar={showLastfmSimilar}
|
|
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>
|
|
);
|
|
}
|