mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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)
This commit is contained in:
@@ -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.
|
* 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
|
## [1.45.0] - 2026-05-04
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface SubsonicAlbum {
|
|||||||
isCompilation?: boolean;
|
isCompilation?: boolean;
|
||||||
/** OpenSubsonic: release types from MusicBrainz tags (e.g. "Album", "EP", "Single", "Compilation", "Live"). */
|
/** OpenSubsonic: release types from MusicBrainz tags (e.g. "Album", "EP", "Single", "Compilation", "Live"). */
|
||||||
releaseTypes?: string[];
|
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`). */
|
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { buildCoverArtUrl } from '../api/subsonicStreamUrl';
|
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 React, { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
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 { formatLongDuration } from '../utils/format/formatDuration';
|
||||||
import { formatMb } from '../utils/format/formatBytes';
|
import { formatMb } from '../utils/format/formatBytes';
|
||||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||||
|
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||||
|
|
||||||
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -44,6 +45,8 @@ interface AlbumInfo {
|
|||||||
|
|
||||||
interface AlbumHeaderProps {
|
interface AlbumHeaderProps {
|
||||||
info: AlbumInfo;
|
info: AlbumInfo;
|
||||||
|
/** OpenSubsonic album credits (derived from album + songs). */
|
||||||
|
headerArtistRefs: SubsonicOpenArtistRef[];
|
||||||
songs: SubsonicSong[];
|
songs: SubsonicSong[];
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
coverKey: string;
|
coverKey: string;
|
||||||
@@ -71,6 +74,7 @@ interface AlbumHeaderProps {
|
|||||||
|
|
||||||
export default function AlbumHeader({
|
export default function AlbumHeader({
|
||||||
info,
|
info,
|
||||||
|
headerArtistRefs,
|
||||||
songs,
|
songs,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
coverKey,
|
coverKey,
|
||||||
@@ -166,13 +170,12 @@ export default function AlbumHeader({
|
|||||||
)}
|
)}
|
||||||
<h1 className="album-detail-title">{info.name}</h1>
|
<h1 className="album-detail-title">{info.name}</h1>
|
||||||
<p className="album-detail-artist">
|
<p className="album-detail-artist">
|
||||||
<button
|
<OpenArtistRefInline
|
||||||
className="album-detail-artist-link"
|
refs={headerArtistRefs}
|
||||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
fallbackName={info.artist}
|
||||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||||
>
|
linkClassName="album-detail-artist-link"
|
||||||
{info.artist}
|
/>
|
||||||
</button>
|
|
||||||
</p>
|
</p>
|
||||||
<div className="album-detail-info">
|
<div className="album-detail-info">
|
||||||
{info.year && <span>{info.year}</span>}
|
{info.year && <span>{info.year}</span>}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useCachedUrl } from './CachedImage';
|
import { useCachedUrl } from './CachedImage';
|
||||||
|
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||||
import LyricsPane from './LyricsPane';
|
import LyricsPane from './LyricsPane';
|
||||||
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
|
||||||
@@ -326,12 +327,33 @@ export default function MobilePlayerView() {
|
|||||||
<div className="mp-meta">
|
<div className="mp-meta">
|
||||||
<div className="mp-meta-text">
|
<div className="mp-meta-text">
|
||||||
<div className="mp-title truncate">{currentTrack.title}</div>
|
<div className="mp-title truncate">{currentTrack.title}</div>
|
||||||
<div
|
<div className="mp-artist truncate">
|
||||||
className="mp-artist truncate"
|
{currentTrack.artists && currentTrack.artists.length > 0 ? (
|
||||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
<OpenArtistRefInline
|
||||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
refs={currentTrack.artists}
|
||||||
>
|
fallbackName={currentTrack.artist}
|
||||||
{currentTrack.artist}
|
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||||
|
as="none"
|
||||||
|
linkTag="span"
|
||||||
|
linkClassName="mp-artist-link"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
role={currentTrack.artistId ? 'link' : undefined}
|
||||||
|
tabIndex={currentTrack.artistId ? 0 : undefined}
|
||||||
|
onClick={() => 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}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const parts = [
|
const parts = [
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
<Fragment key={a.id ?? `n:${a.name ?? ''}:${i}`}>
|
||||||
|
{i > 0 && <span className={separatorClassName} aria-hidden="true"> · </span>}
|
||||||
|
{a.id ? (
|
||||||
|
linkTag === 'span' ? (
|
||||||
|
<span
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
className={linkClassName}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onGoArtist(a.id!);
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onGoArtist(a.id!);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.name ?? fallbackName}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={linkClassName}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onGoArtist(a.id!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.name ?? fallbackName}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>{a.name ?? fallbackName}</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (as === 'none') return inner;
|
||||||
|
return <span className={outerClassName}>{inner}</span>;
|
||||||
|
}
|
||||||
@@ -152,6 +152,9 @@ export default function PlayerBar() {
|
|||||||
const displayCoverArt = showPreviewMeta ? previewingTrack!.coverArt : currentTrack?.coverArt;
|
const displayCoverArt = showPreviewMeta ? previewingTrack!.coverArt : currentTrack?.coverArt;
|
||||||
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
|
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
|
||||||
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
|
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 coverSrc = useMemo(() => displayCoverArt ? buildCoverArtUrl(displayCoverArt, 128) : '', [displayCoverArt]);
|
||||||
const coverKey = displayCoverArt ? coverArtCacheKey(displayCoverArt, 128) : '';
|
const coverKey = displayCoverArt ? coverArtCacheKey(displayCoverArt, 128) : '';
|
||||||
@@ -206,6 +209,7 @@ export default function PlayerBar() {
|
|||||||
displayCoverArt={displayCoverArt}
|
displayCoverArt={displayCoverArt}
|
||||||
displayTitle={displayTitle}
|
displayTitle={displayTitle}
|
||||||
displayArtist={displayArtist}
|
displayArtist={displayArtist}
|
||||||
|
displayArtistRefs={displayArtistRefs}
|
||||||
showPreviewMeta={showPreviewMeta}
|
showPreviewMeta={showPreviewMeta}
|
||||||
previewingTrack={previewingTrack}
|
previewingTrack={previewingTrack}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { emit } from '@tauri-apps/api/event';
|
||||||
import CachedImage from '../CachedImage';
|
import CachedImage from '../CachedImage';
|
||||||
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
import type { MiniTrackInfo } from '../../utils/miniPlayerBridge';
|
import type { MiniTrackInfo } from '../../utils/miniPlayerBridge';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,9 +28,20 @@ export function MiniMeta({ track, miniCoverSrc, miniCoverKey }: Props) {
|
|||||||
<div className="mini-player__title" title={track?.title}>
|
<div className="mini-player__title" title={track?.title}>
|
||||||
{track?.title ?? '—'}
|
{track?.title ?? '—'}
|
||||||
</div>
|
</div>
|
||||||
{track?.artist && (
|
{track?.artists && track.artists.length > 0 ? (
|
||||||
|
<div className="mini-player__artist" title={track.artists.map(a => a.name).filter(Boolean).join(' · ')}>
|
||||||
|
<OpenArtistRefInline
|
||||||
|
refs={track.artists}
|
||||||
|
fallbackName={track.artist}
|
||||||
|
onGoArtist={id => { void emit('mini:navigate', { to: `/artist/${id}` }); }}
|
||||||
|
as="none"
|
||||||
|
linkTag="span"
|
||||||
|
linkClassName="mini-player__artist-link"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : track?.artist ? (
|
||||||
<div className="mini-player__artist" title={track.artist}>{track.artist}</div>
|
<div className="mini-player__artist" title={track.artist}>{track.artist}</div>
|
||||||
)}
|
) : null}
|
||||||
{track?.album && (
|
{track?.album && (
|
||||||
<div className="mini-player__album" title={track.album}>{track.album}</div>
|
<div className="mini-player__album" title={track.album}>{track.album}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Cast, Heart, Maximize2, Music } from 'lucide-react';
|
import { Cast, Heart, Maximize2, Music } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { setRating } from '../../api/subsonicStarRating';
|
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 { PlayerState, Track } from '../../store/playerStoreTypes';
|
||||||
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
||||||
import type { PreviewingTrack } from '../../store/previewStore';
|
import type { PreviewingTrack } from '../../store/previewStore';
|
||||||
import CachedImage from '../CachedImage';
|
import CachedImage from '../CachedImage';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
import LastfmIcon from '../LastfmIcon';
|
||||||
import MarqueeText from '../MarqueeText';
|
import MarqueeText from '../MarqueeText';
|
||||||
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,6 +23,8 @@ interface Props {
|
|||||||
displayCoverArt: string | undefined;
|
displayCoverArt: string | undefined;
|
||||||
displayTitle: string;
|
displayTitle: string;
|
||||||
displayArtist: string;
|
displayArtist: string;
|
||||||
|
/** When set (OpenSubsonic `artists` on the playing track), render split links like album track rows. */
|
||||||
|
displayArtistRefs?: SubsonicOpenArtistRef[];
|
||||||
showPreviewMeta: boolean;
|
showPreviewMeta: boolean;
|
||||||
previewingTrack: PreviewingTrack | null;
|
previewingTrack: PreviewingTrack | null;
|
||||||
isStarred: boolean;
|
isStarred: boolean;
|
||||||
@@ -39,7 +42,7 @@ interface Props {
|
|||||||
|
|
||||||
export function PlayerTrackInfo({
|
export function PlayerTrackInfo({
|
||||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey,
|
currentTrack, currentRadio, isRadio, radioMeta, radioCoverSrc, radioCoverKey,
|
||||||
coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist,
|
coverSrc, coverKey, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
|
||||||
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
||||||
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
||||||
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
|
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
|
||||||
@@ -116,16 +119,29 @@ export function PlayerTrackInfo({
|
|||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
<MarqueeText
|
{!isRadio && displayArtistRefs && displayArtistRefs.length > 0 ? (
|
||||||
text={isRadio
|
<div className="marquee-wrap player-track-artist">
|
||||||
? (radioMeta.currentTitle && currentRadio?.name
|
<OpenArtistRefInline
|
||||||
? currentRadio.name
|
refs={displayArtistRefs}
|
||||||
: t('radio.liveStream'))
|
fallbackName={displayArtist}
|
||||||
: displayArtist}
|
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||||
className="player-track-artist"
|
as="none"
|
||||||
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.artistId ? 'pointer' : 'default' }}
|
linkTag="span"
|
||||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
linkClassName="player-artist-link"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarqueeText
|
||||||
|
text={isRadio
|
||||||
|
? (radioMeta.currentTitle && currentRadio?.name
|
||||||
|
? currentRadio.name
|
||||||
|
: t('radio.liveStream'))
|
||||||
|
: displayArtist}
|
||||||
|
className="player-track-artist"
|
||||||
|
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{currentTrack && !isRadio && !showPreviewMeta && (
|
{currentTrack && !isRadio && !showPreviewMeta && (
|
||||||
<StarRating
|
<StarRating
|
||||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export const CONTRIBUTORS = [
|
|||||||
'Audio: post-sleep stream recovery (Windows + Linux) with poll-gap-armed stall watchdog; preview seekbar freeze + anti-jump on preview end; remove card hover lift and per-card GPU compositing hints (PR #476)',
|
'Audio: post-sleep stream recovery (Windows + Linux) with poll-gap-armed stall watchdog; preview seekbar freeze + anti-jump on preview end; remove card hover lift and per-card GPU compositing hints (PR #476)',
|
||||||
'Analysis queue control: prune stale http-backfill / cpu-seed jobs when tracks leave the playback queue, cap loudness backfill warmup to current + next 5 tracks, plus debug counters for diagnostics (PR #480)',
|
'Analysis queue control: prune stale http-backfill / cpu-seed jobs when tracks leave the playback queue, cap loudness backfill warmup to current + next 5 tracks, plus debug counters for diagnostics (PR #480)',
|
||||||
'CachedImage / useCachedUrl: blob URL only for matching cacheKey, layout load reset on key change; fixes broken player cover flash on track switch (#606) (PR #695)',
|
'CachedImage / useCachedUrl: blob URL only for matching cacheKey, layout load reset on key change; fixes broken player cover flash on track switch (#606) (PR #695)',
|
||||||
|
'OpenSubsonic albumArtists in album header; track artists in player bar, mobile, mini + songToTrack (#552) (PR #696)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useSelectionStore } from '../store/selectionStore';
|
import { useSelectionStore } from '../store/selectionStore';
|
||||||
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
|
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
|
||||||
|
import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
|
|
||||||
export default function AlbumDetail() {
|
export default function AlbumDetail() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -279,12 +280,14 @@ const handleShuffleAll = () => {
|
|||||||
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
|
||||||
|
|
||||||
const { album: info, songs } = album;
|
const { album: info, songs } = album;
|
||||||
|
const headerArtistRefs = deriveAlbumHeaderArtistRefs(info, songs);
|
||||||
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="album-detail animate-fade-in">
|
<div className="album-detail animate-fade-in">
|
||||||
<AlbumHeader
|
<AlbumHeader
|
||||||
info={info}
|
info={info}
|
||||||
|
headerArtistRefs={headerArtistRefs}
|
||||||
songs={songs}
|
songs={songs}
|
||||||
coverUrl={coverUrl}
|
coverUrl={coverUrl}
|
||||||
coverKey={coverKey}
|
coverKey={coverKey}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InternetRadioStation } from '../api/subsonicTypes';
|
import type { InternetRadioStation, SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||||
import type { PlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
|
import type { PlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
@@ -8,6 +8,8 @@ export interface Track {
|
|||||||
album: string;
|
album: string;
|
||||||
albumId: string;
|
albumId: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
|
/** OpenSubsonic `artists` on the child song — multiple performers with ids. */
|
||||||
|
artists?: SubsonicOpenArtistRef[];
|
||||||
duration: number;
|
duration: number;
|
||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
track?: number;
|
track?: number;
|
||||||
|
|||||||
@@ -122,6 +122,11 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.album-detail-artist .open-artist-ref-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.album-detail-info {
|
.album-detail-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -147,6 +147,20 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mini-player__artist .open-artist-ref-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__artist-link {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-player__artist-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.mini-player__toolbar {
|
.mini-player__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -170,6 +170,20 @@ html[data-platform="windows"] .player-bar.floating {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-track-artist .open-artist-ref-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-track-artist .player-artist-link {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-track-artist .player-artist-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.player-track-rating {
|
.player-track-rating {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|||||||
@@ -25,6 +25,20 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mp-artist .open-artist-ref-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-artist-link {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-artist-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.mp-track-info {
|
.mp-track-info {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { deriveAlbumHeaderArtistRefs } from './deriveAlbumHeaderArtistRefs';
|
||||||
|
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
|
import { makeSubsonicSong } from '@/test/helpers/factories';
|
||||||
|
|
||||||
|
const baseAlbum = (): SubsonicAlbum => ({
|
||||||
|
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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
|||||||
import { listen, emitTo } from '@tauri-apps/api/event';
|
import { listen, emitTo } from '@tauri-apps/api/event';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||||
|
|
||||||
export const MINI_WINDOW_LABEL = 'mini';
|
export const MINI_WINDOW_LABEL = 'mini';
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ export interface MiniTrackInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
|
/** OpenSubsonic performer refs when the main queue carried them. */
|
||||||
|
artists?: SubsonicOpenArtistRef[];
|
||||||
album: string;
|
album: string;
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
artistId?: string;
|
artistId?: string;
|
||||||
@@ -41,6 +44,7 @@ function toMini(t: any): MiniTrackInfo {
|
|||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist,
|
artist: t.artist,
|
||||||
|
artists: Array.isArray(t.artists) && t.artists.length > 0 ? t.artists : undefined,
|
||||||
album: t.album,
|
album: t.album,
|
||||||
albumId: t.albumId,
|
albumId: t.albumId,
|
||||||
artistId: t.artistId,
|
artistId: t.artistId,
|
||||||
@@ -87,6 +91,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
|||||||
payload.track?.id ?? '',
|
payload.track?.id ?? '',
|
||||||
payload.isPlaying,
|
payload.isPlaying,
|
||||||
payload.track?.starred ?? '',
|
payload.track?.starred ?? '',
|
||||||
|
(payload.track?.artists ?? []).map((a: SubsonicOpenArtistRef) => a.id ?? a.name).join('|'),
|
||||||
payload.queueIndex,
|
payload.queueIndex,
|
||||||
payload.volume,
|
payload.volume,
|
||||||
payload.gaplessEnabled,
|
payload.gaplessEnabled,
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ describe('songToTrack', () => {
|
|||||||
expect(t.replayGainPeak).toBeUndefined();
|
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', () => {
|
it('does not invent fields that the Subsonic song lacks', () => {
|
||||||
const song = makeSubsonicSong({});
|
const song = makeSubsonicSong({});
|
||||||
const t = songToTrack(song);
|
const t = songToTrack(song);
|
||||||
@@ -92,5 +100,6 @@ describe('songToTrack', () => {
|
|||||||
expect(t.autoAdded).toBeUndefined();
|
expect(t.autoAdded).toBeUndefined();
|
||||||
expect(t.radioAdded).toBeUndefined();
|
expect(t.radioAdded).toBeUndefined();
|
||||||
expect(t.playNextAdded).toBeUndefined();
|
expect(t.playNextAdded).toBeUndefined();
|
||||||
|
expect(t.artists).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function songToTrack(song: SubsonicSong): Track {
|
|||||||
album: song.album,
|
album: song.album,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
|
artists: song.artists && song.artists.length > 0 ? song.artists : undefined,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt,
|
coverArt: song.coverArt,
|
||||||
track: song.track,
|
track: song.track,
|
||||||
|
|||||||
Reference in New Issue
Block a user