mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -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({
|
||||
)}
|
||||
<h1 className="album-detail-title">{info.name}</h1>
|
||||
<p className="album-detail-artist">
|
||||
<button
|
||||
className="album-detail-artist-link"
|
||||
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
|
||||
onClick={() => navigate(`/artist/${info.artistId}`)}
|
||||
>
|
||||
{info.artist}
|
||||
</button>
|
||||
<OpenArtistRefInline
|
||||
refs={headerArtistRefs}
|
||||
fallbackName={info.artist}
|
||||
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||
linkClassName="album-detail-artist-link"
|
||||
/>
|
||||
</p>
|
||||
<div className="album-detail-info">
|
||||
{info.year && <span>{info.year}</span>}
|
||||
|
||||
@@ -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() {
|
||||
<div className="mp-meta">
|
||||
<div className="mp-meta-text">
|
||||
<div className="mp-title truncate">{currentTrack.title}</div>
|
||||
<div
|
||||
className="mp-artist truncate"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>
|
||||
{currentTrack.artist}
|
||||
<div className="mp-artist truncate">
|
||||
{currentTrack.artists && currentTrack.artists.length > 0 ? (
|
||||
<OpenArtistRefInline
|
||||
refs={currentTrack.artists}
|
||||
fallbackName={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>
|
||||
{(() => {
|
||||
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 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}
|
||||
|
||||
@@ -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) {
|
||||
<div className="mini-player__title" title={track?.title}>
|
||||
{track?.title ?? '—'}
|
||||
</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>
|
||||
)}
|
||||
) : null}
|
||||
{track?.album && (
|
||||
<div className="mini-player__album" title={track.album}>{track.album}</div>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<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}`)}
|
||||
/>
|
||||
{!isRadio && displayArtistRefs && displayArtistRefs.length > 0 ? (
|
||||
<div className="marquee-wrap player-track-artist">
|
||||
<OpenArtistRefInline
|
||||
refs={displayArtistRefs}
|
||||
fallbackName={displayArtist}
|
||||
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
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 && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
|
||||
Reference in New Issue
Block a user