mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(now-playing): split artist links and About the Artist tabs (#960)
* fix(now-playing): split artist links and About the Artist tabs OpenSubsonic artists[] now drives per-artist navigation on Now Playing hero and the queue current-track row (matching player bar). About the Artist loads bio for each performer via tabs when a track has multiple artist ids; queue Info uses the primary ref for bio/tour fetch. Reported by zunoz on the Psysonic Discord (v1.47.0-rc.3). * docs: note Now Playing multi-artist fix in CHANGELOG (PR #960)
This commit is contained in:
@@ -11,6 +11,7 @@ import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
|
||||
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
|
||||
import CachedImage from './CachedImage';
|
||||
import OverlayScrollArea from './OverlayScrollArea';
|
||||
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
|
||||
|
||||
const TOUR_LIMIT = 5;
|
||||
const BIO_CLAMP_LINES = 4;
|
||||
@@ -88,8 +89,9 @@ export default function NowPlayingInfo() {
|
||||
const subsonicServerId = usePlaybackServerId();
|
||||
const subsonicReady = Boolean(subsonicServerId);
|
||||
|
||||
const artistName = currentTrack?.artist || '';
|
||||
const artistId = currentTrack?.artistId || '';
|
||||
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
|
||||
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
|
||||
const artistId = primaryArtist?.id ?? '';
|
||||
const songId = currentTrack?.id || '';
|
||||
|
||||
// Tuple { id, info } gates rendering on "info matches the current artistId" so
|
||||
@@ -175,8 +177,8 @@ export default function NowPlayingInfo() {
|
||||
}, [bioClean]);
|
||||
|
||||
const contributorRows = useMemo(
|
||||
() => buildContributorRows(matchedSongDetail, artistName),
|
||||
[matchedSongDetail, artistName],
|
||||
() => buildContributorRows(matchedSongDetail, currentTrack?.artist ?? ''),
|
||||
[matchedSongDetail, currentTrack?.artist],
|
||||
);
|
||||
|
||||
if (!currentTrack) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* /artist/:id and the previously-divergent rendering).
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import ArtistCard from './ArtistCard';
|
||||
import type { SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||
@@ -47,6 +48,28 @@ describe('ArtistCard — hideArtistName / hideSimilar', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArtistCard — artistTabs', () => {
|
||||
it('renders tabs and switches bio when a second artist tab is selected', () => {
|
||||
const { container, getByRole } = renderWithProviders(
|
||||
<ArtistCard
|
||||
artistName="A"
|
||||
artistId="a1"
|
||||
artistInfo={infoWithImage}
|
||||
artistTabs={[
|
||||
{ id: 'a1', name: 'Dan Balan', artistInfo: { biography: 'Bio A' } as SubsonicArtistInfo },
|
||||
{ id: 'a2', name: 'Katerina Begu', artistInfo: { biography: 'Bio B' } as SubsonicArtistInfo },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('.np-artist-tab')).toHaveLength(2);
|
||||
expect(container.querySelector('.np-bio-text')?.textContent).toContain('Bio A');
|
||||
|
||||
fireEvent.click(getByRole('tab', { name: 'Katerina Begu' }));
|
||||
expect(container.querySelector('.np-bio-text')?.textContent).toContain('Bio B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArtistCard — coverFallback', () => {
|
||||
it('uses coverFallback src + cacheKey when artistInfo has no hero image', () => {
|
||||
const noImageInfo = { biography: 'b', similarArtist: [] } as unknown as SubsonicArtistInfo;
|
||||
@@ -60,8 +83,6 @@ describe('ArtistCard — coverFallback', () => {
|
||||
);
|
||||
const img = container.querySelector<HTMLImageElement>('img.np-dash-artist-image');
|
||||
expect(img).not.toBeNull();
|
||||
// CachedImage swaps src to a blob URL asynchronously; the initial render
|
||||
// shows the fetch URL — assert the *configured* fallback src is in play.
|
||||
expect(img!.getAttribute('src') || '').toContain('fallback.test');
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,18 @@ import type { SubsonicArtistInfo } from '../../api/subsonicTypes';
|
||||
import { isRealArtistImage, sanitizeHtml } from '../../utils/componentHelpers/nowPlayingHelpers';
|
||||
import CachedImage from '../CachedImage';
|
||||
|
||||
export interface ArtistCardTab {
|
||||
id?: string;
|
||||
name: string;
|
||||
artistInfo: SubsonicArtistInfo | null;
|
||||
}
|
||||
|
||||
interface ArtistCardProps {
|
||||
artistName: string;
|
||||
artistId?: string;
|
||||
artistInfo: SubsonicArtistInfo | null;
|
||||
/** When more than one entry, render picker tabs (Now Playing multi-artist tracks). */
|
||||
artistTabs?: ArtistCardTab[];
|
||||
/** When omitted the "Go to Artist" link and similar-artist chip click handlers do nothing — used on /artist/:id where the user is already there. */
|
||||
onNavigate?: (path: string) => void;
|
||||
/** Render fallback cover when artistInfo has no hero image (ArtistDetail's coverArt fallback). */
|
||||
@@ -19,28 +27,13 @@ interface ArtistCardProps {
|
||||
hideSimilar?: boolean;
|
||||
}
|
||||
|
||||
const ArtistCard = memo(function ArtistCard({
|
||||
artistName, artistId, artistInfo, onNavigate, coverFallback,
|
||||
hideArtistName = false, hideSimilar = false,
|
||||
}: ArtistCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
const [bioOverflows, setBioOverflows] = useState(false);
|
||||
const bioRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => { setBioExpanded(false); }, [artistId]);
|
||||
|
||||
const bioHtml = useMemo(() => artistInfo?.biography ? sanitizeHtml(artistInfo.biography) : '', [artistInfo?.biography]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = bioRef.current;
|
||||
if (!el) { setBioOverflows(false); return; }
|
||||
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
|
||||
}, [bioHtml]);
|
||||
|
||||
const similar = hideSimilar ? [] : (artistInfo?.similarArtist ?? []);
|
||||
function heroForEntry(
|
||||
artistId: string | undefined,
|
||||
artistInfo: SubsonicArtistInfo | null,
|
||||
coverFallback?: { src: string; cacheKey: string },
|
||||
): { heroImage: string; heroCacheKey: string } {
|
||||
const rawLarge = artistInfo?.largeImageUrl;
|
||||
const rawMed = artistInfo?.mediumImageUrl;
|
||||
const rawMed = artistInfo?.mediumImageUrl;
|
||||
const heroFromInfo = isRealArtistImage(rawLarge)
|
||||
? rawLarge!
|
||||
: isRealArtistImage(rawMed) ? rawMed! : '';
|
||||
@@ -48,32 +41,104 @@ const ArtistCard = memo(function ArtistCard({
|
||||
const heroCacheKey = heroFromInfo
|
||||
? (artistId ? `artistInfo:${artistId}:hero` : '')
|
||||
: (coverFallback?.cacheKey ?? '');
|
||||
return { heroImage, heroCacheKey };
|
||||
}
|
||||
|
||||
if (!bioHtml && similar.length === 0 && !heroImage) return null;
|
||||
function entryHasContent(
|
||||
entry: ArtistCardTab,
|
||||
hideSimilar: boolean,
|
||||
coverFallback?: { src: string; cacheKey: string },
|
||||
): boolean {
|
||||
const bioHtml = entry.artistInfo?.biography ? sanitizeHtml(entry.artistInfo.biography) : '';
|
||||
const similar = hideSimilar ? [] : (entry.artistInfo?.similarArtist ?? []);
|
||||
const { heroImage } = heroForEntry(entry.id, entry.artistInfo, coverFallback);
|
||||
return Boolean(bioHtml || similar.length > 0 || heroImage);
|
||||
}
|
||||
|
||||
const ArtistCard = memo(function ArtistCard({
|
||||
artistName, artistId, artistInfo, artistTabs, onNavigate, coverFallback,
|
||||
hideArtistName = false, hideSimilar = false,
|
||||
}: ArtistCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [bioExpanded, setBioExpanded] = useState(false);
|
||||
const [bioOverflows, setBioOverflows] = useState(false);
|
||||
const bioRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const tabs = artistTabs && artistTabs.length > 1 ? artistTabs : null;
|
||||
const tabsKey = tabs?.map(tab => tab.id ?? tab.name).join('\x1e') ?? '';
|
||||
const [activeTabIdx, setActiveTabIdx] = useState(0);
|
||||
|
||||
useEffect(() => { setActiveTabIdx(0); }, [tabsKey]);
|
||||
useEffect(() => { setBioExpanded(false); }, [artistId, tabsKey, activeTabIdx]);
|
||||
|
||||
const activeEntry = tabs
|
||||
? tabs[Math.min(activeTabIdx, tabs.length - 1)]
|
||||
: { id: artistId, name: artistName, artistInfo };
|
||||
|
||||
const activeArtistId = activeEntry.id;
|
||||
const activeArtistName = activeEntry.name;
|
||||
const activeArtistInfo = activeEntry.artistInfo;
|
||||
|
||||
const bioHtml = useMemo(
|
||||
() => activeArtistInfo?.biography ? sanitizeHtml(activeArtistInfo.biography) : '',
|
||||
[activeArtistInfo?.biography],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = bioRef.current;
|
||||
if (!el) { setBioOverflows(false); return; }
|
||||
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
|
||||
}, [bioHtml]);
|
||||
|
||||
const similar = hideSimilar ? [] : (activeArtistInfo?.similarArtist ?? []);
|
||||
const { heroImage, heroCacheKey } = heroForEntry(activeArtistId, activeArtistInfo, coverFallback);
|
||||
|
||||
const visible = tabs
|
||||
? tabs.some(tab => entryHasContent(tab, hideSimilar, coverFallback))
|
||||
: entryHasContent({ id: artistId, name: artistName, artistInfo }, hideSimilar, coverFallback);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="np-info-card np-dash-card">
|
||||
<div className="np-card-header">
|
||||
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
|
||||
{artistId && onNavigate && (
|
||||
<button className="np-card-link" onClick={() => onNavigate(`/artist/${artistId}`)}>
|
||||
{activeArtistId && onNavigate && (
|
||||
<button className="np-card-link" onClick={() => onNavigate(`/artist/${activeArtistId}`)}>
|
||||
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tabs && (
|
||||
<div className="np-artist-tab-row" role="tablist" aria-label={t('nowPlaying.aboutArtist')}>
|
||||
{tabs.map((tab, idx) => (
|
||||
<button
|
||||
key={tab.id ?? tab.name}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={idx === activeTabIdx}
|
||||
className={`np-artist-tab${idx === activeTabIdx ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveTabIdx(idx)}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="np-dash-artist-body">
|
||||
{heroImage && heroCacheKey && (
|
||||
<CachedImage
|
||||
src={heroImage}
|
||||
cacheKey={heroCacheKey}
|
||||
alt={artistName}
|
||||
alt={activeArtistName}
|
||||
className="np-dash-artist-image"
|
||||
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div className="np-dash-artist-text">
|
||||
{!hideArtistName && <div className="np-dash-artist-name">{artistName}</div>}
|
||||
{!hideArtistName && !tabs && <div className="np-dash-artist-name">{activeArtistName}</div>}
|
||||
{bioHtml && (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import type { CoverArtRef } from '../../cover/types';
|
||||
import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
|
||||
import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||
|
||||
interface HeroProps {
|
||||
@@ -12,6 +14,8 @@ interface HeroProps {
|
||||
duration: number; suffix?: string; bitRate?: number; samplingRate?: number;
|
||||
bitDepth?: number; artistId?: string; albumId?: string; id: string;
|
||||
userRating?: number; };
|
||||
/** OpenSubsonic `artists` on the playing track — per-artist links in the hero subline. */
|
||||
artistRefs?: SubsonicOpenArtistRef[];
|
||||
genre?: string;
|
||||
playCount?: number;
|
||||
userRatingOverride?: number;
|
||||
@@ -42,7 +46,7 @@ function renderStars(rating?: number) {
|
||||
);
|
||||
}
|
||||
|
||||
const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
|
||||
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
|
||||
const { t } = useTranslation();
|
||||
const rating = userRatingOverride ?? track.userRating;
|
||||
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
|
||||
@@ -67,11 +71,22 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l
|
||||
<div className="np-dash-hero-body">
|
||||
<div className="np-dash-hero-title">{track.title}</div>
|
||||
<div className="np-dash-hero-sub">
|
||||
<span className="np-link"
|
||||
onClick={() => track.artistId && onNavigate(`/artist/${track.artistId}`)}
|
||||
style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
|
||||
{track.artist}
|
||||
</span>
|
||||
{artistRefs && artistRefs.length > 0 ? (
|
||||
<OpenArtistRefInline
|
||||
refs={artistRefs}
|
||||
fallbackName={track.artist}
|
||||
onGoArtist={id => onNavigate(`/artist/${id}`)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
linkClassName="np-link"
|
||||
/>
|
||||
) : (
|
||||
<span className="np-link"
|
||||
onClick={() => track.artistId && onNavigate(`/artist/${track.artistId}`)}
|
||||
style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
|
||||
{track.artist}
|
||||
</span>
|
||||
)}
|
||||
<span className="np-sep">·</span>
|
||||
<span className="np-link"
|
||||
onClick={() => track.albumId && onNavigate(`/album/${track.albumId}`)}
|
||||
|
||||
@@ -15,8 +15,10 @@ import { useQueueTrackEnrichment } from '../../hooks/useQueueTrackEnrichment';
|
||||
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
|
||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
|
||||
|
||||
interface Props {
|
||||
currentTrack: Track;
|
||||
@@ -52,6 +54,7 @@ export function QueueCurrentTrack({
|
||||
}: Props) {
|
||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
||||
const artistRefs = resolveTrackArtistRefs(currentTrack);
|
||||
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
||||
const bpmTech = formatQueueBpmTech(enrichment, t);
|
||||
const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t);
|
||||
@@ -219,10 +222,16 @@ export function QueueCurrentTrack({
|
||||
</div>
|
||||
<div className="queue-current-info">
|
||||
<h3 className="truncate">{currentTrack.title}</h3>
|
||||
<div
|
||||
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
>{currentTrack.artist}</div>
|
||||
<div className="queue-current-sub truncate">
|
||||
<OpenArtistRefInline
|
||||
refs={artistRefs}
|
||||
fallbackName={currentTrack.artist}
|
||||
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
linkClassName="is-link"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
|
||||
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getArtistInfoForServer } from '../api/subsonicArtists';
|
||||
import type { SubsonicArtistInfo, SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||
import { makeCache } from '../utils/cache/nowPlayingCache';
|
||||
|
||||
const artistInfoCache = makeCache<SubsonicArtistInfo | null>();
|
||||
|
||||
function cacheKey(serverId: string, artistId: string): string {
|
||||
return `${serverId}:${artistId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches `getArtistInfo` for each ref with an id. Returns `undefined` for ids
|
||||
* still loading, `null` when fetch finished with no info.
|
||||
*/
|
||||
export function useArtistInfoBatch(
|
||||
serverId: string | undefined,
|
||||
refs: SubsonicOpenArtistRef[],
|
||||
similarArtistCount?: number,
|
||||
): Record<string, SubsonicArtistInfo | null | undefined> {
|
||||
const ids = useMemo(
|
||||
() => [...new Set(refs.map(r => r.id?.trim()).filter((id): id is string => Boolean(id)))],
|
||||
[refs],
|
||||
);
|
||||
const idsKey = ids.join('\x1e');
|
||||
|
||||
const [byId, setById] = useState<Record<string, SubsonicArtistInfo | null | undefined>>(() => {
|
||||
if (!serverId || ids.length === 0) return {};
|
||||
const seed: Record<string, SubsonicArtistInfo | null | undefined> = {};
|
||||
for (const id of ids) {
|
||||
const cached = artistInfoCache.get(cacheKey(serverId, id));
|
||||
if (cached !== undefined) seed[id] = cached;
|
||||
}
|
||||
return seed;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || ids.length === 0) {
|
||||
setById({});
|
||||
return;
|
||||
}
|
||||
|
||||
const next: Record<string, SubsonicArtistInfo | null | undefined> = {};
|
||||
const pending: string[] = [];
|
||||
for (const id of ids) {
|
||||
const cached = artistInfoCache.get(cacheKey(serverId, id));
|
||||
if (cached !== undefined) {
|
||||
next[id] = cached;
|
||||
} else {
|
||||
next[id] = undefined;
|
||||
pending.push(id);
|
||||
}
|
||||
}
|
||||
setById(next);
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
pending.map(async id => {
|
||||
try {
|
||||
const info = await getArtistInfoForServer(serverId, id, {
|
||||
similarArtistCount: similarArtistCount,
|
||||
});
|
||||
artistInfoCache.set(cacheKey(serverId, id), info ?? null);
|
||||
return [id, info ?? null] as const;
|
||||
} catch {
|
||||
artistInfoCache.set(cacheKey(serverId, id), null);
|
||||
return [id, null] as const;
|
||||
}
|
||||
}),
|
||||
).then(results => {
|
||||
if (cancelled) return;
|
||||
setById(prev => {
|
||||
const merged = { ...prev };
|
||||
for (const [id, info] of results) merged[id] = info;
|
||||
return merged;
|
||||
});
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [serverId, idsKey, similarArtistCount]);
|
||||
|
||||
return byId;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { usePlaybackServerId } from './usePlaybackServerId';
|
||||
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
|
||||
|
||||
const NOW_PLAYING_COVER_CSS_PX = 800;
|
||||
|
||||
@@ -52,11 +53,12 @@ export function useNowPlayingPrewarm(): void {
|
||||
useEffect(() => {
|
||||
if (!currentTrack || !playbackServerId) return;
|
||||
|
||||
const primary = primaryTrackArtistRef(currentTrack);
|
||||
void prewarmNowPlayingFetchers({
|
||||
songId: currentTrack.id,
|
||||
artistId: currentTrack.artistId,
|
||||
artistId: primary.id,
|
||||
albumId: currentTrack.albumId,
|
||||
artistName: currentTrack.artist,
|
||||
artistName: primary.name ?? currentTrack.artist,
|
||||
enableBandsintown,
|
||||
audiomuseNavidromeEnabled,
|
||||
lastfmUsername,
|
||||
@@ -81,6 +83,7 @@ export function useNowPlayingPrewarm(): void {
|
||||
}, [
|
||||
currentTrack?.id,
|
||||
currentTrack?.artistId,
|
||||
currentTrack?.artists,
|
||||
currentTrack?.albumId,
|
||||
currentTrack?.coverArt,
|
||||
currentTrack?.artist,
|
||||
|
||||
@@ -40,6 +40,9 @@ import TourCard from '../components/nowPlaying/TourCard';
|
||||
import DiscographyCard from '../components/nowPlaying/DiscographyCard';
|
||||
import { useNowPlayingFetchers } from '../hooks/useNowPlayingFetchers';
|
||||
import { useNowPlayingStarLove } from '../hooks/useNowPlayingStarLove';
|
||||
import { useArtistInfoBatch } from '../hooks/useArtistInfoBatch';
|
||||
import { primaryTrackArtistRef, resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
|
||||
import type { ArtistCardTab } from '../components/nowPlaying/ArtistCard';
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,9 +71,17 @@ export default function NowPlaying() {
|
||||
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||
|
||||
const songId = currentTrack?.id;
|
||||
const artistId = currentTrack?.artistId;
|
||||
const albumId = currentTrack?.albumId;
|
||||
const artistName = currentTrack?.artist ?? '';
|
||||
const trackArtistRefs = useMemo(
|
||||
() => (currentTrack ? resolveTrackArtistRefs(currentTrack) : []),
|
||||
[currentTrack],
|
||||
);
|
||||
const primaryArtist = useMemo(
|
||||
() => (currentTrack ? primaryTrackArtistRef(currentTrack) : null),
|
||||
[currentTrack],
|
||||
);
|
||||
const artistId = primaryArtist?.id;
|
||||
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
|
||||
|
||||
// Entity fetchers (8 cached useEffects + their state)
|
||||
const {
|
||||
@@ -107,8 +118,8 @@ export default function NowPlaying() {
|
||||
const resolvedRadioCover = radioCover.src;
|
||||
|
||||
const contributorRows = useMemo(
|
||||
() => buildContributorRows(songMeta, artistName),
|
||||
[songMeta, artistName],
|
||||
() => buildContributorRows(songMeta, currentTrack?.artist ?? ''),
|
||||
[songMeta, currentTrack?.artist],
|
||||
);
|
||||
|
||||
// Merge Subsonic artistInfo with Last.fm fallback: if Subsonic has no bio,
|
||||
@@ -123,6 +134,28 @@ export default function NowPlaying() {
|
||||
};
|
||||
}, [artistInfo, lfmArtist]);
|
||||
|
||||
const artistInfoById = useArtistInfoBatch(
|
||||
playbackServerId,
|
||||
trackArtistRefs,
|
||||
audiomuseNavidromeEnabled ? 24 : undefined,
|
||||
);
|
||||
|
||||
const artistTabs = useMemo((): ArtistCardTab[] | undefined => {
|
||||
const tabs = trackArtistRefs.filter(r => r.id?.trim());
|
||||
if (tabs.length <= 1) return undefined;
|
||||
return tabs.map((ref, index) => {
|
||||
const id = ref.id!.trim();
|
||||
const info = index === 0
|
||||
? (effectiveArtistInfo ?? artistInfoById[id] ?? null)
|
||||
: (artistInfoById[id] ?? null);
|
||||
return {
|
||||
id,
|
||||
name: ref.name ?? id,
|
||||
artistInfo: info,
|
||||
};
|
||||
});
|
||||
}, [trackArtistRefs, artistInfoById, effectiveArtistInfo]);
|
||||
|
||||
const handleEnableBandsintown = useCallback(() => setEnableBandsintown(true), [setEnableBandsintown]);
|
||||
|
||||
const handlePlayTopSong = useCallback((song: SubsonicSong) => {
|
||||
@@ -237,6 +270,7 @@ export default function NowPlaying() {
|
||||
albumId: currentTrack.albumId,
|
||||
userRating: currentTrack.userRating,
|
||||
}}
|
||||
artistRefs={trackArtistRefs.length > 0 ? trackArtistRefs : undefined}
|
||||
genre={songMeta?.genre ?? undefined}
|
||||
playCount={(songMeta as (SubsonicSong & { playCount?: number }) | null)?.playCount}
|
||||
userRatingOverride={userRatingOverrides[currentTrack.id]}
|
||||
@@ -283,6 +317,7 @@ export default function NowPlaying() {
|
||||
artistName={artistName}
|
||||
artistId={artistId}
|
||||
artistInfo={effectiveArtistInfo}
|
||||
artistTabs={artistTabs}
|
||||
onNavigate={stableNavigate}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -328,6 +328,40 @@
|
||||
}
|
||||
|
||||
/* Artist card */
|
||||
.np-artist-tab-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.np-artist-tab {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.np-artist-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.np-artist-tab.is-active {
|
||||
background: color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.np-dash-artist-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { primaryTrackArtistRef, resolveTrackArtistRefs } from './trackArtistRefs';
|
||||
|
||||
describe('resolveTrackArtistRefs', () => {
|
||||
it('prefers OpenSubsonic artists[] when present', () => {
|
||||
const refs = [{ id: 'a1', name: 'Dan Balan' }, { id: 'a2', name: 'Katerina Begu' }];
|
||||
expect(resolveTrackArtistRefs({
|
||||
artist: 'Dan Balan feat. Katerina Begu',
|
||||
artistId: 'legacy',
|
||||
artists: refs,
|
||||
})).toEqual(refs);
|
||||
});
|
||||
|
||||
it('falls back to legacy artistId + artist', () => {
|
||||
expect(resolveTrackArtistRefs({
|
||||
artist: 'Solo',
|
||||
artistId: 'ar-solo',
|
||||
})).toEqual([{ id: 'ar-solo', name: 'Solo' }]);
|
||||
});
|
||||
|
||||
it('returns name-only ref when no id', () => {
|
||||
expect(resolveTrackArtistRefs({ artist: 'Unknown' })).toEqual([{ name: 'Unknown' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryTrackArtistRef', () => {
|
||||
it('returns the first structured ref', () => {
|
||||
expect(primaryTrackArtistRef({
|
||||
artist: 'A feat. B',
|
||||
artists: [{ id: 'a1', name: 'A' }, { id: 'a2', name: 'B' }],
|
||||
})).toEqual({ id: 'a1', name: 'A' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
|
||||
type TrackArtistFields = Pick<Track, 'artist' | 'artistId' | 'artists'>;
|
||||
|
||||
/** OpenSubsonic `artists` when present; else legacy `artistId` + `artist` (album track rows). */
|
||||
export function resolveTrackArtistRefs(track: TrackArtistFields): SubsonicOpenArtistRef[] {
|
||||
if (track.artists && track.artists.length > 0) {
|
||||
return track.artists;
|
||||
}
|
||||
const id = track.artistId?.trim();
|
||||
if (id) {
|
||||
return [{ id, name: track.artist }];
|
||||
}
|
||||
return [{ name: track.artist }];
|
||||
}
|
||||
|
||||
/** First performer ref — used for artist bio / discography / top songs on Now Playing. */
|
||||
export function primaryTrackArtistRef(track: TrackArtistFields): SubsonicOpenArtistRef {
|
||||
return resolveTrackArtistRefs(track)[0];
|
||||
}
|
||||
Reference in New Issue
Block a user