mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +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
@@ -878,6 +878,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
* Compilation albums under **Also featured on** show their album artist (e.g. *Various Artists*) again instead of a bare `—`, and the credit links to the artist when the server provides one.
|
* Compilation albums under **Also featured on** show their album artist (e.g. *Various Artists*) again instead of a bare `—`, and the credit links to the artist when the server provides one.
|
||||||
|
|
||||||
|
### Playlist — Suggested Songs row matches the playlist
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#980](https://github.com/Psychotoxical/psysonic/pull/980)**
|
||||||
|
|
||||||
|
* Suggested Songs now show the favorite heart and star rating like the playlist above, so the Favorite/Rating columns no longer leave an empty gap.
|
||||||
|
* Tracks with several artists split into individually clickable names, matching the rest of the app — and reading the same before and after you add the track.
|
||||||
|
|
||||||
## [1.46.0] - 2026-05-18
|
## [1.46.0] - 2026-05-18
|
||||||
|
|
||||||
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
|
||||||
|
|||||||
@@ -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 i18n from '../../i18n';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
|
import { PlaylistArtistCell } from './PlaylistArtistCell';
|
||||||
|
|
||||||
export interface PlaylistRowCallbacks {
|
export interface PlaylistRowCallbacks {
|
||||||
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
|
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
|
||||||
@@ -104,11 +105,7 @@ function PlaylistRow({
|
|||||||
<span className="track-title">{song.title}</span>
|
<span className="track-title">{song.title}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'artist': return (
|
case 'artist': return <PlaylistArtistCell key="artist" song={song} />;
|
||||||
<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 'album': return (
|
case 'album': return (
|
||||||
<div key="album" className="track-artist-cell">
|
<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>
|
<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 React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { ColDef } from '../../utils/useTracklistColumns';
|
||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { usePreviewStore } from '../../store/previewStore';
|
||||||
|
import StarRating from '../StarRating';
|
||||||
|
import { PlaylistArtistCell } from './PlaylistArtistCell';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
@@ -31,6 +33,10 @@ interface Props {
|
|||||||
setHoveredSuggestionId: React.Dispatch<React.SetStateAction<string | null>>;
|
setHoveredSuggestionId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
addSong: (song: SubsonicSong) => void;
|
addSong: (song: SubsonicSong) => void;
|
||||||
startPreview: (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({
|
export default function PlaylistSuggestions({
|
||||||
@@ -40,10 +46,13 @@ export default function PlaylistSuggestions({
|
|||||||
contextMenuSongId, setContextMenuSongId,
|
contextMenuSongId, setContextMenuSongId,
|
||||||
hoveredSuggestionId, setHoveredSuggestionId,
|
hoveredSuggestionId, setHoveredSuggestionId,
|
||||||
addSong, startPreview,
|
addSong, startPreview,
|
||||||
|
ratings, starredSongs, handleRate, handleToggleStar,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
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 previewingId = usePreviewStore(s => s.previewingId);
|
||||||
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
|
||||||
const showBitrate = useThemeStore(s => s.showBitrate);
|
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 === 'num') return <div key="num" className="col-center">#</div>;
|
||||||
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
|
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
|
||||||
if (key === 'delete') return <div key="delete" />;
|
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>;
|
return <div key={key} className={isCentered ? 'col-center' : ''} style={!isCentered ? { paddingLeft: 12 } : undefined}>{label}</div>;
|
||||||
})}
|
})}
|
||||||
</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
|
<div
|
||||||
key={song.id}
|
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}
|
style={gridStyle}
|
||||||
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
onMouseEnter={() => setHoveredSuggestionId(song.id)}
|
||||||
onMouseLeave={() => setHoveredSuggestionId(null)}
|
onMouseLeave={() => setHoveredSuggestionId(null)}
|
||||||
@@ -156,18 +172,20 @@ export default function PlaylistSuggestions({
|
|||||||
<span className="track-title">{song.title}</span>
|
<span className="track-title">{song.title}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'artist': return (
|
case 'artist': return <PlaylistArtistCell key="artist" song={song} />;
|
||||||
<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 'album': return (
|
case 'album': return (
|
||||||
<div key="album" className="track-artist-cell">
|
<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>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
case 'favorite': return <div key="favorite" />;
|
case 'favorite': return (
|
||||||
case 'rating': return <div key="rating" />;
|
<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 'duration': return <div key="duration" className="track-duration">{formatTrackTime(song.duration ?? 0)}</div>;
|
||||||
case 'format': return (
|
case 'format': return (
|
||||||
<div key="format" className="track-meta">
|
<div key="format" className="track-meta">
|
||||||
@@ -197,7 +215,8 @@ export default function PlaylistSuggestions({
|
|||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -390,6 +390,10 @@ export default function PlaylistDetail() {
|
|||||||
setHoveredSuggestionId={setHoveredSuggestionId}
|
setHoveredSuggestionId={setHoveredSuggestionId}
|
||||||
addSong={addSong}
|
addSong={addSong}
|
||||||
startPreview={startPreview}
|
startPreview={startPreview}
|
||||||
|
ratings={ratings}
|
||||||
|
starredSongs={starredSongs}
|
||||||
|
handleRate={handleRate}
|
||||||
|
handleToggleStar={handleToggleStar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{editingMeta && playlist && (
|
{editingMeta && playlist && (
|
||||||
|
|||||||
Reference in New Issue
Block a user