fix(playlist): suggestion rows match the normal track row (#980)

* fix(playlist): suggestion rows match the normal track row

The Suggested Songs table left the Favorite and Rating columns empty and
rendered only a single artist, so multi-artist tracks lost their split and
the blank columns read as a gap between Genre and Duration.

- Extract a shared PlaylistArtistCell that splits the OpenSubsonic artists
  array into individually navigable links; use it for both the playlist
  rows and the suggestions so a track reads the same before and after it is
  added.
- Show the real favorite heart and star rating in suggestion rows (global
  song operations), seeded from the song's own starred/userRating, removing
  the empty-column gap.

* docs: changelog for suggestion row parity (#980)
This commit is contained in:
Frank Stellmacher
2026-06-04 10:34:25 +02:00
committed by GitHub
parent a7c79ee210
commit f3eb58c707
6 changed files with 117 additions and 17 deletions
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/helpers/renderWithProviders';
import { PlaylistArtistCell } from './PlaylistArtistCell';
import type { SubsonicSong } from '../../api/subsonicTypes';
function song(overrides: Partial<SubsonicSong>): SubsonicSong {
return {
id: 's1', title: 'Track', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100,
...overrides,
} as SubsonicSong;
}
describe('PlaylistArtistCell', () => {
it('splits the OpenSubsonic artists array into individual links', () => {
renderWithProviders(
<PlaylistArtistCell song={song({
artist: 'Apocalyptica', artistId: 'a1',
artists: [{ id: 'a1', name: 'Apocalyptica' }, { id: 'a2', name: 'Joe Duplantier' }],
})} />,
);
expect(screen.getByText('Apocalyptica')).toHaveClass('track-artist-link');
expect(screen.getByText('Joe Duplantier')).toHaveClass('track-artist-link');
});
it('falls back to the legacy artist string when no structured array exists', () => {
renderWithProviders(
<PlaylistArtistCell song={song({ artist: 'Gathering Of Kings', artistId: 'a1' })} />,
);
expect(screen.getByText('Gathering Of Kings')).toHaveClass('track-artist-link');
});
it('renders a non-navigable name when the ref has no id', () => {
renderWithProviders(
<PlaylistArtistCell song={song({ artist: 'Various Artists', artistId: '' })} />,
);
const el = screen.getByText('Various Artists');
expect(el).not.toHaveClass('track-artist-link');
});
});
@@ -0,0 +1,33 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import type { SubsonicSong } from '../../api/subsonicTypes';
/**
* Multi-artist credit for playlist track rows (main list + suggestions).
* Renders the OpenSubsonic `artists` array as ·-separated, individually
* navigable links, falling back to the legacy `artist`/`artistId` pair.
* Mirrors the album track list (TrackRow) so a track reads the same before
* and after it is added to the playlist.
*/
export function PlaylistArtistCell({ song }: { song: SubsonicSong }) {
const navigate = useNavigate();
const artistRefs = song.artists && song.artists.length > 0
? song.artists
: [{ id: song.artistId, name: song.artist }];
return (
<div className="track-artist-cell">
{artistRefs.map((a, i) => (
<React.Fragment key={a.id ?? a.name ?? i}>
{i > 0 && <span className="track-artist-sep">&nbsp;·&nbsp;</span>}
<span
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
style={{ cursor: a.id ? 'pointer' : 'default' }}
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
>
{a.name ?? song.artist}
</span>
</React.Fragment>
))}
</div>
);
}
+2 -5
View File
@@ -8,6 +8,7 @@ import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
import i18n from '../../i18n';
import { formatTrackTime } from '../../utils/format/formatDuration';
import StarRating from '../StarRating';
import { PlaylistArtistCell } from './PlaylistArtistCell';
export interface PlaylistRowCallbacks {
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
@@ -104,11 +105,7 @@ function PlaylistRow({
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist}</span>
</div>
);
case 'artist': return <PlaylistArtistCell key="artist" song={song} />;
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album}</span>
+31 -12
View File
@@ -1,11 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, Play, Plus, RefreshCw, Square } from 'lucide-react';
import { ChevronRight, Heart, Play, Plus, RefreshCw, Square } from 'lucide-react';
import type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import StarRating from '../StarRating';
import { PlaylistArtistCell } from './PlaylistArtistCell';
import { useThemeStore } from '../../store/themeStore';
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
import { songToTrack } from '../../utils/playback/songToTrack';
@@ -31,6 +33,10 @@ interface Props {
setHoveredSuggestionId: React.Dispatch<React.SetStateAction<string | null>>;
addSong: (song: SubsonicSong) => void;
startPreview: (song: SubsonicSong) => void;
ratings: Record<string, number>;
starredSongs: Set<string>;
handleRate: (songId: string, rating: number) => void;
handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void;
}
export default function PlaylistSuggestions({
@@ -40,10 +46,13 @@ export default function PlaylistSuggestions({
contextMenuSongId, setContextMenuSongId,
hoveredSuggestionId, setHoveredSuggestionId,
addSong, startPreview,
ratings, starredSongs, handleRate, handleToggleStar,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const showBitrate = useThemeStore(s => s.showBitrate);
@@ -86,15 +95,22 @@ export default function PlaylistSuggestions({
if (key === 'num') return <div key="num" className="col-center">#</div>;
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
if (key === 'favorite' || key === 'rating') return <div key={key} />;
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
})}
</div>
{filteredSuggestions.map((song, idx) => (
{filteredSuggestions.map((song, idx) => {
const isStarred = song.id in starredOverrides
? !!starredOverrides[song.id]
: (starredSongs.has(song.id) || !!song.starred);
const ratingValue = ratings[song.id]
?? userRatingOverrides[song.id]
?? song.userRating
?? 0;
return (
<div
key={song.id}
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
className={`track-row track-row-va track-row-with-actions tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={gridStyle}
onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
@@ -156,18 +172,20 @@ export default function PlaylistSuggestions({
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'artist': return <PlaylistArtistCell key="artist" song={song} />;
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return <div key="favorite" />;
case 'rating': return <div key="rating" />;
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }} data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratingValue} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatTrackTime(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
@@ -197,7 +215,8 @@ export default function PlaylistSuggestions({
}
})}
</div>
))}
);
})}
</>
)}
</div>