Compare commits

...

11 Commits

Author SHA1 Message Date
Geert Rademakes
febfb638b9 Merge branch 'feat/multiselect-perf' into main 2025-08-13 16:09:25 +02:00
Geert Rademakes
feac54e2e0 style(frontend): dark scrollbars for playlist sidebar and mobile drawer 2025-08-13 16:08:38 +02:00
Geert Rademakes
dd2c7d353e style(frontend): dark scrollbars for song list, main content, and details panel to match theme 2025-08-13 16:06:02 +02:00
Geert Rademakes
017ba31d83 perf(frontend): use startTransition for selection updates to keep UI responsive on large sets 2025-08-13 16:03:26 +02:00
Geert Rademakes
1d290bdfa6 feat(frontend): shift-click range selection with optimistic checkbox feedback 2025-08-13 15:59:25 +02:00
Geert Rademakes
54b22d5cc5 perf(frontend): instant checkbox feedback via local optimistic selection in virtualized list 2025-08-13 15:57:26 +02:00
Geert Rademakes
517af140cf Merge branch 'feat/frontend-performance-audit' into main 2025-08-13 15:48:50 +02:00
Geert Rademakes
4bab1ae3a2 fix(frontend): avoid blocking fetch by not setting loadingRef in immediate switch 2025-08-13 15:47:25 +02:00
Geert Rademakes
ebc6f31d32 perf(frontend): set loading immediately on playlist switch for instant indicator 2025-08-13 15:43:23 +02:00
Geert Rademakes
449dfc708e perf(frontend): start playlist data load immediately on selection; useLayoutEffect for faster playlist switch 2025-08-13 15:35:27 +02:00
Geert Rademakes
52953d7e0d feat(frontend): virtualize song list, gate DnD, remove double mapping, memoize PlaylistManager; fix TS build warnings 2025-08-13 15:20:38 +02:00
9 changed files with 274 additions and 132 deletions

26
package-lock.json generated
View File

@ -3569,6 +3569,31 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tokenizer/token": { "node_modules/@tokenizer/token": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@ -8018,6 +8043,7 @@
"@chakra-ui/transition": "^2.1.0", "@chakra-ui/transition": "^2.1.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@tanstack/react-virtual": "^3.13.12",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"events": "^3.3.0", "events": "^3.3.0",
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",

View File

@ -19,6 +19,7 @@
"@chakra-ui/transition": "^2.1.0", "@chakra-ui/transition": "^2.1.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@tanstack/react-virtual": "^3.13.12",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"events": "^3.3.0", "events": "^3.3.0",
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",

View File

@ -130,7 +130,8 @@ const RekordboxReader: React.FC = () => {
loadNextPage, loadNextPage,
searchSongs, searchSongs,
searchQuery, searchQuery,
refresh refresh,
switchPlaylistImmediately
} = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist }); } = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist });
// Export library to XML // Export library to XML
@ -209,6 +210,10 @@ const RekordboxReader: React.FC = () => {
// Clear selected song immediately to prevent stale state // Clear selected song immediately to prevent stale state
setSelectedSong(null); setSelectedSong(null);
// Kick off data load immediately to avoid delay before backend call
const target = name || "All Songs";
switchPlaylistImmediately(target);
// Navigate immediately without any delays // Navigate immediately without any delays
if (name === "All Songs") { if (name === "All Songs") {
navigate("/", { replace: true }); navigate("/", { replace: true });
@ -305,8 +310,7 @@ const RekordboxReader: React.FC = () => {
return nodes.map(node => { return nodes.map(node => {
if (node.type === 'playlist' && node.name === currentPlaylist) { if (node.type === 'playlist' && node.name === currentPlaylist) {
const remainingTracks = (node.tracks || []).filter(id => !songIds.includes(id)); const remainingTracks = (node.tracks || []).filter(id => !songIds.includes(id));
const remainingOrder = (node.order || []).filter(id => !songIds.includes(id)); return { ...node, tracks: remainingTracks } as PlaylistNode;
return { ...node, tracks: remainingTracks, order: remainingOrder } as PlaylistNode;
} }
if (node.type === 'folder' && node.children) { if (node.type === 'folder' && node.children) {
return { ...node, children: applyRemove(node.children) } as PlaylistNode; return { ...node, children: applyRemove(node.children) } as PlaylistNode;
@ -583,6 +587,14 @@ const RekordboxReader: React.FC = () => {
borderColor="gray.700" borderColor="gray.700"
overflowY="auto" overflowY="auto"
bg="gray.900" bg="gray.900"
sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: '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)' }
}}
> >
{playlistManager} {playlistManager}
<ResizeHandle onMouseDown={handleResizeStart} /> <ResizeHandle onMouseDown={handleResizeStart} />
@ -617,7 +629,14 @@ const RekordboxReader: React.FC = () => {
_hover={{ color: "blue.300", bg: "whiteAlpha.200" }} _hover={{ color: "blue.300", bg: "whiteAlpha.200" }}
/> />
</DrawerHeader> </DrawerHeader>
<DrawerBody p={2}> <DrawerBody p={2} sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: '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)' }
}}>
{playlistManager} {playlistManager}
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
@ -632,7 +651,14 @@ const RekordboxReader: React.FC = () => {
overflow="hidden" overflow="hidden"
> >
{/* Song List */} {/* Song List */}
<Box flex={1} overflowY="auto" minH={0}> <Box flex={1} overflowY="auto" minH={0} sx={{
scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: '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)' }
}}>
<PaginatedSongList <PaginatedSongList
songs={songs} songs={songs}
onAddToPlaylist={handleAddSongsToPlaylist} onAddToPlaylist={handleAddSongsToPlaylist}
@ -672,15 +698,12 @@ const RekordboxReader: React.FC = () => {
bg="gray.900" bg="gray.900"
minH={0} minH={0}
sx={{ sx={{
'&::-webkit-scrollbar': { scrollbarColor: 'var(--chakra-colors-gray-700) var(--chakra-colors-gray-900)',
width: '8px', scrollbarWidth: 'thin',
borderRadius: '8px', '&::-webkit-scrollbar': { width: '8px', backgroundColor: 'var(--chakra-colors-gray-900)' },
backgroundColor: '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-thumb': { '&::-webkit-scrollbar-track': { backgroundColor: 'var(--chakra-colors-gray-900)' },
backgroundColor: 'gray.700',
borderRadius: '8px',
},
}} }}
> >
<SongDetails song={selectedSong} /> <SongDetails song={selectedSong} />

View File

@ -8,7 +8,6 @@ import {
VStack, VStack,
HStack, HStack,
Badge, Badge,
IconButton,
useDisclosure, useDisclosure,
Modal, Modal,
ModalOverlay, ModalOverlay,
@ -24,7 +23,6 @@ import {
Td, Td,
Spinner, Spinner,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { CloseIcon } from '@chakra-ui/icons';
import { api } from '../services/api'; import { api } from '../services/api';
interface JobProgress { interface JobProgress {
@ -55,7 +53,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
const [jobs, setJobs] = useState<JobProgress[]>([]); const [jobs, setJobs] = useState<JobProgress[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onClose } = useDisclosure();
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Load all jobs // Load all jobs
@ -180,7 +178,7 @@ export const BackgroundJobProgress: React.FC<BackgroundJobProgressProps> = ({
}; };
const activeJobs = jobs.filter(job => job.status === 'running'); const activeJobs = jobs.filter(job => job.status === 'running');
const completedJobs = jobs.filter(job => job.status === 'completed' || job.status === 'failed'); // const completedJobs = jobs.filter(job => job.status === 'completed' || job.status === 'failed');
return ( return (
<> <>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo, memo, startTransition } from 'react';
import { import {
Box, Box,
Flex, Flex,
@ -20,6 +20,8 @@ import { api } from '../services/api';
import { formatDuration, formatTotalDuration } from '../utils/formatters'; import { formatDuration, formatTotalDuration } from '../utils/formatters';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
import { PlaylistSelectionModal } from './PlaylistSelectionModal'; import { PlaylistSelectionModal } from './PlaylistSelectionModal';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { VirtualItem } from '@tanstack/react-virtual';
interface PaginatedSongListProps { interface PaginatedSongListProps {
songs: Song[]; songs: Song[];
@ -51,21 +53,34 @@ const SongItem = memo<{
onToggleSelection: (songId: string) => void; onToggleSelection: (songId: string) => void;
showCheckbox: boolean; showCheckbox: boolean;
onPlaySong?: (song: Song) => void; onPlaySong?: (song: Song) => void;
onDragStart: (e: React.DragEvent, songIdsFallback: string[]) => void; showDropIndicatorTop?: boolean;
onDragStart?: (e: React.DragEvent, songIdsFallback: string[]) => void;
onRowDragOver?: (e: React.DragEvent) => void; onRowDragOver?: (e: React.DragEvent) => void;
onRowDrop?: (e: React.DragEvent) => void; onRowDrop?: (e: React.DragEvent) => void;
onRowDragStartCapture?: (e: React.DragEvent) => void; onRowDragStartCapture?: (e: React.DragEvent) => void;
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture }) => { 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 // Memoize the formatted duration to prevent recalculation
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); 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(() => { const handleClick = useCallback(() => {
onSelect(song); onSelect(song);
}, [onSelect, song]); }, [onSelect, song]);
const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleCheckboxClick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation(); e.stopPropagation();
onToggleSelection(song.id); setLocalChecked(e.target.checked);
}, [onToggleSelection, song.id]); 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) => { const handlePlayClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -89,17 +104,18 @@ const SongItem = memo<{
_hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }} _hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }}
onClick={handleClick} onClick={handleClick}
transition="background-color 0.2s" transition="background-color 0.2s"
draggable {...(onDragStart ? { draggable: true, onDragStart: (e: React.DragEvent) => onDragStart(e, [song.id]) } : {})}
onDragStart={(e) => { {...(onRowDragOver ? { onDragOver: onRowDragOver } : {})}
onDragStart(e, [song.id]); {...(onRowDrop ? { onDrop: onRowDrop } : {})}
}} {...(onRowDragStartCapture ? { onDragStartCapture: onRowDragStartCapture } : {})}
onDragOver={onRowDragOver} position="relative"
onDrop={onRowDrop}
onDragStartCapture={onRowDragStartCapture}
> >
{showDropIndicatorTop && (
<Box position="absolute" top={0} left={0} right={0} height="2px" bg="blue.400" zIndex={1} />
)}
{showCheckbox && ( {showCheckbox && (
<Checkbox <Checkbox
isChecked={isSelected} isChecked={localChecked}
onChange={handleCheckboxClick} onChange={handleCheckboxClick}
mr={3} mr={3}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -172,6 +188,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null); const [dragHoverIndex, setDragHoverIndex] = useState<number | null>(null);
const [endDropHover, setEndDropHover] = useState<boolean>(false); const [endDropHover, setEndDropHover] = useState<boolean>(false);
const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false); const [isReorderDragging, setIsReorderDragging] = useState<boolean>(false);
const lastSelectedIndexRef = useRef<number | null>(null);
// Store current values in refs to avoid stale closures // Store current values in refs to avoid stale closures
const hasMoreRef = useRef(hasMore); const hasMoreRef = useRef(hasMore);
@ -207,31 +224,60 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
// const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]); // const allPlaylists = useMemo(() => getAllPlaylists(playlists), [playlists, getAllPlaylists]);
const toggleSelection = useCallback((songId: string) => { const toggleSelection = useCallback((songId: string) => {
setSelectedSongs(prev => { startTransition(() => {
const newSelection = new Set(prev); setSelectedSongs(prev => {
if (newSelection.has(songId)) { const newSelection = new Set(prev);
newSelection.delete(songId); if (newSelection.has(songId)) {
} else { newSelection.delete(songId);
newSelection.add(songId); } else {
} newSelection.add(songId);
return newSelection; }
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];
startTransition(() => {
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(() => { const toggleSelectAll = useCallback(() => {
setSelectedSongs(prev => { startTransition(() => {
const noneSelected = prev.size === 0; setSelectedSongs(prev => {
const allSelected = prev.size === songs.length && songs.length > 0; const noneSelected = prev.size === 0;
if (noneSelected) { const allSelected = prev.size === songs.length && songs.length > 0;
// Select all from empty state if (noneSelected) {
return new Set(songs.map(s => s.id));
}
if (allSelected) {
return new Set();
}
return new Set(songs.map(s => s.id)); 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]); }, [songs]);
@ -277,67 +323,13 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
dragSelectionRef.current = null; dragSelectionRef.current = null;
}, []); }, []);
// Memoized song items to prevent unnecessary re-renders // Virtualizer for large lists
const songItems = useMemo(() => { const rowVirtualizer = useVirtualizer({
return songs.map((song, index) => ( count: songs.length,
<SongItem getScrollElement: () => scrollContainerRef.current,
key={song.id} estimateSize: () => 64,
song={song} overscan: 8
isSelected={selectedSongs.has(song.id)} });
isHighlighted={selectedSongId === song.id}
onSelect={handleSongSelect}
onToggleSelection={toggleSelection}
showCheckbox={selectedSongs.size > 0 || depth === 0}
onPlaySong={onPlaySong}
onDragStart={handleDragStart}
// Simple playlist reordering within same list by dragging rows
onRowDragOver={(e: React.DragEvent) => {
if (!onReorder || !currentPlaylist) return;
e.preventDefault();
setDragHoverIndex(index);
}}
onRowDrop={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 fromIndex = fromId ? songs.findIndex(s => s.id === fromId) : -1;
const toIndex = index;
if (toIndex < 0) return;
if (fromId && fromIndex >= 0 && fromIndex === toIndex) return;
const toId = songs[index].id;
// If multiple, move block; else move single
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)); // trigger refresh via parent
setDragHoverIndex(null);
setIsReorderDragging(false);
}}
onRowDragStartCapture={(e: React.DragEvent) => {
// Provide a simple id for intra-list reorder
if (!currentPlaylist) return;
e.dataTransfer.setData('text/song-id', song.id);
// Explicitly set effect to move for better UX
try { e.dataTransfer.effectAllowed = 'move'; } catch {}
try { e.dataTransfer.dropEffect = 'move'; } catch {}
setIsReorderDragging(true);
}}
/>
));
}, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong, handleDragStart]); // Removed handleSongSelect since it's already memoized
// Use total playlist duration if available, otherwise calculate from current songs // Use total playlist duration if available, otherwise calculate from current songs
const totalDuration = useMemo(() => { const totalDuration = useMemo(() => {
@ -503,25 +495,106 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
</Flex> </Flex>
</Box> </Box>
{/* Scrollable Song List */} {/* Scrollable Song List (virtualized) */}
<Box <Box
ref={scrollContainerRef} ref={scrollContainerRef}
flex={1} flex={1}
overflowY="auto" overflowY="auto"
mt={2} mt={2}
id="song-list-container" 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)'
}
}}
> >
<Flex direction="column" gap={2}> <Box position="relative" height={`${rowVirtualizer.getTotalSize()}px`}>
<Box onDragEnd={handleDragEnd}> <Box onDragEnd={handleDragEnd}>
{songs.map((song, index) => ( {rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => {
<Box key={`row-${song.id}`} position="relative"> const index = virtualRow.index;
{dragHoverIndex === index && ( const song = songs[index];
<Box position="absolute" top={0} left={0} right={0} height="2px" bg="blue.400" zIndex={1} /> const allowReorder = Boolean(onReorder && currentPlaylist);
)} return (
{songItems[index]} <Box
</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>
</Box>
{/* Drop zone to move item to end of playlist */} {/* Drop zone to move item to end of playlist */}
{onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && ( {onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && (
@ -607,7 +680,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
</Text> </Text>
</Flex> </Flex>
)} )}
</Flex>
</Box> </Box>
{/* Playlist Selection Modal */} {/* Playlist Selection Modal */}

View File

@ -337,7 +337,7 @@ const PlaylistItem: React.FC<PlaylistItemProps> = React.memo(({
); );
}); });
export const PlaylistManager: React.FC<PlaylistManagerProps> = ({ const PlaylistManagerComponent: React.FC<PlaylistManagerProps> = ({
playlists, playlists,
selectedItem, selectedItem,
onPlaylistCreate, onPlaylistCreate,
@ -562,4 +562,6 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
</Modal> </Modal>
</Box> </Box>
); );
}; };
export const PlaylistManager = React.memo(PlaylistManagerComponent);

View File

@ -16,10 +16,9 @@ import {
Icon, Icon,
useToast, useToast,
Box, Box,
Divider,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SearchIcon, FolderIcon } from '@chakra-ui/icons'; import { SearchIcon } from '@chakra-ui/icons';
import { FiFolder, FiMusic } from 'react-icons/fi'; import { FiMusic } from 'react-icons/fi';
import type { PlaylistNode } from '../types/interfaces'; import type { PlaylistNode } from '../types/interfaces';
interface PlaylistSelectionModalProps { interface PlaylistSelectionModalProps {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
import { api, type SongsResponse } from '../services/api'; import { api, type SongsResponse } from '../services/api';
import type { Song } from '../types/interfaces'; import type { Song } from '../types/interfaces';
@ -139,7 +139,7 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
}, []); }, []);
// Handle playlist changes - streamlined for immediate response // Handle playlist changes - streamlined for immediate response
useEffect(() => { useLayoutEffect(() => {
if (previousPlaylistRef.current !== playlistName) { if (previousPlaylistRef.current !== playlistName) {
// Update refs immediately // Update refs immediately
currentPlaylistRef.current = playlistName; currentPlaylistRef.current = playlistName;
@ -160,6 +160,26 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
} }
}, [playlistName, initialSearch, loadPage]); }, [playlistName, initialSearch, loadPage]);
// Imperative method to switch playlist and start loading immediately
const switchPlaylistImmediately = useCallback((targetPlaylistName: string) => {
// Update refs immediately so effect does not double-trigger
currentPlaylistRef.current = targetPlaylistName;
previousPlaylistRef.current = targetPlaylistName;
currentSearchQueryRef.current = searchQuery;
// Clear state for instant visual feedback
setLoading(true);
setSongs([]);
setTotalSongs(0);
setTotalDuration(undefined);
setHasMore(true);
setCurrentPage(1);
setError(null);
// Start loading right away
loadPage(1, initialSearch, targetPlaylistName);
}, [initialSearch, loadPage, searchQuery]);
return { return {
songs, songs,
loading, loading,
@ -174,5 +194,6 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
searchSongs, searchSongs,
reset, reset,
refresh: () => loadPage(1) refresh: () => loadPage(1)
, switchPlaylistImmediately
}; };
}; };

View File

@ -18,13 +18,12 @@ import {
CardBody, CardBody,
CardHeader, CardHeader,
Spinner, Spinner,
Divider,
Badge, Badge,
Icon, Icon,
Switch, Switch,
FormHelperText, FormHelperText,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiCheck, FiX, FiSettings, FiZap, FiSave } from 'react-icons/fi'; import { FiSettings, FiZap, FiSave } from 'react-icons/fi';
interface S3Config { interface S3Config {
endpoint: string; endpoint: string;