"use client"; import { useState, useMemo, useRef, useEffect } from "react"; import { useParams, useRouter } from "next/navigation"; import Image from "next/image"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { api } from "@/lib/api"; import { useAudio } from "@/lib/audio-context"; import { cn } from "@/utils/cn"; import { usePlaylistQuery } from "@/hooks/useQueries"; import { useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/lib/toast-context"; import { GradientSpinner } from "@/components/ui/GradientSpinner"; import { Play, Pause, Trash2, Shuffle, EyeOff, ListPlus, ListMusic, Music, Clock, Volume2, RefreshCw, AlertCircle, X, Loader2, } from "lucide-react"; interface Track { id: string; title: string; duration: number; album: { id?: string; title: string; coverArt?: string; artist: { id?: string; name: string; }; }; } interface PlaylistItem { id: string; track: Track; type?: "track"; sort?: number; } interface PendingTrack { id: string; type: "pending"; sort: number; pending: { id: string; artist: string; title: string; album: string; previewUrl: string | null; }; } export default function PlaylistDetailPage() { const params = useParams(); const router = useRouter(); const queryClient = useQueryClient(); const { toast } = useToast(); const { playTracks, addToQueue, currentTrack, isPlaying, pause, resume } = useAudio(); const playlistId = params.id as string; const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isHiding, setIsHiding] = useState(false); const [playingPreviewId, setPlayingPreviewId] = useState( null ); const [retryingTrackId, setRetryingTrackId] = useState(null); const [removingTrackId, setRemovingTrackId] = useState(null); const previewAudioRef = useRef(null); // Clean up preview audio on unmount useEffect(() => { return () => { if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null; } }; }, []); // Handle Deezer preview playback const handlePlayPreview = async (pendingId: string) => { // If already playing this preview, stop it if (playingPreviewId === pendingId && previewAudioRef.current) { previewAudioRef.current.pause(); setPlayingPreviewId(null); return; } // Stop any currently playing preview if (previewAudioRef.current) { previewAudioRef.current.pause(); } // Show loading state setPlayingPreviewId(pendingId); try { // Always fetch a fresh preview URL since Deezer URLs expire quickly const result = await api.getFreshPreviewUrl(playlistId, pendingId); const previewUrl = result.previewUrl; // Create and play new audio const audio = new Audio(previewUrl); audio.volume = 0.5; audio.onended = () => setPlayingPreviewId(null); audio.onerror = (e) => { console.error("Deezer preview playback failed:", e); setPlayingPreviewId(null); toast.error("Preview playback failed"); }; previewAudioRef.current = audio; await audio.play(); } catch (err) { console.error("Failed to play Deezer preview:", err); setPlayingPreviewId(null); toast.error("No preview available"); } }; // Handle retry download for pending track const handleRetryPendingTrack = async (pendingId: string) => { setRetryingTrackId(pendingId); try { const result = await api.retryPendingTrack(playlistId, pendingId); if (result.success) { // Use the activity sidebar (Active tab) instead of a toast/modal window.dispatchEvent( new CustomEvent("set-activity-panel-tab", { detail: { tab: "active" }, }) ); window.dispatchEvent(new CustomEvent("open-activity-panel")); // If the backend emits a scan/download notification, refresh it window.dispatchEvent(new CustomEvent("notifications-changed")); // Refresh playlist data after a delay to allow download + scan to complete setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["playlist", playlistId], }); }, 10000); // 10 seconds for download + scan } else { toast.error(result.message || "Track not found on Soulseek"); } } catch (error) { console.error("Failed to retry download:", error); toast.error("Failed to retry download"); } finally { setRetryingTrackId(null); } }; // Handle remove pending track const handleRemovePendingTrack = async (pendingId: string) => { setRemovingTrackId(pendingId); try { await api.removePendingTrack(playlistId, pendingId); // Refresh playlist data queryClient.invalidateQueries({ queryKey: ["playlist", playlistId], }); } catch (error) { console.error("Failed to remove pending track:", error); } finally { setRemovingTrackId(null); } }; // Use React Query hook for playlist const { data: playlist, isLoading } = usePlaylistQuery(playlistId); // Check if this is a shared playlist const isShared = playlist?.isOwner === false; const handleToggleHide = async () => { if (!playlist) return; setIsHiding(true); try { if (playlist.isHidden) { await api.unhidePlaylist(playlistId); } else { await api.hidePlaylist(playlistId); } // Dispatch event to update sidebar and other components window.dispatchEvent( new CustomEvent("playlist-updated", { detail: { playlistId } }) ); // Optionally navigate away if hiding if (!playlist.isHidden) { router.push("/playlists"); } } catch (error) { console.error("Failed to toggle playlist visibility:", error); } finally { setIsHiding(false); } }; // Calculate cover arts from playlist tracks for mosaic (memoized) const coverUrls = useMemo(() => { if (!playlist?.items || playlist.items.length === 0) return []; const tracksWithCovers = playlist.items.filter( (item: PlaylistItem) => item.track.album?.coverArt ); if (tracksWithCovers.length === 0) return []; // Get unique cover arts (up to 4) const uniqueCovers = Array.from( new Set(tracksWithCovers.map((item) => item.track.album.coverArt)) ).slice(0, 4); return uniqueCovers; }, [playlist]); const handleRemoveTrack = async (trackId: string) => { try { await api.removeTrackFromPlaylist(playlistId, trackId); // Track disappearing from list is feedback enough } catch (error) { console.error("Failed to remove track:", error); } }; const handleDeletePlaylist = async () => { try { await api.deletePlaylist(playlistId); // Dispatch event to update sidebar window.dispatchEvent( new CustomEvent("playlist-deleted", { detail: { playlistId } }) ); router.push("/playlists"); } catch (error) { console.error("Failed to delete playlist:", error); } }; // Check if this playlist is currently playing const playlistTrackIds = useMemo(() => { return new Set( playlist?.items?.map((item: PlaylistItem) => item.track.id) || [] ); }, [playlist?.items]); const isThisPlaylistPlaying = useMemo(() => { if (!isPlaying || !currentTrack || !playlist?.items?.length) return false; // Check if current track is in this playlist return playlistTrackIds.has(currentTrack.id); }, [isPlaying, currentTrack, playlistTrackIds, playlist?.items?.length]); // Calculate total duration - MUST be before early returns const totalDuration = useMemo(() => { if (!playlist?.items) return 0; return playlist.items.reduce( (sum: number, item: PlaylistItem) => sum + (item.track.duration || 0), 0 ); }, [playlist?.items]); const formatTotalDuration = (seconds: number) => { const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); if (hours > 0) { return `about ${hours} hr ${mins} min`; } return `${mins} min`; }; const handlePlayPlaylist = () => { if (!playlist?.items || playlist.items.length === 0) return; // If this playlist is playing, toggle pause/resume if (isThisPlaylistPlaying) { if (isPlaying) { pause(); } else { resume(); } return; } const tracks = playlist.items.map((item: PlaylistItem) => ({ id: item.track.id, title: item.track.title, artist: { name: item.track.album.artist.name, id: item.track.album.artist.id, }, album: { title: item.track.album.title, coverArt: item.track.album.coverArt, id: item.track.album.id, }, duration: item.track.duration, })); playTracks(tracks, 0); }; const handlePlayTrack = (index: number) => { if (!playlist?.items || playlist.items.length === 0) return; const tracks = playlist.items.map((item: PlaylistItem) => ({ id: item.track.id, title: item.track.title, artist: { name: item.track.album.artist.name, id: item.track.album.artist.id, }, album: { title: item.track.album.title, coverArt: item.track.album.coverArt, id: item.track.album.id, }, duration: item.track.duration, })); playTracks(tracks, index); }; const handleAddToQueue = (track: Track) => { const formattedTrack = { id: track.id, title: track.title, artist: { name: track.album.artist.name, id: track.album.artist.id, }, album: { title: track.album.title, coverArt: track.album.coverArt, id: track.album.id, }, duration: track.duration, }; addToQueue(formattedTrack); }; const formatDuration = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; }; if (isLoading) { return (
); } if (!playlist) { return (

Playlist not found

); } return (
{/* Compact Hero - Spotify Style */}
{/* Cover Art */}
{coverUrls && coverUrls.length > 0 ? (
{coverUrls .slice(0, 4) .map( ( url: string | undefined, index: number ) => { if (!url) return null; const proxiedUrl = api.getCoverArtUrl(url, 200); return (
); } )} {Array.from({ length: Math.max( 0, 4 - (coverUrls?.length || 0) ), }).map((_, index) => (
))}
) : (
)}
{/* Playlist Info - Bottom Aligned */}

{isShared ? "Public Playlist" : "Playlist"}

{playlist.name}

{isShared && playlist.user?.username && ( <> {playlist.user.username} )} {playlist.items?.length || 0} songs {totalDuration > 0 && ( <> , {formatTotalDuration(totalDuration)} )}
{/* Action Bar */}
{/* Play Button */} {playlist.items && playlist.items.length > 0 && ( )} {/* Shuffle Button */} {playlist.items && playlist.items.length > 1 && ( )} {/* Spacer */}
{/* Hide Button */} {/* Delete Button */} {playlist.isOwner && ( )}
{/* Track Listing */}
{/* Show failed/pending count if any */} {playlist.pendingCount > 0 && (
{playlist.pendingCount} track {playlist.pendingCount !== 1 ? "s" : ""} failed to download - will auto-import when available
)} {playlist.items?.length > 0 || playlist.pendingTracks?.length > 0 ? (
{/* Table Header */}
# Title Album Duration
{/* Track Rows - use mergedItems to show tracks and pending in correct order */}
{(playlist.mergedItems || playlist.items || []).map( ( item: PlaylistItem | PendingTrack, index: number ) => { // Handle pending/failed tracks if (item.type === "pending") { const pending = (item as PendingTrack) .pending; const isPreviewPlaying = playingPreviewId === pending.id; const isRetrying = retryingTrackId === pending.id; const isRemoving = removingTrackId === pending.id; return (
{/* Track Number - failed icon */}
{/* Title + Artist */}

{pending.title}

{pending.artist}

{/* Album (hidden on mobile) */}

{pending.album}

{/* Actions: Retry + Remove */}
Failed {/* Retry button */} {/* Remove button */} {playlist.isOwner && ( )}
); } // Handle regular tracks const playlistItem = item as PlaylistItem; const isCurrentlyPlaying = currentTrack?.id === playlistItem.track.id; // Calculate the index for playback (only count actual tracks) const trackIndex = playlist.items?.findIndex( (i: PlaylistItem) => i.id === playlistItem.id ) ?? index; return (
handlePlayTrack(trackIndex) } className={cn( "grid grid-cols-[40px_1fr_auto] md:grid-cols-[40px_minmax(200px,4fr)_minmax(100px,1fr)_80px] gap-4 px-4 py-2 rounded-md hover:bg-white/5 transition-colors group cursor-pointer", isCurrentlyPlaying && "bg-white/10" )} > {/* Track Number / Play Icon */}
{isCurrentlyPlaying && isPlaying ? ( ) : ( trackIndex + 1 )}
{/* Title + Artist */}
{playlistItem.track.album ?.coverArt ? ( { ) : (
)}

{ playlistItem.track .title }

{ playlistItem.track .album.artist .name }

{/* Album (hidden on mobile) */}

{playlistItem.track.album.title}

{/* Duration + Actions */}
{formatDuration( playlistItem.track .duration )} {playlist.isOwner && ( )}
); } )}
) : (

No tracks yet

Add some tracks to get started

)}
{/* Confirm Dialog */} setShowDeleteConfirm(false)} onConfirm={handleDeletePlaylist} title="Delete Playlist?" message={`Are you sure you want to delete "${playlist.name}"? This action cannot be undone.`} confirmText="Delete" cancelText="Cancel" variant="danger" />
); }