Files
Psychotoxical-psysonic/src/components/SongCard.tsx
T
cucadmuh 30e9db1a2b fix(artists): per-artist links on song rails and shared OpenSubsonic refs (#1023)
* fix(artists): per-artist links on song rails and shared OpenSubsonic refs

Song cards in Random Picks and Discover Songs showed joined artist
credits but navigated to a single artistId. Route track surfaces through
resolveTrackArtistRefs and coerce single-object Subsonic JSON payloads.

* docs(changelog): note song-rail multi-artist link fix (PR #1023)
2026-06-08 00:45:32 +03:00

182 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo, useMemo } from 'react';
import { Play, ListPlus, Star, Disc3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useCoverArt } from '../cover/useCoverArt';
import { useTrackCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes';
import { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
interface SongCardProps {
song: SubsonicSong;
disableArtwork?: boolean;
/** Layout-native cover square width in CSS px (rail cell). */
displayCssPx?: number;
/** @deprecated Use displayCssPx */
artworkSize?: number;
}
function SongCard({
song,
disableArtwork = false,
displayCssPx = COVER_DENSE_RAIL_CELL_CSS_PX,
artworkSize,
}: SongCardProps) {
const layoutPx = artworkSize ?? displayCssPx;
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
const coverHandle = useCoverArt(coverRef, layoutPx, {
surface: 'dense',
ensurePriority: 'middle',
});
const coverUrl = coverHandle.src;
const psyDrag = useDragDrop();
const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior();
const navigateToAlbum = useNavigateToAlbum();
const handlePlay = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueueAndPlay(song);
};
const handleEnqueue = () => {
if (orbitActive) { addTrackToOrbit(song.id); return; }
enqueue([songToTrack(song)]);
};
const handleClick = handlePlay;
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return;
e.stopPropagation();
navigateToAlbum(song.albumId);
};
return (
<div
className="song-card card"
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${song.title} ${song.artist}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, song, 'song');
}}
onMouseDown={e => {
if (e.button !== 0) return;
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'song', id: song.id, name: song.title }), label: song.title, coverUrl: coverUrl || undefined },
me.clientX, me.clientY,
);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="song-card-cover cover-circle">
{!disableArtwork && coverRef ? (
<CoverArtImage
coverRef={coverRef}
displayCssPx={layoutPx}
surface="dense"
alt={`${song.album} Cover`}
loading="eager"
decoding="async"
/>
) : (
<div className="song-card-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
)}
<div className="song-card-play-overlay">
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handlePlay(); }}
aria-label={t('tracks.playSong')}
data-tooltip={t('tracks.playSong')}
data-tooltip-pos="top"
>
<Play size={14} fill="currentColor" />
</button>
<button
className="song-card-action-btn"
onClick={e => { e.stopPropagation(); handleEnqueue(); }}
aria-label={t('tracks.enqueueSong')}
data-tooltip={t('tracks.enqueueSong')}
data-tooltip-pos="top"
>
<ListPlus size={14} />
</button>
</div>
</div>
<div className="song-card-info">
<p className="song-card-title truncate" title={song.title}>{song.title}</p>
<p className="song-card-artist truncate" title={song.artist}>
<OpenArtistRefInline
refs={artistRefs}
fallbackName={song.artist}
onGoArtist={id => navigateToArtist(id)}
as="none"
linkTag="span"
linkClassName="track-artist-link"
/>
</p>
{song.albumId && (
<button
type="button"
className="song-card-album-badge"
onClick={handleAlbumClick}
aria-label={`${t('tracks.toAlbum')} ${song.album}`}
title={song.album}
>
<Disc3 size={11} />
<span>{t('tracks.toAlbum')}</span>
</button>
)}
{(song.userRating ?? 0) > 0 && (
<div className="song-card-rating" aria-label={`${song.userRating} stars`}>
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
size={11}
fill={i < (song.userRating ?? 0) ? 'currentColor' : 'none'}
strokeWidth={1.5}
/>
))}
</div>
)}
</div>
</div>
);
}
export default memo(SongCard);