mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
test(offline): mock useOfflineBrowseReloadToken submodule in useSongBrowseList
The offline move collapsed a single-module mock into a `@/features/offline` barrel mock, which shadowed the real `useOfflineBrowseContext` the test relied on. Mock the reload-token submodule directly so the barrel re-exports the stub while the sibling context hook stays live.
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
Reference in New Issue
Block a user