434 lines
14 KiB
TypeScript

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<HTMLInputElement>) => {
e.stopPropagation();
onToggleSelection(song.id);
}, [onToggleSelection, song.id]);
return (
<Flex
key={song.id}
p={3}
borderBottom="1px"
borderColor="gray.700"
cursor="pointer"
bg={isHighlighted ? "blue.900" : "transparent"}
_hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }}
onClick={handleClick}
transition="background-color 0.2s"
>
{showCheckbox && (
<Checkbox
isChecked={isSelected}
onChange={handleCheckboxClick}
mr={3}
onClick={(e) => e.stopPropagation()}
/>
)}
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
{song.title}
</Text>
<Text fontSize="xs" color="gray.400" noOfLines={1}>
{song.artist}
</Text>
</Box>
<Box textAlign="right" ml={2}>
<Text fontSize="xs" color="gray.500">
{formattedDuration}
</Text>
<Text fontSize="xs" color="gray.600">
{song.averageBpm} BPM
</Text>
</Box>
</Flex>
);
});
SongItem.displayName = 'SongItem';
export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
songs,
onAddToPlaylist,
onRemoveFromPlaylist,
playlists,
onSongSelect,
selectedSongId,
currentPlaylist,
loading,
hasMore,
totalSongs,
totalPlaylistDuration,
onLoadMore,
onSearch,
searchQuery,
depth = 0
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadingRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isTriggeringRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(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 => (
<SongItem
key={song.id}
song={song}
isSelected={selectedSongs.has(song.id)}
isHighlighted={selectedSongId === song.id}
onSelect={handleSongSelect}
onToggleSelection={toggleSelection}
showCheckbox={selectedSongs.size > 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 => (
<MenuItem key={playlist.id} onClick={() => handleBulkAddToPlaylist(playlist.name)}>
{playlist.name}
</MenuItem>
));
}, [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 (
<Flex direction="column" height="100%">
{/* Sticky Header */}
<Box
position="sticky"
top={0}
bg="gray.900"
zIndex={1}
pb={4}
>
{/* Search Bar */}
<InputGroup mb={4}>
<InputLeftElement pointerEvents="none">
<Search2Icon color="gray.300" />
</InputLeftElement>
<Input
placeholder="Search songs by title, artist, album, or genre..."
value={localSearchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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)" }}
/>
</InputGroup>
{/* Bulk Actions Toolbar */}
<Flex justify="space-between" align="center" p={2} bg="gray.800" borderRadius="md">
<HStack spacing={4}>
<Checkbox
isChecked={selectedSongs.size === songs.length && songs.length > 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'}`}
</Checkbox>
<Text color="gray.400" fontSize="sm">
{songs.length} of {totalSongs} songs {totalDuration}
{hasMore && songs.length > 0 && (
<Text as="span" color="blue.400" ml={2}>
Scroll for more
</Text>
)}
</Text>
</HStack>
{selectedSongs.size > 0 && (
<Menu>
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
size="sm"
colorScheme="blue"
>
Actions
</MenuButton>
<MenuList>
{allPlaylists.map((playlist) => (
<MenuItem
key={playlist.id}
onClick={() => {
handleBulkAddToPlaylist(playlist.name);
}}
>
Add to {playlist.name}
</MenuItem>
))}
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={() => {
handleBulkRemoveFromPlaylist();
}}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</MenuList>
</Menu>
)}
</Flex>
</Box>
{/* Scrollable Song List */}
<Box
ref={scrollContainerRef}
flex={1}
overflowY="auto"
mt={2}
id="song-list-container"
>
<Flex direction="column" gap={2}>
{songItems}
{/* Loading indicator for infinite scroll */}
{loading && (
<Flex justify="center" align="center" p={6} key="loading-spinner" gap={3}>
<Spinner size="md" color="blue.400" />
<Text color="gray.400" fontSize="sm">
Loading more songs...
</Text>
</Flex>
)}
{/* Intersection observer target */}
<div ref={loadingRef} style={{ height: '20px' }} key="intersection-target" />
{/* Loading more indicator (subtle) */}
{!loading && hasMore && songs.length > 0 && (
<Flex justify="center" p={3} key="loading-more-indicator">
<Text color="gray.600" fontSize="xs">
Scroll for more songs
</Text>
</Flex>
)}
{/* End of results message */}
{!hasMore && songs.length > 0 && (
<Flex justify="center" p={4} key="end-message">
<Text color="gray.500" fontSize="sm">
No more songs to load
</Text>
</Flex>
)}
{/* No results message */}
{!loading && songs.length === 0 && (
<Flex justify="center" p={8} key="no-results">
<Text color="gray.500">
{searchQuery ? 'No songs found matching your search' : 'No songs available'}
</Text>
</Flex>
)}
</Flex>
</Box>
</Flex>
);
});