import React, { useState, useRef, useEffect, useCallback, useMemo, memo } 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 { formatDuration, formatTotalDuration } from '../utils/formatters'; import { useDebounce } from '../hooks/useDebounce'; import { PlaylistSelectionModal } from './PlaylistSelectionModal'; 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 } // 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; onDragStart: (e: React.DragEvent, songIdsFallback: string[]) => void; }>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart }) => { // Memoize the formatted duration to prevent recalculation const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); const handleClick = useCallback(() => { onSelect(song); }, [onSelect, song]); const handleCheckboxClick = useCallback((e: React.ChangeEvent) => { e.stopPropagation(); onToggleSelection(song.id); }, [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]); }} > {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 }) => { 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); // 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) => { setSelectedSongs(prev => { const newSelection = new Set(prev); if (newSelection.has(songId)) { newSelection.delete(songId); } else { newSelection.add(songId); } return newSelection; }); }, []); const toggleSelectAll = useCallback(() => { setSelectedSongs(prev => prev.size === songs.length ? new Set() : 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); 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'; }, [selectedSongs]); const handleDragEnd = useCallback(() => { setIsDragging(false); dragSelectionRef.current = null; }, []); // Memoized song items to prevent unnecessary re-renders const songItems = useMemo(() => { return songs.map(song => ( 0 || depth === 0} onPlaySong={onPlaySong} onDragStart={handleDragStart} /> )); }, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong, handleDragStart]); // Removed handleSongSelect since it's already memoized // 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 */} {songItems} {/* 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 */} ); });