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 && (