Compare commits
11 Commits
a7ccadc8ac
...
febfb638b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
febfb638b9 | ||
|
|
feac54e2e0 | ||
|
|
dd2c7d353e | ||
|
|
017ba31d83 | ||
|
|
1d290bdfa6 | ||
|
|
54b22d5cc5 | ||
|
|
517af140cf | ||
|
|
4bab1ae3a2 | ||
|
|
ebc6f31d32 | ||
|
|
449dfc708e | ||
|
|
52953d7e0d |
26
package-lock.json
generated
26
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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);
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user