diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e0df6f..0cca34cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +### 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 > **๐Ÿ™ 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. diff --git a/src/components/playlist/PlaylistArtistCell.test.tsx b/src/components/playlist/PlaylistArtistCell.test.tsx new file mode 100644 index 00000000..65fa1fe2 --- /dev/null +++ b/src/components/playlist/PlaylistArtistCell.test.tsx @@ -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 { + 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( + , + ); + 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( + , + ); + expect(screen.getByText('Gathering Of Kings')).toHaveClass('track-artist-link'); + }); + + it('renders a non-navigable name when the ref has no id', () => { + renderWithProviders( + , + ); + const el = screen.getByText('Various Artists'); + expect(el).not.toHaveClass('track-artist-link'); + }); +}); diff --git a/src/components/playlist/PlaylistArtistCell.tsx b/src/components/playlist/PlaylistArtistCell.tsx new file mode 100644 index 00000000..04a9779f --- /dev/null +++ b/src/components/playlist/PlaylistArtistCell.tsx @@ -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 ( +
+ {artistRefs.map((a, i) => ( + + {i > 0 &&  ยท } + { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }} + > + {a.name ?? song.artist} + + + ))} +
+ ); +} diff --git a/src/components/playlist/PlaylistRow.tsx b/src/components/playlist/PlaylistRow.tsx index 50eca6ba..7b5270da 100644 --- a/src/components/playlist/PlaylistRow.tsx +++ b/src/components/playlist/PlaylistRow.tsx @@ -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({ {song.title} ); - case 'artist': return ( -
- { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId); } }}>{song.artist} -
- ); + case 'artist': return ; case 'album': return (
{ if (song.albumId) { e.stopPropagation(); cb.navAlbum(song.albumId); } }}>{song.album} diff --git a/src/components/playlist/PlaylistSuggestions.tsx b/src/components/playlist/PlaylistSuggestions.tsx index 217a70a8..a3aa7366 100644 --- a/src/components/playlist/PlaylistSuggestions.tsx +++ b/src/components/playlist/PlaylistSuggestions.tsx @@ -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>; addSong: (song: SubsonicSong) => void; startPreview: (song: SubsonicSong) => void; + ratings: Record; + starredSongs: Set; + 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
#
; if (key === 'title') return
{label}
; if (key === 'delete') return
; - if (key === 'favorite' || key === 'rating') return
; return
{label}
; })}
- {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 (
setHoveredSuggestionId(song.id)} onMouseLeave={() => setHoveredSuggestionId(null)} @@ -156,18 +172,20 @@ export default function PlaylistSuggestions({ {song.title}
); - case 'artist': return ( -
- { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} -
- ); + case 'artist': return ; case 'album': return (
{ if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}
); - case 'favorite': return
; - case 'rating': return
; + case 'favorite': return ( +
+ +
+ ); + case 'rating': return handleRate(song.id, r)} />; case 'duration': return
{formatTrackTime(song.duration ?? 0)}
; case 'format': return (
@@ -197,7 +215,8 @@ export default function PlaylistSuggestions({ } })}
- ))} + ); + })} )}
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 45fad0b6..9490c8bd 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -390,6 +390,10 @@ export default function PlaylistDetail() { setHoveredSuggestionId={setHoveredSuggestionId} addSong={addSong} startPreview={startPreview} + ratings={ratings} + starredSongs={starredSongs} + handleRate={handleRate} + handleToggleStar={handleToggleStar} /> {editingMeta && playlist && (