Geert Rademakes 383f3476f0 Optimize PaginatedSongList performance to prevent re-renders
- Memoized drag handlers to prevent inline function recreation
- Added custom React.memo comparison for SongItem components
- Optimized hasMusicFile calculation with useMemo
- Removed debug console.log statements
- Fixed inline function creation in song mapping

This should significantly reduce re-renders and improve song selection performance.
2025-09-19 11:05:16 +02:00

782 lines
28 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,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
} from '@chakra-ui/react';
import { Search2Icon, ChevronDownIcon } 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';
// Temporarily disabled virtualizer for performance testing
// 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]);
const handleClick = useCallback(() => {
onSelect(song);
}, [onSelect, song]);
const hasMusicFile = useMemo(() =>
song.s3File?.hasS3File || song.hasMusicFile || false,
[song.s3File?.hasS3File, song.hasMusicFile]
);
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
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 && hasMusicFile) {
onPlaySong(song);
}
}, [onPlaySong, hasMusicFile, song]);
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={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}{song.tonality ? ` - ${song.tonality}` : ''}
</Text>
<Text fontSize="xs" color="gray.600">
{song.averageBpm} BPM
</Text>
</Box>
{hasMusicFile && 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';
// Custom comparison function to prevent unnecessary re-renders
const areEqual = (prevProps: any, nextProps: any) => {
return (
prevProps.song.id === nextProps.song.id &&
prevProps.isSelected === nextProps.isSelected &&
prevProps.isHighlighted === nextProps.isHighlighted &&
prevProps.showCheckbox === nextProps.showCheckbox &&
prevProps.showDropIndicatorTop === nextProps.showDropIndicatorTop &&
prevProps.index === nextProps.index &&
prevProps.song.title === nextProps.song.title &&
prevProps.song.artist === nextProps.song.artist &&
prevProps.song.totalTime === nextProps.song.totalTime &&
prevProps.song.tonality === nextProps.song.tonality &&
prevProps.song.averageBpm === nextProps.song.averageBpm &&
prevProps.song.s3File?.hasS3File === nextProps.song.s3File?.hasS3File &&
prevProps.song.hasMusicFile === nextProps.song.hasMusicFile
);
};
// Apply custom comparison to SongItem
const OptimizedSongItem = memo(SongItem, areEqual);
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);
const dragPreviewRef = useRef<HTMLDivElement | 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]);
// Clear selection when switching playlists
useEffect(() => {
if (isSwitchingPlaylist) {
setSelectedSongs(new Set());
}
}, [isSwitchingPlaylist]);
// 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) {
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 drag handlers to prevent re-renders
const createRowDragOver = useCallback((index: number) => {
if (!onReorder || !currentPlaylist) return undefined;
return (e: React.DragEvent) => {
e.preventDefault();
setDragHoverIndex(index);
};
}, [onReorder, currentPlaylist]);
const createRowDrop = useCallback((index: number) => {
if (!onReorder || !currentPlaylist) return undefined;
return 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;
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);
};
}, [onReorder, currentPlaylist, songs]);
const createRowDragStartCapture = useCallback((song: Song) => {
if (!currentPlaylist) return undefined;
return (e: React.DragEvent) => {
e.dataTransfer.setData('text/song-id', song.id);
try { e.dataTransfer.effectAllowed = 'move'; } catch {}
try { e.dataTransfer.dropEffect = 'move'; } catch {}
setIsReorderDragging(true);
};
}, [currentPlaylist]);
// 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;
}, []);
// Temporarily disable virtualizer for performance testing
// 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 '';
// Defer expensive calculation to avoid blocking first selection
if (songs.length > 100) {
// For large lists, show a placeholder and calculate in background
return 'Calculating...';
}
// 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 && (
<Menu>
<MenuButton
as={Button}
size="sm"
colorScheme="blue"
rightIcon={<ChevronDownIcon />}
>
Actions
</MenuButton>
<MenuList>
<MenuItem onClick={onPlaylistModalOpen}>
Add to Playlist...
</MenuItem>
{currentPlaylist !== "All Songs" && onRemoveFromPlaylist && (
<>
<MenuDivider />
<MenuItem
color="red.300"
onClick={handleBulkRemoveFromPlaylist}
>
Remove from {currentPlaylist}
</MenuItem>
</>
)}
</MenuList>
</Menu>
)}
</Flex>
</Box>
{/* Scrollable Song List (virtualized) */}
<Box
ref={scrollContainerRef}
flex={1}
overflowY="auto"
mt={2}
id="song-list-container"
sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': {
width: '8px',
height: '8px',
backgroundColor: 'var(--chakra-colors-gray-900)'
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'var(--chakra-colors-gray-700)',
borderRadius: '8px',
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: 'var(--chakra-colors-gray-600)'
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'var(--chakra-colors-gray-900)'
}
}}
>
<Box onDragEnd={handleDragEnd}>
{songs.map((song, index) => {
const allowReorder = Boolean(onReorder && currentPlaylist);
return (
<OptimizedSongItem
key={song.id}
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 ? createRowDragOver(index) : undefined}
onRowDrop={allowReorder ? createRowDrop(index) : undefined}
onRowDragStartCapture={allowReorder ? createRowDragStartCapture(song) : undefined}
/>
);
})}
{/* 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>
</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" />
{/* Playlist Selection Modal */}
<PlaylistSelectionModal
isOpen={isPlaylistModalOpen}
onClose={onPlaylistModalClose}
playlists={playlists}
onPlaylistSelect={handleBulkAddToPlaylist}
selectedSongCount={selectedSongs.size}
/>
</Flex>
);
});