refactor(playlist): G.63 — extract SongSearchPanel + Suggestions (cluster) (#630)

Two-cut cluster on PlaylistDetail.tsx. 1400 → 1199 LOC (−201). Both
JSX islands lift out cleanly — they own no playlist-level state, they
just consume props plus their own store subscriptions.

PlaylistSongSearchPanel — the song-search overlay that opens behind
the "Add songs" button. Owns its render only; query / results /
selection / playlist-picker-open / context-menu-id all stay as state
in PlaylistDetail (the debounced search useEffect still drives them).
The component pulls `openContextMenu` from playerStore directly so
the parent doesn't have to wire it through. PlaylistSearchResultThumb
moves inline with the new file — it has no other consumers.

PlaylistSuggestions — the discover-more strip rendered below the
tracklist. Subscribes to playerStore (openContextMenu + the
play-next inline action), previewStore (previewingId +
audioStarted), themeStore (showBitrate), and react-router
(navigate). Existing-id filter, hovered-id highlight, contextMenuId
and the load-more callback come in as props from the page.
PL_CENTERED is duplicated in the component because the tracklist
header inside PlaylistDetail still references it; dedup is a
follow-up once the tracklist itself is extracted.

PlaylistDetail's import list drops PlaylistSearchResultThumb (now
unused locally) and picks up the two component imports. Pure code
move otherwise — no behaviour change.
This commit is contained in:
Frank Stellmacher
2026-05-13 12:48:56 +02:00
committed by GitHub
parent 1b1122f086
commit bf8e4fff3f
3 changed files with 353 additions and 233 deletions
@@ -0,0 +1,181 @@
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 type { ColDef } from '../../utils/useTracklistColumns';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { usePreviewStore } from '../../store/previewStore';
import { useThemeStore } from '../../store/themeStore';
import { songToTrack } from '../../utils/songToTrack';
import { codecLabel, formatDuration } from '../../utils/playlistDetailHelpers';
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
interface Props {
songs: SubsonicSong[];
suggestions: SubsonicSong[];
existingIds: Set<string>;
loadingSuggestions: boolean;
loadSuggestions: (songs: SubsonicSong[]) => void;
visibleCols: ColDef[];
gridStyle: React.CSSProperties;
contextMenuSongId: string | null;
setContextMenuSongId: React.Dispatch<React.SetStateAction<string | null>>;
hoveredSuggestionId: string | null;
setHoveredSuggestionId: React.Dispatch<React.SetStateAction<string | null>>;
addSong: (song: SubsonicSong) => void;
startPreview: (song: SubsonicSong) => void;
}
export default function PlaylistSuggestions({
songs, suggestions, existingIds,
loadingSuggestions, loadSuggestions,
visibleCols, gridStyle,
contextMenuSongId, setContextMenuSongId,
hoveredSuggestionId, setHoveredSuggestionId,
addSong, startPreview,
}: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const previewingId = usePreviewStore(s => s.previewingId);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const showBitrate = useThemeStore(s => s.showBitrate);
const filteredSuggestions = suggestions.filter(s => !existingIds.has(s.id));
return (
<div className="playlist-suggestions tracklist" data-preview-loc="suggestions">
<div className="playlist-suggestions-header">
<div className="playlist-suggestions-title">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
<span className="playlist-suggestions-hint">{t('playlists.suggestionsHint')}</span>
</div>
<button
className="btn btn-surface"
onClick={() => loadSuggestions(songs)}
disabled={loadingSuggestions || songs.length === 0}
data-tooltip={t('playlists.refreshSuggestions')}
>
<RefreshCw size={14} className={loadingSuggestions ? 'spin-slow' : ''} />
{t('playlists.refreshSuggestions')}
</button>
</div>
{!loadingSuggestions && filteredSuggestions.length === 0 && (
<div className="empty-state" style={{ padding: '1.5rem 0', fontSize: '0.85rem' }}>{t('playlists.noSuggestions')}</div>
)}
{filteredSuggestions.length > 0 && (
<>
<div className="tracklist-header tracklist-va" style={{ ...gridStyle, marginTop: 'var(--space-3)' }}>
{visibleCols.map((colDef) => {
const key = colDef.key;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
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) => (
<div
key={song.id}
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={gridStyle}
onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
onDoubleClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
addSong(song);
}}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
case 'title': return (
<div key="title" className="track-info track-info-suggestion">
<button
className="playlist-suggestion-play-btn"
onClick={e => {
e.stopPropagation();
const { queue, queueIndex, currentTrack, playTrack } = usePlayerStore.getState();
const track = songToTrack(song);
if (!currentTrack || queue.length === 0) {
playTrack(track, [track]);
return;
}
const insertAt = Math.min(queueIndex + 1, queue.length);
const newQueue = [
...queue.slice(0, insertAt),
track,
...queue.slice(insertAt),
];
playTrack(track, newQueue);
}}
data-tooltip={t('playlists.playNextSuggestion')}
aria-label={t('playlists.playNextSuggestion')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
onClick={e => { e.stopPropagation(); startPreview(song); }}
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{previewingId === song.id
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<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 '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 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && <span className="track-codec">{codecLabel(song, showBitrate)}</span>}
</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
<Plus size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
))}
</>
)}
</div>
);
}