- 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.
782 lines
28 KiB
TypeScript
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>
|
|
);
|
|
});
|