674 lines
25 KiB
TypeScript

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 { 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<void>;
}
// 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<boolean>(isSelected);
useEffect(() => {
setLocalChecked(isSelected);
}, [isSelected]);
const handleClick = useCallback(() => {
onSelect(song);
}, [onSelect, song]);
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<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"
{...(onDragStart ? { draggable: true, onDragStart: (e: React.DragEvent) => onDragStart(e, [song.id]) } : {})}
{...(onRowDragOver ? { onDragOver: onRowDragOver } : {})}
{...(onRowDrop ? { onDrop: onRowDrop } : {})}
{...(onRowDragStartCapture ? { onDragStartCapture: onRowDragStartCapture } : {})}
position="relative"
>
{showDropIndicatorTop && (
<Box position="absolute" top={0} left={0} right={0} height="2px" bg="blue.400" zIndex={1} />
)}
{showCheckbox && (
<Checkbox
isChecked={localChecked}
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>
{hasMusicFile(song) && onPlaySong && (
<IconButton
aria-label="Play song"
icon={<FiPlay />}
size="sm"
variant="ghost"
colorScheme="blue"
onClick={handlePlayClick}
ml={2}
_hover={{ bg: "blue.900" }}
/>
)}
</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,
isSwitchingPlaylist = false,
onPlaySong,
onReorder
}) => {
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
const dragSelectionRef = useRef<string[] | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
const { isOpen: isPlaylistModalOpen, onOpen: onPlaylistModalOpen, onClose: onPlaylistModalClose } = useDisclosure();
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);
const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null);
const [endDropHover, setEndDropHover] = useState<boolean>(false);
const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false);
const lastSelectedIndexRef = useRef<number | 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[] => {
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;
});
}, []);
// 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];
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(() => {
setSelectedSongs(prev => {
const noneSelected = prev.size === 0;
const allSelected = prev.size === songs.length && songs.length > 0;
if (noneSelected) {
// Select all from empty state
return new Set(songs.map(s => s.id));
}
if (allSelected) {
// Deselect all when everything is selected
return new Set();
}
// Mixed/some selected: clear first, then select all (single state update reflects final state)
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';
}, [selectedSongs]);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setIsReorderDragging(false);
setEndDropHover(false);
setDragHoverIndex(null);
dragSelectionRef.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 (
<Flex direction="column" height="100%">
{/* Global drag badge for selected count */}
{isDragging && (
<Box position="fixed" top={3} right={3} zIndex={2000} bg="blue.600" color="white" px={3} py={1} borderRadius="md" boxShadow="lg">
Dragging {dragSelectionRef.current?.length || 0} song{(dragSelectionRef.current?.length || 0) === 1 ? '' : 's'}
</Box>
)}
{/* 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)" }}
autoFocus
/>
</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">
{isSwitchingPlaylist ? (
<>
0 of 0 songs
<Text as="span" color="blue.400" ml={1}>
Switching playlist...
</Text>
</>
) : (
<>
{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 && (
<HStack spacing={2}>
<Button
size="sm"
colorScheme="blue"
onClick={onPlaylistModalOpen}
>
Add to Playlist...
</Button>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<Button
size="sm"
variant="outline"
colorScheme="red"
onClick={() => {
handleBulkRemoveFromPlaylist();
}}
>
Remove from {currentPlaylist}
</Button>
)}
</HStack>
)}
</Flex>
</Box>
{/* Scrollable Song List (virtualized) */}
<Box
ref={scrollContainerRef}
flex={1}
overflowY="auto"
mt={2}
id="song-list-container"
>
<Box position="relative" height={`${rowVirtualizer.getTotalSize()}px`}>
<Box onDragEnd={handleDragEnd}>
{rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => {
const index = virtualRow.index;
const song = songs[index];
const allowReorder = Boolean(onReorder && currentPlaylist);
return (
<Box
key={song ? song.id : index}
position="absolute"
top={0}
left={0}
right={0}
transform={`translateY(${virtualRow.start}px)`}
>
{song && (
<SongItem
song={song}
isSelected={selectedSongs.has(song.id)}
isHighlighted={selectedSongId === song.id}
onSelect={handleSongSelect}
onToggleSelection={toggleSelection}
showCheckbox={selectedSongs.size > 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}
/>
)}
</Box>
);
})}
</Box>
</Box>
{/* Drop zone to move item to end of playlist */}
{onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && (
<Box
onDragOver={(e: React.DragEvent) => {
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 && (
<Box position="absolute" top="50%" left={0} right={0} height="2px" bg="blue.400" />
)}
</Box>
)}
{/* Loading indicator for infinite scroll or playlist switching */}
{(loading || isSwitchingPlaylist) && (
<Flex justify="center" align="center" p={6} key="loading-spinner" gap={3}>
<Spinner size="md" color="blue.400" />
<Text color="gray.400" fontSize="sm">
{isSwitchingPlaylist ? 'Switching playlist...' : '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 && !isSwitchingPlaylist && songs.length === 0 && (
<Flex justify="center" p={8} key="no-results" direction="column" align="center" gap={3}>
<Text color="gray.500">
{searchQuery ? 'No songs found matching your search' : 'No songs available'}
</Text>
</Flex>
)}
</Box>
{/* Playlist Selection Modal */}
<PlaylistSelectionModal
isOpen={isPlaylistModalOpen}
onClose={onPlaylistModalClose}
playlists={playlists}
onPlaylistSelect={handleBulkAddToPlaylist}
selectedSongCount={selectedSongs.size}
/>
</Flex>
);
});