import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react'; import { useDebounce } from '../hooks/useDebounce'; import { Box, Flex, Text, Input, InputGroup, InputLeftElement, Checkbox, Button, HStack, Menu, MenuButton, MenuList, MenuItem, MenuDivider, IconButton, Spinner, } from '@chakra-ui/react'; import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons'; import type { Song, PlaylistNode } from '../types/interfaces'; import { formatDuration, formatTotalDuration } from '../utils/formatters'; 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; } // 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; }>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox }) => { // 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]); return ( {showCheckbox && ( e.stopPropagation()} /> )} {song.title} {song.artist} {formattedDuration} {song.averageBpm} BPM ); }); 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 }) => { const [selectedSongs, setSelectedSongs] = useState>(new Set()); const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); 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[] => { 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 const handleSearch = useCallback((query: string) => { setLocalSearchQuery(query); onSearch(query); }, [onSearch]); // Memoized song items to prevent unnecessary re-renders const songItems = useMemo(() => { return songs.map(song => ( 0 || depth === 0} /> )); }, [songs, selectedSongs, selectedSongId, toggleSelection, depth]); // 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; } // 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 const playlistOptions = useMemo(() => { return allPlaylists.map(playlist => ( handleBulkAddToPlaylist(playlist.name)}> {playlist.name} )); }, [allPlaylists, handleBulkAddToPlaylist]); // Handle debounced search useEffect(() => { if (debouncedSearchQuery !== searchQuery) { onSearch(debouncedSearchQuery); } }, [debouncedSearchQuery, searchQuery, onSearch]); // Intersection Observer for infinite scroll 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; 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 ( {/* 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)" }} /> {/* 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'}`} {songs.length} of {totalSongs} songs • {totalDuration} {hasMore && songs.length > 0 && ( • Scroll for more )} {selectedSongs.size > 0 && ( } size="sm" colorScheme="blue" > Actions {allPlaylists.map((playlist) => ( { handleBulkAddToPlaylist(playlist.name); }} > Add to {playlist.name} ))} {currentPlaylist !== "All Songs" && onRemoveFromPlaylist && ( <> { handleBulkRemoveFromPlaylist(); }} > Remove from {currentPlaylist} )} )} {/* Scrollable Song List */} {songItems} {/* Loading indicator for infinite scroll */} {loading && ( 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 && songs.length === 0 && ( {searchQuery ? 'No songs found matching your search' : 'No songs available'} )} ); });