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:
cucadmuh
2026-06-03 22:16:06 +03:00
committed by GitHub
parent a142bb1dab
commit 4c70408bd6
12 changed files with 379 additions and 48 deletions
+6 -4
View File
@@ -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) {
+23 -2
View File
@@ -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');
});
+91 -26
View File
@@ -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
+21 -6
View File
@@ -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}`)}