currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
- style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
- >
- {currentTrack.artist}
+
+ {currentTrack.artists && currentTrack.artists.length > 0 ? (
+ navigate(`/artist/${id}`)}
+ as="none"
+ linkTag="span"
+ linkClassName="mp-artist-link"
+ />
+ ) : (
+ currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
+ onKeyDown={e => {
+ if (!currentTrack.artistId) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ navigate(`/artist/${currentTrack.artistId}`);
+ }
+ }}
+ style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
+ >
+ {currentTrack.artist}
+
+ )}
{(() => {
const parts = [
diff --git a/src/components/OpenArtistRefInline.tsx b/src/components/OpenArtistRefInline.tsx
new file mode 100644
index 00000000..77022df7
--- /dev/null
+++ b/src/components/OpenArtistRefInline.tsx
@@ -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) => (
+
+ {i > 0 && · }
+ {a.id ? (
+ linkTag === 'span' ? (
+ {
+ e.stopPropagation();
+ onGoArtist(a.id!);
+ }}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ e.stopPropagation();
+ onGoArtist(a.id!);
+ }
+ }}
+ >
+ {a.name ?? fallbackName}
+
+ ) : (
+
+ )
+ ) : (
+ {a.name ?? fallbackName}
+ )}
+
+ ))}
+ >
+ );
+ if (as === 'none') return inner;
+ return
{inner};
+}
diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx
index f9d51d65..ab9a6999 100644
--- a/src/components/PlayerBar.tsx
+++ b/src/components/PlayerBar.tsx
@@ -152,6 +152,9 @@ export default function PlayerBar() {
const displayCoverArt = showPreviewMeta ? previewingTrack!.coverArt : currentTrack?.coverArt;
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
+ const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0
+ ? currentTrack.artists
+ : undefined;
const coverSrc = useMemo(() => displayCoverArt ? buildCoverArtUrl(displayCoverArt, 128) : '', [displayCoverArt]);
const coverKey = displayCoverArt ? coverArtCacheKey(displayCoverArt, 128) : '';
@@ -206,6 +209,7 @@ export default function PlayerBar() {
displayCoverArt={displayCoverArt}
displayTitle={displayTitle}
displayArtist={displayArtist}
+ displayArtistRefs={displayArtistRefs}
showPreviewMeta={showPreviewMeta}
previewingTrack={previewingTrack}
isStarred={isStarred}
diff --git a/src/components/miniPlayer/MiniMeta.tsx b/src/components/miniPlayer/MiniMeta.tsx
index 3e9701d3..0435e33b 100644
--- a/src/components/miniPlayer/MiniMeta.tsx
+++ b/src/components/miniPlayer/MiniMeta.tsx
@@ -1,4 +1,6 @@
+import { emit } from '@tauri-apps/api/event';
import CachedImage from '../CachedImage';
+import { OpenArtistRefInline } from '../OpenArtistRefInline';
import type { MiniTrackInfo } from '../../utils/miniPlayerBridge';
interface Props {
@@ -26,9 +28,20 @@ export function MiniMeta({ track, miniCoverSrc, miniCoverKey }: Props) {
{track?.title ?? '—'}
- {track?.artist && (
+ {track?.artists && track.artists.length > 0 ? (
+
a.name).filter(Boolean).join(' · ')}>
+ { void emit('mini:navigate', { to: `/artist/${id}` }); }}
+ as="none"
+ linkTag="span"
+ linkClassName="mini-player__artist-link"
+ />
+
+ ) : track?.artist ? (
{track.artist}
- )}
+ ) : null}
{track?.album && (
{track.album}
)}
diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx
index 9891ac92..de2deee8 100644
--- a/src/components/playerBar/PlayerTrackInfo.tsx
+++ b/src/components/playerBar/PlayerTrackInfo.tsx
@@ -1,13 +1,14 @@
import { Cast, Heart, Maximize2, Music } from 'lucide-react';
import type { TFunction } from 'i18next';
import { setRating } from '../../api/subsonicStarRating';
-import type { InternetRadioStation, SubsonicAlbum } from '../../api/subsonicTypes';
+import type { InternetRadioStation, SubsonicAlbum, SubsonicOpenArtistRef } from '../../api/subsonicTypes';
import type { PlayerState, Track } from '../../store/playerStoreTypes';
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
import type { PreviewingTrack } from '../../store/previewStore';
import CachedImage from '../CachedImage';
import LastfmIcon from '../LastfmIcon';
import MarqueeText from '../MarqueeText';
+import { OpenArtistRefInline } from '../OpenArtistRefInline';
import StarRating from '../StarRating';
interface Props {
@@ -22,6 +23,8 @@ interface Props {
displayCoverArt: string | undefined;
displayTitle: string;
displayArtist: string;
+ /** When set (OpenSubsonic `artists` on the playing track), render split links like album track rows. */
+ displayArtistRefs?: SubsonicOpenArtistRef[];
showPreviewMeta: boolean;
previewingTrack: PreviewingTrack | null;
isStarred: boolean;
@@ -39,7 +42,7 @@ interface Props {
export function PlayerTrackInfo({
currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey,
- coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist,
+ coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
showPreviewMeta, previewingTrack, isStarred, toggleStar,
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
@@ -116,16 +119,29 @@ export function PlayerTrackInfo({
}
: undefined}
/>
-
!isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
- />
+ {!isRadio && displayArtistRefs && displayArtistRefs.length > 0 ? (
+
+ navigate(`/artist/${id}`)}
+ as="none"
+ linkTag="span"
+ linkClassName="player-artist-link"
+ />
+
+ ) : (
+ !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
+ />
+ )}
{currentTrack && !isRadio && !showPreviewMeta && (
{
if (!album) return {t('albumDetail.notFound')}
;
const { album: info, songs } = album;
+ const headerArtistRefs = deriveAlbumHeaderArtistRefs(info, songs);
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
return (
({
+ id: 'al-1',
+ name: 'Test Album',
+ artist: 'Joined A / B',
+ artistId: 'ar-first',
+ songCount: 2,
+ duration: 100,
+});
+
+describe('deriveAlbumHeaderArtistRefs', () => {
+ it('prefers album-level albumArtists when present', () => {
+ const album: SubsonicAlbum = {
+ ...baseAlbum(),
+ albumArtists: [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }],
+ };
+ expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual(album.albumArtists);
+ });
+
+ it('falls back to the first song with albumArtists', () => {
+ const album = baseAlbum();
+ const songs = [
+ makeSubsonicSong({
+ albumId: album.id,
+ album: album.name,
+ albumArtists: [{ id: 'b1', name: 'Beta' }, { name: 'Gamma' }],
+ }),
+ ];
+ expect(deriveAlbumHeaderArtistRefs(album, songs)).toEqual(songs[0].albumArtists);
+ });
+
+ it('uses legacy artist + artistId when no structured refs', () => {
+ const album = baseAlbum();
+ const songs = [makeSubsonicSong({ albumId: album.id, album: album.name })];
+ expect(deriveAlbumHeaderArtistRefs(album, songs)).toEqual([{ id: 'ar-first', name: 'Joined A / B' }]);
+ });
+
+ it('omits id when artistId is blank', () => {
+ const album: SubsonicAlbum = { ...baseAlbum(), artistId: ' ', artist: 'Solo' };
+ expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual([{ name: 'Solo' }]);
+ });
+});
diff --git a/src/utils/album/deriveAlbumHeaderArtistRefs.ts b/src/utils/album/deriveAlbumHeaderArtistRefs.ts
new file mode 100644
index 00000000..9543a056
--- /dev/null
+++ b/src/utils/album/deriveAlbumHeaderArtistRefs.ts
@@ -0,0 +1,24 @@
+import type { SubsonicAlbum, SubsonicOpenArtistRef, SubsonicSong } from '../../api/subsonicTypes';
+
+function nonEmpty(refs: SubsonicOpenArtistRef[] | undefined): refs is SubsonicOpenArtistRef[] {
+ return !!refs && refs.length > 0;
+}
+
+/**
+ * OpenSubsonic album credits for the album-detail header.
+ * Prefer `albumArtists` on the album payload, then on any child song (Navidrome
+ * often attaches the structured list only on songs); fall back to legacy
+ * `artist` + `artistId` strings.
+ */
+export function deriveAlbumHeaderArtistRefs(
+ album: SubsonicAlbum,
+ songs: SubsonicSong[],
+): SubsonicOpenArtistRef[] {
+ if (nonEmpty(album.albumArtists)) return album.albumArtists;
+ for (const s of songs) {
+ if (nonEmpty(s.albumArtists)) return s.albumArtists;
+ }
+ const name = album.artist?.trim() || '—';
+ const id = album.artistId?.trim();
+ return id ? [{ id, name }] : [{ name }];
+}
diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts
index 0c53e3c3..018087c2 100644
--- a/src/utils/miniPlayerBridge.ts
+++ b/src/utils/miniPlayerBridge.ts
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
+import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
export const MINI_WINDOW_LABEL = 'mini';
@@ -9,6 +10,8 @@ export interface MiniTrackInfo {
id: string;
title: string;
artist: string;
+ /** OpenSubsonic performer refs when the main queue carried them. */
+ artists?: SubsonicOpenArtistRef[];
album: string;
albumId?: string;
artistId?: string;
@@ -41,6 +44,7 @@ function toMini(t: any): MiniTrackInfo {
id: t.id,
title: t.title,
artist: t.artist,
+ artists: Array.isArray(t.artists) && t.artists.length > 0 ? t.artists : undefined,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
@@ -87,6 +91,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
payload.track?.id ?? '',
payload.isPlaying,
payload.track?.starred ?? '',
+ (payload.track?.artists ?? []).map((a: SubsonicOpenArtistRef) => a.id ?? a.name).join('|'),
payload.queueIndex,
payload.volume,
payload.gaplessEnabled,
diff --git a/src/utils/playback/songToTrack.test.ts b/src/utils/playback/songToTrack.test.ts
index 612b9af6..705df1dc 100644
--- a/src/utils/playback/songToTrack.test.ts
+++ b/src/utils/playback/songToTrack.test.ts
@@ -85,6 +85,14 @@ describe('songToTrack', () => {
expect(t.replayGainPeak).toBeUndefined();
});
+ it('copies OpenSubsonic artists when present', () => {
+ const song = makeSubsonicSong({
+ artists: [{ id: 'a1', name: 'Feat' }, { id: 'a2', name: 'Main' }],
+ });
+ const t = songToTrack(song);
+ expect(t.artists).toEqual(song.artists);
+ });
+
it('does not invent fields that the Subsonic song lacks', () => {
const song = makeSubsonicSong({});
const t = songToTrack(song);
@@ -92,5 +100,6 @@ describe('songToTrack', () => {
expect(t.autoAdded).toBeUndefined();
expect(t.radioAdded).toBeUndefined();
expect(t.playNextAdded).toBeUndefined();
+ expect(t.artists).toBeUndefined();
});
});
diff --git a/src/utils/playback/songToTrack.ts b/src/utils/playback/songToTrack.ts
index 7b1a659f..7fdf4b4f 100644
--- a/src/utils/playback/songToTrack.ts
+++ b/src/utils/playback/songToTrack.ts
@@ -8,6 +8,7 @@ export function songToTrack(song: SubsonicSong): Track {
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
+ artists: song.artists && song.artists.length > 0 ? song.artists : undefined,
duration: song.duration,
coverArt: song.coverArt,
track: song.track,