import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle } from 'lucide-react'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { getPlaylist, updatePlaylist, search, setRating, star, unstar, getRandomSongs, SubsonicPlaylist, SubsonicSong, } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlaylistStore } from '../store/playlistStore'; import { useDragDrop } from '../contexts/DragDropContext'; import CachedImage, { useCachedUrl } from '../components/CachedImage'; import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${String(s).padStart(2, '0')}`; } function totalDurationLabel(songs: SubsonicSong[]): string { const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); return h > 0 ? `${h}h ${m}m` : `${m}m`; } function codecLabel(song: SubsonicSong): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); if (song.bitRate) parts.push(`${song.bitRate} kbps`); return parts.join(' · '); } function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) { const [hover, setHover] = React.useState(0); return (
{[1, 2, 3, 4, 5].map(n => ( ))}
); } export default function PlaylistDetail() { const { id } = useParams<{ id: string }>(); const { t } = useTranslation(); const navigate = useNavigate(); const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore(); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const { startDrag, isDragging } = useDragDrop(); const [playlist, setPlaylist] = useState(null); const [songs, setSongs] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [ratings, setRatings] = useState>({}); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSongId, setHoveredSongId] = useState(null); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); // ── Bulk select ─────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedIdx, setLastSelectedIdx] = useState(null); const [showBulkPlPicker, setShowBulkPlPicker] = useState(false); const toggleSelect = (id: string, idx: number, shift: boolean) => { setSelectedIds(prev => { const next = new Set(prev); if (shift && lastSelectedIdx !== null) { const from = Math.min(lastSelectedIdx, idx); const to = Math.max(lastSelectedIdx, idx); songs.slice(from, to + 1).forEach(s => next.add(s.id)); } else { next.has(id) ? next.delete(id) : next.add(id); } return next; }); setLastSelectedIdx(idx); }; const allSelected = selectedIds.size === songs.length && songs.length > 0; const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id))); const bulkRemove = () => { const next = songs.filter(s => !selectedIds.has(s.id)); setSongs(next); savePlaylist(next); setSelectedIds(new Set()); }; useEffect(() => { if (!showBulkPlPicker) return; const handler = (e: MouseEvent) => { if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowBulkPlPicker(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showBulkPlPicker]); // ── 2×2 cover quad (first 4 unique album covers) ───────────── const coverQuad = useMemo(() => { const seen = new Set(); const result: string[] = []; for (const s of songs) { if (s.coverArt && !seen.has(s.coverArt)) { seen.add(s.coverArt); result.push(s.coverArt); if (result.length === 4) break; } } return result; }, [songs]); // Stable fetch URLs + cache keys for the 2×2 grid and blurred background. // buildCoverArtUrl generates a new crypto salt on every call, so these MUST // be memoized — otherwise every render produces new URLs, useCachedUrl // re-triggers, state updates, another render → infinite flicker loop. const coverQuadUrls = useMemo(() => Array.from({ length: 4 }, (_, i) => { const coverId = coverQuad[i % Math.max(1, coverQuad.length)]; if (!coverId) return null; return { src: buildCoverArtUrl(coverId, 200), cacheKey: coverArtCacheKey(coverId, 200) }; }), [coverQuad]); const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]); const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]); const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey, false); // Song search const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const searchDebounce = useRef | null>(null); // Suggestions const [suggestions, setSuggestions] = useState([]); const [loadingSuggestions, setLoadingSuggestions] = useState(false); // DnD const tracklistRef = useRef(null); const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null); useEffect(() => { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); // ── Load ───────────────────────────────────────────────────── useEffect(() => { if (!id) return; setLoading(true); getPlaylist(id) .then(({ playlist, songs }) => { setPlaylist(playlist); setSongs(songs); const init: Record = {}; const starred = new Set(); songs.forEach(s => { if (s.userRating) init[s.id] = s.userRating; if (s.starred) starred.add(s.id); }); setRatings(init); setStarredSongs(starred); }) .catch(() => {}) .finally(() => setLoading(false)); }, [id]); // ── Suggestions ─────────────────────────────────────────────── const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => { if (!currentSongs.length) return; // Count genres across playlist songs, pick the most common one const genreCounts: Record = {}; for (const s of currentSongs) { if (s.genre) genreCounts[s.genre] = (genreCounts[s.genre] ?? 0) + 1; } const genres = Object.entries(genreCounts).sort((a, b) => b[1] - a[1]); // Fall back to no genre filter if none of the songs have genre tags const genre = genres.length > 0 ? genres[Math.floor(Math.random() * Math.min(3, genres.length))][0] : undefined; const existingIds = new Set(currentSongs.map(s => s.id)); setLoadingSuggestions(true); setSuggestions([]); try { const random = await getRandomSongs(25, genre); setSuggestions(random.filter(s => !existingIds.has(s.id)).slice(0, 10)); } catch {} setLoadingSuggestions(false); }, []); useEffect(() => { if (songs.length > 0) loadSuggestions(songs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [playlist?.id]); // ── Save ────────────────────────────────────────────────────── const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => { if (!id) return; setSaving(true); try { await updatePlaylist(id, updatedSongs.map(s => s.id)); if (id) touchPlaylist(id); } catch {} setSaving(false); }, [id, touchPlaylist]); // ── Remove ──────────────────────────────────────────────────── const removeSong = (idx: number) => { const next = songs.filter((_, i) => i !== idx); setSongs(next); savePlaylist(next); }; // ── Add ─────────────────────────────────────────────────────── const addSong = (song: SubsonicSong) => { if (songs.some(s => s.id === song.id)) return; const next = [...songs, song]; setSongs(next); savePlaylist(next); setSuggestions(prev => prev.filter(s => s.id !== song.id)); setSearchResults(prev => prev.filter(s => s.id !== song.id)); }; // ── Rating / Star ───────────────────────────────────────────── const handleRate = (songId: string, rating: number) => { setRatings(prev => ({ ...prev, [songId]: rating })); setRating(songId, rating).catch(() => {}); }; const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => { e.stopPropagation(); const isStarred = starredSongs.has(song.id); setStarredSongs(prev => { const next = new Set(prev); isStarred ? next.delete(song.id) : next.add(song.id); return next; }); (isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {}); }; // ── Search ──────────────────────────────────────────────────── useEffect(() => { if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; } if (searchDebounce.current) clearTimeout(searchDebounce.current); searchDebounce.current = setTimeout(async () => { setSearching(true); try { const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 }); const existingIds = new Set(songs.map(s => s.id)); setSearchResults(res.songs.filter(s => !existingIds.has(s.id))); } catch {} setSearching(false); }, 350); return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); }; }, [searchQuery, searchOpen, songs]); // ── psy-drop DnD reordering ─────────────────────────────────── useEffect(() => { const container = tracklistRef.current; if (!container) return; const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.data) return; let parsed: any; try { parsed = JSON.parse(detail.data); } catch { return; } if (parsed.type !== 'playlist_reorder') return; setDropTargetIdx(null); const fromIdx: number = parsed.index; // Determine drop index from the event target row const target = (e.target as HTMLElement).closest('[data-track-idx]'); let toIdx = songs.length; if (target) { const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10); const rect = target.getBoundingClientRect(); const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2); const before = cursorY < rect.top + rect.height / 2; toIdx = before ? targetIdx : targetIdx + 1; } if (fromIdx === toIdx || fromIdx === toIdx - 1) return; setSongs(prev => { const next = [...prev]; const [moved] = next.splice(fromIdx, 1); const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx; next.splice(insertAt, 0, moved); savePlaylist(next); return next; }); }; container.addEventListener('psy-drop', onPsyDrop); return () => container.removeEventListener('psy-drop', onPsyDrop); }, [songs, savePlaylist]); // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest('button, input')) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; const onMove = (me: MouseEvent) => { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); startDrag( { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' }, me.clientX, me.clientY ); } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; // ── Drag-over visual feedback ───────────────────────────────── const handleRowMouseEnter = (idx: number, e: React.MouseEvent) => { if (!isDragging) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const before = e.clientY < rect.top + rect.height / 2; setDropTargetIdx({ idx, before }); }; // ── Render ──────────────────────────────────────────────────── if (loading) { return (
); } if (!playlist) { return
{t('playlists.notFound')}
; } const existingIds = new Set(songs.map(s => s.id)); return (
{/* ── Hero ── */}
{resolvedBgUrl && (