import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, search, setRating, star, unstar, getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong, } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { usePlaylistStore } from '../store/playlistStore'; import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; import { useDragDrop } from '../contexts/DragDropContext'; import CachedImage, { useCachedUrl } from '../components/CachedImage'; import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/toast'; import StarRating from '../components/StarRating'; function sanitizeFilename(name: string): string { return name .replace(/[/\\?%*:|"<>]/g, '-') .replace(/\.{2,}/g, '.') .replace(/^[\s.]+|[\s.]+$/g, '') .substring(0, 200) || 'download'; } 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(' · '); } // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false }, { key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false }, { key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false }, { key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false }, { key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true }, ]; const PL_CENTERED = new Set(['favorite', 'rating', 'duration']); export default function PlaylistDetail() { const { id } = useParams<{ id: string }>(); const { t } = useTranslation(); const navigate = useNavigate(); const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore( useShallow(s => ({ playTrack: s.playTrack, enqueue: s.enqueue, openContextMenu: s.openContextMenu, currentTrack: s.currentTrack, isPlaying: s.isPlaying, starredOverrides: s.starredOverrides, setStarredOverride: s.setStarredOverride, userRatingOverrides: s.userRatingOverrides, })) ); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const { startDrag, isDragging } = useDragDrop(); const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore(); const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const downloadFolder = useAuthStore(s => s.downloadFolder); const setDownloadFolder = useAuthStore(s => s.setDownloadFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const [playlist, setPlaylist] = useState(null); const [songs, setSongs] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [ratings, setRatings] = useState>({}); const [editingMeta, setEditingMeta] = useState(false); const [customCoverId, setCustomCoverId] = useState(null); const [starredSongs, setStarredSongs] = useState>(new Set()); const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const [downloadProgress, setDownloadProgress] = useState(null); // ── 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 prevCount = songs.length; const next = songs.filter(s => !selectedIds.has(s.id)); setSongs(next); savePlaylist(next, prevCount); 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 effectiveBgId = customCoverId ?? coverQuad[0] ?? ''; const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]); const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]); const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey); const customCoverFetchUrl = useMemo( () => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null, [customCoverId], ); const customCoverCacheKey = useMemo( () => customCoverId ? coverArtCacheKey(customCoverId, 300) : null, [customCoverId], ); // 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); // ── Column resize/visibility ────────────────────────────────────────────── const { colVisible, visibleCols, gridStyle, startResize, toggleColumn, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns'); // DnD 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); if (playlist.coverArt) setCustomCoverId(playlist.coverArt); 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[], prevCount = 0) => { if (!id) return; setSaving(true); try { await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount); if (id) touchPlaylist(id); } catch {} setSaving(false); }, [id, touchPlaylist]); // ── Meta edit ───────────────────────────────────────────────── const handleSaveMeta = async (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean; }) => { if (!id || !playlist) return; await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic); setPlaylist(p => p ? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic } : p ); if (opts.coverFile) { try { await uploadPlaylistCoverArt(id, opts.coverFile); const { playlist: refreshed } = await getPlaylist(id); setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev); if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt); showToast(t('playlists.coverUpdated')); } catch (err) { showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error'); } } else if (opts.coverRemoved) { setCustomCoverId(null); } showToast(t('playlists.metaSaved')); setEditingMeta(false); }; // ── ZIP Download ────────────────────────────────────────────── const handleDownload = async () => { if (!playlist || !id) return; const folder = downloadFolder || await requestDownloadFolder(); if (!folder) return; setDownloadProgress(0); try { const url = buildDownloadUrl(id); const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const contentLength = response.headers.get('Content-Length'); const total = contentLength ? parseInt(contentLength, 10) : 0; const chunks: Uint8Array[] = []; if (total && response.body) { const reader = response.body.getReader(); let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.length; setDownloadProgress(Math.round((received / total) * 100)); } } else { const buffer = await response.arrayBuffer() as ArrayBuffer; chunks.push(new Uint8Array(buffer)); setDownloadProgress(100); } const blob = new Blob(chunks); const buffer = await blob.arrayBuffer(); const path = await join(folder, `${sanitizeFilename(playlist.name)}.zip`); await writeFile(path, new Uint8Array(buffer)); } catch (e) { console.error('Download failed:', e); setDownloadProgress(null); } finally { setTimeout(() => setDownloadProgress(null), 60000); } }; // ── Remove ──────────────────────────────────────────────────── const removeSong = (idx: number) => { const prevCount = songs.length; const next = songs.filter((_, i) => i !== idx); setSongs(next); savePlaylist(next, prevCount); }; // ── 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 })); usePlayerStore.getState().setUserRatingOverride(songId, rating); setRating(songId, rating).catch(() => {}); }; const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => { e.stopPropagation(); const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id); setStarredSongs(prev => { const next = new Set(prev); isStarred ? next.delete(song.id) : next.add(song.id); return next; }); setStarredOverride(song.id, !isStarred); (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); }; // ── Memoized derivations ────────────────────────────────────── const existingIds = useMemo(() => new Set(songs.map(s => s.id)), [songs]); const tracks = useMemo(() => songs.map(songToTrack), [songs]); // ── 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')}
; } return (
{/* ── Hero ── */}
{resolvedBgUrl && (