mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
a7c79ee210
commit
f3eb58c707
@@ -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"> · </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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -390,6 +390,10 @@ export default function PlaylistDetail() {
|
||||
setHoveredSuggestionId={setHoveredSuggestionId}
|
||||
addSong={addSong}
|
||||
startPreview={startPreview}
|
||||
ratings={ratings}
|
||||
starredSongs={starredSongs}
|
||||
handleRate={handleRate}
|
||||
handleToggleStar={handleToggleStar}
|
||||
/>
|
||||
|
||||
{editingMeta && playlist && (
|
||||
|
||||
Reference in New Issue
Block a user