{currentTrack.title}
-
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
- >{currentTrack.artist}
+
+ navigate(`/artist/${id}`)}
+ as="none"
+ linkTag="span"
+ linkClassName="is-link"
+ />
+
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
diff --git a/src/hooks/useArtistInfoBatch.ts b/src/hooks/useArtistInfoBatch.ts
new file mode 100644
index 00000000..4bd2aedf
--- /dev/null
+++ b/src/hooks/useArtistInfoBatch.ts
@@ -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();
+
+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 {
+ 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>(() => {
+ if (!serverId || ids.length === 0) return {};
+ const seed: Record = {};
+ 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 = {};
+ 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;
+}
diff --git a/src/hooks/useNowPlayingPrewarm.ts b/src/hooks/useNowPlayingPrewarm.ts
index 9b6203f4..62a0b255 100644
--- a/src/hooks/useNowPlayingPrewarm.ts
+++ b/src/hooks/useNowPlayingPrewarm.ts
@@ -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,
diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx
index e2e97314..44a87bb4 100644
--- a/src/pages/NowPlaying.tsx
+++ b/src/pages/NowPlaying.tsx
@@ -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}
/>
);
diff --git a/src/styles/components/np-dash.css b/src/styles/components/np-dash.css
index b976a193..dc283779 100644
--- a/src/styles/components/np-dash.css
+++ b/src/styles/components/np-dash.css
@@ -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;
diff --git a/src/utils/playback/trackArtistRefs.test.ts b/src/utils/playback/trackArtistRefs.test.ts
new file mode 100644
index 00000000..a973e163
--- /dev/null
+++ b/src/utils/playback/trackArtistRefs.test.ts
@@ -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' });
+ });
+});
diff --git a/src/utils/playback/trackArtistRefs.ts b/src/utils/playback/trackArtistRefs.ts
new file mode 100644
index 00000000..82269003
--- /dev/null
+++ b/src/utils/playback/trackArtistRefs.ts
@@ -0,0 +1,21 @@
+import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
+import type { Track } from '../../store/playerStoreTypes';
+
+type TrackArtistFields = Pick