import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react'; import { getPlaylists, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlaylistStore } from '../store/playlistStore'; import CachedImage from '../components/CachedImage'; import { useTranslation } from 'react-i18next'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import { showToast } from '../utils/toast'; function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); } export default function Playlists() { const { t } = useTranslation(); const navigate = useNavigate(); const playTrack = usePlayerStore(s => s.playTrack); const openContextMenu = usePlayerStore(s => s.openContextMenu); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const removeId = usePlaylistStore((s) => s.removeId); const playlists = usePlaylistStore((s) => s.playlists); const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists); const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); const [playingId, setPlayingId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const nameInputRef = useRef(null); // ── Multi-selection ────────────────────────────────────────────────────── const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); }; const toggleSelect = useCallback((id: string) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); }; const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id)); useEffect(() => { fetchPlaylists().finally(() => setLoading(false)); }, [fetchPlaylists]); useEffect(() => { if (creating) nameInputRef.current?.focus(); }, [creating]); const createPlaylist = usePlaylistStore(s => s.createPlaylist); const handleCreate = async () => { const name = newName.trim() || t('playlists.unnamed'); await createPlaylist(name); // Refresh playlists from API to get the new one await fetchPlaylists(); setCreating(false); setNewName(''); }; const handlePlay = async (e: React.MouseEvent, pl: SubsonicPlaylist) => { e.stopPropagation(); if (playingId === pl.id) return; setPlayingId(pl.id); try { const data = await getPlaylist(pl.id); const tracks = data.songs.map(songToTrack); if (tracks.length > 0) { touchPlaylist(pl.id); playTrack(tracks[0], tracks); } } catch {} setPlayingId(null); }; const handleDelete = async (e: React.MouseEvent, pl: SubsonicPlaylist) => { e.stopPropagation(); if (deleteConfirmId !== pl.id) { setDeleteConfirmId(pl.id); return; } try { await deletePlaylist(pl.id); removeId(pl.id); usePlaylistStore.setState((s) => ({ playlists: s.playlists.filter((p) => p.id !== pl.id), })); showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info'); } catch { showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); } setDeleteConfirmId(null); }; const handleDeleteSelected = async () => { if (selectedPlaylists.length === 0) return; let deleted = 0; for (const pl of selectedPlaylists) { try { await deletePlaylist(pl.id); removeId(pl.id); deleted++; } catch { showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error'); } } usePlaylistStore.setState((s) => ({ playlists: s.playlists.filter((p) => !selectedIds.has(p.id)), })); clearSelection(); if (deleted > 0) { showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info'); } }; const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => { if (selectedPlaylists.length === 0) return; try { const { songs: targetSongs } = await getPlaylist(targetPlaylist.id); const targetIds = new Set(targetSongs.map(s => s.id)); let totalAdded = 0; for (const pl of selectedPlaylists) { if (pl.id === targetPlaylist.id) continue; const { songs } = await getPlaylist(pl.id); const newSongs = songs.filter(s => !targetIds.has(s.id)); if (newSongs.length > 0) { newSongs.forEach(s => targetIds.add(s.id)); totalAdded += newSongs.length; } } if (totalAdded > 0) { await updatePlaylist(targetPlaylist.id, Array.from(targetIds)); touchPlaylist(targetPlaylist.id); showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info'); } else { showToast(t('playlists.mergeNoNewSongs'), 3000, 'info'); } clearSelection(); } catch { showToast(t('playlists.mergeError'), 4000, 'error'); } }; if (loading) { return (
); } return (
{/* ── Header row ── */}

{selectionMode && selectedIds.size > 0 ? t('playlists.selectionCount', { count: selectedIds.size }) : t('playlists.title')}

{!(selectionMode && selectedIds.size > 0) && (<> {creating ? ( <> setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') { setCreating(false); setNewName(''); } }} /> ) : ( )} )}
{/* ── Grid ── */} {playlists.length === 0 ? (
{t('playlists.empty')}
) : (
{playlists.map((pl) => (
{ if (selectionMode) { toggleSelect(pl.id); } else { navigate(`/playlists/${pl.id}`); } }} onContextMenu={(e) => { e.preventDefault(); if (selectionMode && selectedIds.size > 0) { openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist'); } else { openContextMenu(e.clientX, e.clientY, pl, 'playlist'); } }} onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }} style={selectionMode && selectedIds.has(pl.id) ? { outline: '2px solid var(--accent)', outlineOffset: '2px', borderRadius: 'var(--radius-md)' } : {}} > {selectionMode && (
{selectedIds.has(pl.id) && }
)} {/* Cover area — server collage or fallback icon */}
{pl.coverArt ? ( ) : (
)} {/* Play overlay — same pattern as AlbumCard */}
{pl.name}
{t('playlists.songs', { n: pl.songCount })} {pl.duration > 0 && <> · {formatDuration(pl.duration)}}
))}
)}
); }