From 3cc172723d51509a0a9477ce7a7e3c7efbbb552c Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Thu, 14 May 2026 21:56:39 +0300 Subject: [PATCH] fix(ui): split album and track artists (OpenSubsonic) (#696) * fix(ui): split OpenSubsonic album and track artists in header and player Album detail header uses albumArtists from album or child songs; player bar, mobile player, and mini player use structured track artists with per-id links. Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline. Fixes #552 * docs: changelog and credits for OpenSubsonic artist links (PR #696) --- CHANGELOG.md | 7 ++ src/api/subsonicTypes.ts | 2 + src/components/AlbumHeader.tsx | 19 +++-- src/components/MobilePlayerView.tsx | 34 ++++++-- src/components/OpenArtistRefInline.tsx | 81 +++++++++++++++++++ src/components/PlayerBar.tsx | 4 + src/components/miniPlayer/MiniMeta.tsx | 17 +++- src/components/playerBar/PlayerTrackInfo.tsx | 40 ++++++--- src/config/settingsCredits.ts | 1 + src/pages/AlbumDetail.tsx | 3 + src/store/playerStoreTypes.ts | 4 +- src/styles/components/cover-lightbox.css | 5 ++ src/styles/components/mini-player-window.css | 14 ++++ src/styles/layout/player-bar.css | 14 ++++ src/styles/layout/track-metadata.css | 14 ++++ .../album/deriveAlbumHeaderArtistRefs.test.ts | 46 +++++++++++ .../album/deriveAlbumHeaderArtistRefs.ts | 24 ++++++ src/utils/miniPlayerBridge.ts | 5 ++ src/utils/playback/songToTrack.test.ts | 9 +++ src/utils/playback/songToTrack.ts | 1 + 20 files changed, 315 insertions(+), 29 deletions(-) create mode 100644 src/components/OpenArtistRefInline.tsx create mode 100644 src/utils/album/deriveAlbumHeaderArtistRefs.test.ts create mode 100644 src/utils/album/deriveAlbumHeaderArtistRefs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2276f1..6a760c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -384,6 +384,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fixes issue [#606](https://github.com/Psychotoxical/psysonic/issues/606): the small cover in the **player bar** (and other `CachedImage` surfaces) no longer flashes the browser **broken-image** placeholder for a split second when skipping tracks or changing the current queue item. +### Album & player — split OpenSubsonic album credits and performers + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#696](https://github.com/Psychotoxical/psysonic/pull/696)** + +* Album detail **header** shows **multiple album artists** when the server sends OpenSubsonic **`albumArtists`** on the album or on child songs — each name links to its artist page instead of only the first id (issue [#552](https://github.com/Psychotoxical/psysonic/issues/552)). +* **Player bar**, **mobile now playing**, and **mini player** copy **`artists`** through **`songToTrack`** so multi-performer tracks get **per-artist** links like the album tracklist column. + ## [1.45.0] - 2026-05-04 ## Added diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 1a91e6fa..4255530f 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -20,6 +20,8 @@ export interface SubsonicAlbum { isCompilation?: boolean; /** OpenSubsonic: release types from MusicBrainz tags (e.g. "Album", "EP", "Single", "Compilation", "Live"). */ releaseTypes?: string[]; + /** OpenSubsonic: album-level credits (Navidrome may attach on album and/or child songs). */ + albumArtists?: SubsonicOpenArtistRef[]; } /** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */ diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 86ce9a31..49ac4cf3 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -1,5 +1,5 @@ import { buildCoverArtUrl } from '../api/subsonicStreamUrl'; -import type { EntityRatingSupportLevel, SubsonicSong } from '../api/subsonicTypes'; +import type { EntityRatingSupportLevel, SubsonicOpenArtistRef, SubsonicSong } from '../api/subsonicTypes'; import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react'; @@ -15,6 +15,7 @@ import { isAlbumRecentlyAdded } from '../utils/albumRecency'; import { formatLongDuration } from '../utils/format/formatDuration'; import { formatMb } from '../utils/format/formatBytes'; import { sanitizeHtml } from '../utils/sanitizeHtml'; +import { OpenArtistRefInline } from './OpenArtistRefInline'; function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) { const { t } = useTranslation(); @@ -44,6 +45,8 @@ interface AlbumInfo { interface AlbumHeaderProps { info: AlbumInfo; + /** OpenSubsonic album credits (derived from album + songs). */ + headerArtistRefs: SubsonicOpenArtistRef[]; songs: SubsonicSong[]; coverUrl: string; coverKey: string; @@ -71,6 +74,7 @@ interface AlbumHeaderProps { export default function AlbumHeader({ info, + headerArtistRefs, songs, coverUrl, coverKey, @@ -166,13 +170,12 @@ export default function AlbumHeader({ )}

{info.name}

- + navigate(`/artist/${id}`)} + linkClassName="album-detail-artist-link" + />

{info.year && {info.year}} diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index 78de6554..241c8c85 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useCachedUrl } from './CachedImage'; +import { OpenArtistRefInline } from './OpenArtistRefInline'; import { formatTrackTime } from '../utils/format/formatDuration'; import LyricsPane from './LyricsPane'; import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress'; @@ -326,12 +327,33 @@ export default function MobilePlayerView() {
{currentTrack.title}
-
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,