import React, { useState, useRef, useEffect, useCallback, useMemo, memo, startTransition } from 'react'; import { Box, Flex, Text, Button, IconButton, HStack, Checkbox, Spinner, useDisclosure, Input, InputGroup, InputLeftElement, } from '@chakra-ui/react'; import { Search2Icon } from '@chakra-ui/icons'; import { FiPlay } from 'react-icons/fi'; import type { Song, PlaylistNode } from '../types/interfaces'; import { api } from '../services/api'; import { formatDuration, formatTotalDuration } from '../utils/formatters'; import { useDebounce } from '../hooks/useDebounce'; import { PlaylistSelectionModal } from './PlaylistSelectionModal'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { VirtualItem } from '@tanstack/react-virtual'; interface PaginatedSongListProps { songs: Song[]; onAddToPlaylist: (songIds: string[], playlistName: string) => void; onRemoveFromPlaylist?: (songIds: string[]) => void; playlists: PlaylistNode[]; onSongSelect: (song: Song) => void; selectedSongId: string | null; currentPlaylist: string | null; loading: boolean; hasMore: boolean; totalSongs: number; totalPlaylistDuration?: string; // Total duration of the entire playlist onLoadMore: () => void; onSearch: (query: string) => void; searchQuery: string; depth?: number; isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching onPlaySong?: (song: Song) => void; // New prop for playing songs onReorder?: (orderedIds: string[]) => Promise; } // Memoized song item component to prevent unnecessary re-renders const SongItem = memo<{ song: Song; isSelected: boolean; isHighlighted: boolean; onSelect: (song: Song) => void; onToggleSelection: (songId: string) => void; showCheckbox: boolean; onPlaySong?: (song: Song) => void; showDropIndicatorTop?: boolean; onDragStart?: (e: React.DragEvent, songIdsFallback: string[]) => void; onRowDragOver?: (e: React.DragEvent) => void; onRowDrop?: (e: React.DragEvent) => void; onRowDragStartCapture?: (e: React.DragEvent) => void; index: number; onCheckboxToggle?: (index: number, checked: boolean, shift: boolean) => void; }>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, showDropIndicatorTop, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture, index, onCheckboxToggle }) => { // Memoize the formatted duration to prevent recalculation const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); // Local optimistic selection for instant visual feedback const [localChecked, setLocalChecked] = useState(isSelected); useEffect(() => { setLocalChecked(isSelected); }, [isSelected]); const handleClick = useCallback(() => { onSelect(song); }, [onSelect, song]); const handleCheckboxClick = useCallback((e: React.ChangeEvent) => { e.stopPropagation(); setLocalChecked(e.target.checked); if (onCheckboxToggle) { onCheckboxToggle(index, e.target.checked, (e as any).nativeEvent?.shiftKey === true || (e as any).shiftKey === true); } else { onToggleSelection(song.id); } }, [onCheckboxToggle, index, onToggleSelection, song.id]); const handlePlayClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (onPlaySong && (song.s3File?.hasS3File || song.hasMusicFile)) { onPlaySong(song); } }, [onPlaySong, song]); const hasMusicFile = (song: Song): boolean => { return song.s3File?.hasS3File || song.hasMusicFile || false; }; return ( onDragStart(e, [song.id]) } : {})} {...(onRowDragOver ? { onDragOver: onRowDragOver } : {})} {...(onRowDrop ? { onDrop: onRowDrop } : {})} {...(onRowDragStartCapture ? { onDragStartCapture: onRowDragStartCapture } : {})} position="relative" > {showDropIndicatorTop && ( )} {showCheckbox && ( e.stopPropagation()} /> )} {song.title} {song.artist} {formattedDuration} {song.averageBpm} BPM {hasMusicFile(song) && onPlaySong && ( } size="sm" variant="ghost" colorScheme="blue" onClick={handlePlayClick} ml={2} _hover={{ bg: "blue.900" }} /> )} ); }); SongItem.displayName = 'SongItem'; export const PaginatedSongList: React.FC = memo(({ songs, onAddToPlaylist, onRemoveFromPlaylist, playlists, onSongSelect, selectedSongId, currentPlaylist, loading, hasMore, totalSongs, totalPlaylistDuration, onLoadMore, onSearch, searchQuery, depth = 0, isSwitchingPlaylist = false, onPlaySong, onReorder }) => { const [selectedSongs, setSelectedSongs] = useState>(new Set()); const dragSelectionRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure(); const observerRef = useRef(null); const loadingRef = useRef(null); const scrollContainerRef = useRef(null); const isTriggeringRef = useRef(false); const timeoutRef = useRef(null); const [dragHoverIndex, setDragHoverIndex] = useState(null); const [endDropHover, setEndDropHover] = useState(false); const [isReorderDragging, setIsReorderDragging] = useState(false); const lastSelectedIndexRef = useRef(null); const dragPreviewRef = useRef(null); // Store current values in refs to avoid stale closures const hasMoreRef = useRef(hasMore); const loadingRef_state = useRef(loading); const onLoadMoreRef = useRef(onLoadMore); // Update refs when props change useEffect(() => { hasMoreRef.current = hasMore; loadingRef_state.current = loading; onLoadMoreRef.current = onLoadMore; }, [hasMore, loading, onLoadMore]); // Debounce search to prevent excessive API calls const debouncedSearchQuery = useDebounce(localSearchQuery, 300); // Memoized helper function to get all playlists (excluding folders) from the playlist tree const getAllPlaylists = useCallback((nodes: PlaylistNode[]): PlaylistNode[] => { if (!nodes || nodes.length === 0) return []; let result: PlaylistNode[] = []; for (const node of nodes) { if (node.type === 'playlist') { result.push(node); } else if (node.type === 'folder' && node.children) { result = result.concat(getAllPlaylists(node.children)); } } return result; }, []); // Memoized flattened list of all playlists // const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]); const toggleSelection = useCallback((songId: string) => { startTransition(() => { setSelectedSongs(prev => { const newSelection = new Set(prev); if (newSelection.has(songId)) { newSelection.delete(songId); } else { newSelection.add(songId); } return newSelection; }); }); }, []); // Range selection using shift-click between last selected and current const toggleSelectionRange = useCallback((fromIndex: number, toIndex: number, checked: boolean) => { if (fromIndex === null || toIndex === null) return; const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex]; startTransition(() => { setSelectedSongs(prev => { const next = new Set(prev); for (let i = start; i <= end; i++) { const id = songs[i]?.id; if (!id) continue; if (checked) next.add(id); else next.delete(id); } return next; }); }); }, [songs]); const handleCheckboxToggle = useCallback((index: number, checked: boolean, shift: boolean) => { const song = songs[index]; if (!song) return; if (shift && lastSelectedIndexRef.current !== null) { toggleSelectionRange(lastSelectedIndexRef.current, index, checked); } else { toggleSelection(song.id); } lastSelectedIndexRef.current = index; }, [songs, toggleSelection, toggleSelectionRange]); const toggleSelectAll = useCallback(() => { startTransition(() => { setSelectedSongs(prev => { const noneSelected = prev.size === 0; const allSelected = prev.size === songs.length && songs.length > 0; if (noneSelected) { return new Set(songs.map(s => s.id)); } if (allSelected) { return new Set(); } return new Set(songs.map(s => s.id)); }); }); }, [songs]); const handleBulkAddToPlaylist = useCallback((playlistName: string) => { if (selectedSongs.size > 0) { onAddToPlaylist(Array.from(selectedSongs), playlistName); setSelectedSongs(new Set()); // Clear selection after action } }, [selectedSongs, onAddToPlaylist]); const handleBulkRemoveFromPlaylist = useCallback(() => { if (selectedSongs.size > 0 && onRemoveFromPlaylist) { onRemoveFromPlaylist(Array.from(selectedSongs)); setSelectedSongs(new Set()); // Clear selection after action } }, [selectedSongs, onRemoveFromPlaylist]); // Memoized song selection handler const handleSongSelect = useCallback((song: Song) => { onSongSelect(song); }, [onSongSelect]); // Memoized search handler with debouncing // Search handled inline via localSearchQuery effect // Provide drag payload: if multiple selected, drag all; else drag the single item const handleDragStart = useCallback((e: React.DragEvent, songIdsFallback: string[]) => { const ids = selectedSongs.size > 0 ? Array.from(selectedSongs) : songIdsFallback; dragSelectionRef.current = ids; setIsDragging(true); setIsReorderDragging(true); const payload = { type: 'songs', songIds: ids, count: ids.length }; e.dataTransfer.setData('application/json', JSON.stringify(payload)); e.dataTransfer.setData('text/plain', JSON.stringify(payload)); e.dataTransfer.effectAllowed = 'copyMove'; // Create a custom drag image with count badge when dragging multiple try { const count = ids.length; if (count >= 1) { // Build a lightweight preview element const preview = document.createElement('div'); preview.style.position = 'fixed'; preview.style.top = '-1000px'; preview.style.left = '-1000px'; preview.style.pointerEvents = 'none'; preview.style.padding = '6px 10px'; preview.style.borderRadius = '8px'; preview.style.background = 'rgba(26, 32, 44, 0.95)'; // gray.900 preview.style.color = '#E2E8F0'; // gray.200 preview.style.fontSize = '12px'; preview.style.fontWeight = '600'; preview.style.display = 'inline-flex'; preview.style.alignItems = 'center'; preview.style.gap = '8px'; preview.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)'; const dot = document.createElement('div'); dot.style.background = '#3182CE'; // blue.500 dot.style.color = 'white'; dot.style.minWidth = '20px'; dot.style.height = '20px'; dot.style.borderRadius = '10px'; dot.style.display = 'flex'; dot.style.alignItems = 'center'; dot.style.justifyContent = 'center'; dot.style.fontSize = '12px'; dot.style.fontWeight = '700'; dot.textContent = String(count); const label = document.createElement('div'); label.textContent = count === 1 ? 'song' : 'songs'; preview.appendChild(dot); preview.appendChild(label); document.body.appendChild(preview); dragPreviewRef.current = preview; // Offset so the cursor isn't on top of the preview e.dataTransfer.setDragImage(preview, 10, 10); } } catch {} }, [selectedSongs]); const handleDragEnd = useCallback(() => { setIsDragging(false); setIsReorderDragging(false); setEndDropHover(false); setDragHoverIndex(null); dragSelectionRef.current = null; // Cleanup drag preview element if (dragPreviewRef.current && dragPreviewRef.current.parentNode) { try { dragPreviewRef.current.parentNode.removeChild(dragPreviewRef.current); } catch {} } dragPreviewRef.current = null; }, []); // Virtualizer for large lists const rowVirtualizer = useVirtualizer({ count: songs.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => 64, overscan: 8 }); // Use total playlist duration if available, otherwise calculate from current songs const totalDuration = useMemo(() => { if (totalPlaylistDuration) { return totalPlaylistDuration; } // Only calculate if we have songs and no total duration provided if (songs.length === 0) return ''; // Fallback to calculating from current songs const totalSeconds = songs.reduce((total, song) => { if (!song.totalTime) return total; const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1)); return total + seconds; }, 0); return formatTotalDuration(totalSeconds); }, [songs, totalPlaylistDuration]); // Memoized playlist options for bulk actions // Playlist options built directly in the modal // Handle debounced search useEffect(() => { if (debouncedSearchQuery !== searchQuery) { onSearch(debouncedSearchQuery); } }, [debouncedSearchQuery, searchQuery, onSearch]); // Intersection Observer for infinite scroll - optimized useEffect(() => { if (loadingRef.current) { observerRef.current = new IntersectionObserver( (entries) => { // Use current values from refs to avoid stale closure if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef_state.current && !isTriggeringRef.current) { isTriggeringRef.current = true; // Use requestAnimationFrame for better performance requestAnimationFrame(() => { onLoadMoreRef.current(); }); // Reset the flag after a short delay to prevent multiple triggers timeoutRef.current = setTimeout(() => { isTriggeringRef.current = false; }, 100); } }, { threshold: 0.1, rootMargin: '100px' // Start loading when 100px away from the bottom } ); observerRef.current.observe(loadingRef.current); } return () => { if (observerRef.current) { observerRef.current.disconnect(); } if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); // Remove dependencies to prevent recreation return ( {/* Global drag badge for selected count */} {isDragging && ( Dragging {dragSelectionRef.current?.length || 0} song{(dragSelectionRef.current?.length || 0) === 1 ? '' : 's'} )} {/* Sticky Header */} {/* Search Bar */} ) => setLocalSearchQuery(e.target.value)} bg="gray.800" borderColor="gray.600" _hover={{ borderColor: "gray.500" }} _focus={{ borderColor: "blue.300", boxShadow: "0 0 0 1px var(--chakra-colors-blue-300)" }} autoFocus /> {/* Bulk Actions Toolbar */} 0} isIndeterminate={selectedSongs.size > 0 && selectedSongs.size < songs.length} onChange={toggleSelectAll} colorScheme="blue" sx={{ '& > span:first-of-type': { opacity: 1, border: '2px solid', borderColor: 'gray.500' } }} > {selectedSongs.size === 0 ? "Select All" : `Selected ${selectedSongs.size} song${selectedSongs.size === 1 ? '' : 's'}`} {isSwitchingPlaylist ? ( <> 0 of 0 songs • Switching playlist... ) : ( <> {songs.length} of {totalSongs} songs • {totalDuration} {hasMore && songs.length > 0 && ( • Scroll for more )} )} {selectedSongs.size > 0 && ( {currentPlaylist !== "All Songs" && onRemoveFromPlaylist && ( )} )} {/* Scrollable Song List (virtualized) */} {rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => { const index = virtualRow.index; const song = songs[index]; const allowReorder = Boolean(onReorder && currentPlaylist); return ( {song && ( 0 || depth === 0} index={index} onCheckboxToggle={handleCheckboxToggle} onPlaySong={onPlaySong} showDropIndicatorTop={dragHoverIndex === index} onDragStart={handleDragStart} onRowDragOver={allowReorder ? ((e: React.DragEvent) => { if (!onReorder || !currentPlaylist) return; e.preventDefault(); setDragHoverIndex(index); }) : undefined} onRowDrop={allowReorder ? (async (e: React.DragEvent) => { if (!onReorder || !currentPlaylist) return; e.preventDefault(); const fromId = e.dataTransfer.getData('text/song-id'); const multiJson = e.dataTransfer.getData('application/json'); let multiIds: string[] | null = null; if (multiJson) { try { const parsed = JSON.parse(multiJson); if (parsed && parsed.type === 'songs' && Array.isArray(parsed.songIds)) { multiIds = parsed.songIds as string[]; } } catch {} } if (!fromId && !multiIds) return; const toId = songs[index]?.id; if (!toId) return; if (multiIds && multiIds.length > 0) { await api.moveTracksInPlaylist(currentPlaylist, multiIds, toId); } else { await api.moveTrackInPlaylist(currentPlaylist, fromId!, toId); } await onReorder(songs.map(s => s.id)); setDragHoverIndex(null); setIsReorderDragging(false); }) : undefined} onRowDragStartCapture={allowReorder ? ((e: React.DragEvent) => { if (!currentPlaylist) return; e.dataTransfer.setData('text/song-id', song.id); try { e.dataTransfer.effectAllowed = 'move'; } catch {} try { e.dataTransfer.dropEffect = 'move'; } catch {} setIsReorderDragging(true); }) : undefined} /> )} ); })} {/* Drop zone to move item to end of playlist */} {onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && ( { e.preventDefault(); setDragHoverIndex(null); setEndDropHover(true); try { e.dataTransfer.dropEffect = 'move'; } catch {} }} onDragLeave={() => setEndDropHover(false)} onDrop={async (e: React.DragEvent) => { e.preventDefault(); const fromId = e.dataTransfer.getData('text/song-id'); const multiJson = e.dataTransfer.getData('application/json'); let multiIds: string[] | null = null; if (multiJson) { try { const parsed = JSON.parse(multiJson); if (parsed && parsed.type === 'songs' && Array.isArray(parsed.songIds)) { multiIds = parsed.songIds as string[]; } } catch {} } if (!fromId && !multiIds) return; // Move to end: omit toId if (multiIds && multiIds.length > 0) { await api.moveTracksInPlaylist(currentPlaylist, multiIds); } else { await api.moveTrackInPlaylist(currentPlaylist, fromId!); } await onReorder(songs.map(s => s.id)); setEndDropHover(false); setIsReorderDragging(false); }} onDragEnd={handleDragEnd} position="relative" height="28px" mt={1} > {endDropHover && ( )} )} {/* Loading indicator for infinite scroll or playlist switching */} {(loading || isSwitchingPlaylist) && ( {isSwitchingPlaylist ? 'Switching playlist...' : 'Loading more songs...'} )} {/* Intersection observer target */}
{/* Loading more indicator (subtle) */} {!loading && hasMore && songs.length > 0 && ( Scroll for more songs )} {/* End of results message */} {!hasMore && songs.length > 0 && ( No more songs to load )} {/* No results message */} {!loading && !isSwitchingPlaylist && songs.length === 0 && ( {searchQuery ? 'No songs found matching your search' : 'No songs available'} )} {/* Playlist Selection Modal */} ); });