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}`)}
|
||||
|
||||
Reference in New Issue
Block a user