From bf8e4fff3f195d802a825cb6d3f81a345b58058f Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 12:48:56 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.63=20=E2=80=94=20extrac?= =?UTF-8?q?t=20SongSearchPanel=20+=20Suggestions=20(cluster)=20(#630)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../playlist/PlaylistSongSearchPanel.tsx | 141 ++++++++++ .../playlist/PlaylistSuggestions.tsx | 181 ++++++++++++ src/pages/PlaylistDetail.tsx | 264 ++---------------- 3 files changed, 353 insertions(+), 233 deletions(-) create mode 100644 src/components/playlist/PlaylistSongSearchPanel.tsx create mode 100644 src/components/playlist/PlaylistSuggestions.tsx diff --git a/src/components/playlist/PlaylistSongSearchPanel.tsx b/src/components/playlist/PlaylistSongSearchPanel.tsx new file mode 100644 index 00000000..53a66aab --- /dev/null +++ b/src/components/playlist/PlaylistSongSearchPanel.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, ListPlus, X } from 'lucide-react'; +import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl'; +import type { SubsonicSong } from '../../api/subsonicTypes'; +import { usePlayerStore } from '../../store/playerStore'; +import { songToTrack } from '../../utils/songToTrack'; +import { formatDuration } from '../../utils/playlistDetailHelpers'; +import CachedImage from '../CachedImage'; +import { AddToPlaylistSubmenu } from '../ContextMenu'; + +function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) { + const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); + const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); + return ; +} + +interface Props { + query: string; + setQuery: (v: string) => void; + searching: boolean; + searchResults: SubsonicSong[]; + setSearchResults: React.Dispatch>; + selectedSearchIds: Set; + setSelectedSearchIds: React.Dispatch>>; + searchPlPickerOpen: boolean; + setSearchPlPickerOpen: React.Dispatch>; + contextMenuSongId: string | null; + setContextMenuSongId: React.Dispatch>; + addSong: (song: SubsonicSong) => void; +} + +export default function PlaylistSongSearchPanel({ + query, setQuery, searching, searchResults, setSearchResults, + selectedSearchIds, setSelectedSearchIds, + searchPlPickerOpen, setSearchPlPickerOpen, + contextMenuSongId, setContextMenuSongId, + addSong, +}: Props) { + const { t } = useTranslation(); + const openContextMenu = usePlayerStore(s => s.openContextMenu); + + return ( +
+
+ setQuery(e.target.value)} + autoFocus + /> + {query && ( + + )} +
+ {searching &&
} + {!searching && query && searchResults.length === 0 && ( +
{t('playlists.noResults')}
+ )} + {selectedSearchIds.size > 0 && ( +
+ + {t('common.bulkSelected', { count: selectedSearchIds.size })} + + +
+ + {searchPlPickerOpen && ( + { setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }} + /> + )} +
+ +
+ )} + {searchResults.map(song => { + const isSelected = selectedSearchIds.has(song.id); + return ( +
addSong(song)} + onContextMenu={e => { + e.preventDefault(); + setContextMenuSongId(song.id); + openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); + }} + > + e.stopPropagation()} + onChange={() => setSelectedSearchIds(prev => { + const next = new Set(prev); + next.has(song.id) ? next.delete(song.id) : next.add(song.id); + return next; + })} + /> + +
+ {song.title} + {song.artist} · {song.album} +
+ {formatDuration(song.duration ?? 0)} +
+ ); + })} +
+ ); +} diff --git a/src/components/playlist/PlaylistSuggestions.tsx b/src/components/playlist/PlaylistSuggestions.tsx new file mode 100644 index 00000000..f0406342 --- /dev/null +++ b/src/components/playlist/PlaylistSuggestions.tsx @@ -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; + loadingSuggestions: boolean; + loadSuggestions: (songs: SubsonicSong[]) => void; + visibleCols: ColDef[]; + gridStyle: React.CSSProperties; + contextMenuSongId: string | null; + setContextMenuSongId: React.Dispatch>; + hoveredSuggestionId: string | null; + setHoveredSuggestionId: React.Dispatch>; + 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 ( +
+
+
+

{t('playlists.suggestions')}

+ {t('playlists.suggestionsHint')} +
+ +
+ + {!loadingSuggestions && filteredSuggestions.length === 0 && ( +
{t('playlists.noSuggestions')}
+ )} + + {filteredSuggestions.length > 0 && ( + <> +
+ {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
#
; + if (key === 'title') return
{label}
; + if (key === 'delete') return
; + if (key === 'favorite' || key === 'rating') return
; + return
{label}
; + })} +
+ + {filteredSuggestions.map((song, idx) => ( +
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
{idx + 1}
; + case 'title': return ( +
+ + + {song.title} +
+ ); + case 'artist': return ( +
+ { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} +
+ ); + case 'album': return ( +
+ { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album} +
+ ); + case 'favorite': return
; + case 'rating': return
; + case 'duration': return
{formatDuration(song.duration ?? 0)}
; + case 'format': return ( +
+ {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} +
+ ); + case 'delete': return ( +
+ +
+ ); + default: return null; + } + })} +
+ ))} + + )} +
+ ); +} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 4947a427..5072e19f 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -41,6 +41,8 @@ import type { SpotifyCsvTrack } from '../utils/spotifyCsvImport'; import { runPlaylistCsvImport } from '../utils/runPlaylistCsvImport'; import PlaylistEditModal from '../components/playlist/PlaylistEditModal'; import CsvImportReportModal from '../components/playlist/CsvImportReportModal'; +import PlaylistSongSearchPanel from '../components/playlist/PlaylistSongSearchPanel'; +import PlaylistSuggestions from '../components/playlist/PlaylistSuggestions'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ @@ -57,12 +59,6 @@ const PL_COLUMNS: readonly ColDef[] = [ const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); -function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) { - const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); - const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); - return ; -} - export default function PlaylistDetail() { const { id } = useParams<{ id: string }>(); const { t } = useTranslation(); @@ -791,102 +787,20 @@ export default function PlaylistDetail() { {/* ── Song search panel ── */} {searchOpen && ( -
-
- setSearchQuery(e.target.value)} - autoFocus - /> - {searchQuery && ( - - )} -
- {searching &&
} - {!searching && searchQuery && searchResults.length === 0 && ( -
{t('playlists.noResults')}
- )} - {selectedSearchIds.size > 0 && ( -
- - {t('common.bulkSelected', { count: selectedSearchIds.size })} - - -
- - {searchPlPickerOpen && ( - { setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }} - /> - )} -
- -
- )} - {searchResults.map(song => { - const isSelected = selectedSearchIds.has(song.id); - return ( -
addSong(song)} - onContextMenu={e => { - e.preventDefault(); - setContextMenuSongId(song.id); - openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song'); - }} - > - e.stopPropagation()} - onChange={() => setSelectedSearchIds(prev => { - const next = new Set(prev); - next.has(song.id) ? next.delete(song.id) : next.add(song.id); - return next; - })} - /> - -
- {song.title} - {song.artist} · {song.album} -
- {formatDuration(song.duration ?? 0)} -
- ); - })} -
+ )} {/* ── Filter / sort toolbar ── */} @@ -1244,137 +1158,21 @@ export default function PlaylistDetail() {
{/* ── Suggestions ── */} -
-
-
-

{t('playlists.suggestions')}

- {t('playlists.suggestionsHint')} -
- -
- - {!loadingSuggestions && suggestions.filter(s => !existingIds.has(s.id)).length === 0 && ( -
{t('playlists.noSuggestions')}
- )} - - {suggestions.filter(s => !existingIds.has(s.id)).length > 0 && ( - <> -
- {visibleCols.map((colDef, colIndex) => { - const key = colDef.key; - const isCentered = PL_CENTERED.has(key); - const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : ''; - if (key === 'num') return
#
; - if (key === 'title') return
{label}
; - if (key === 'delete') return
; - if (key === 'favorite' || key === 'rating') return
; - return
{label}
; - })} -
- - {suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => ( -
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
{idx + 1}
; - case 'title': return ( -
- - - {song.title} -
- ); - case 'artist': return ( -
- { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist} -
- ); - case 'album': return ( -
- { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album} -
- ); - case 'favorite': return
; - case 'rating': return
; - case 'duration': return
{formatDuration(song.duration ?? 0)}
; - case 'format': return ( -
- {(song.suffix || (showBitrate && song.bitRate)) && {codecLabel(song, showBitrate)}} -
- ); - case 'delete': return ( -
- -
- ); - default: return null; - } - })} -
- ))} - - )} -
+ {editingMeta && playlist && (