From 52953d7e0d67ebcce6679e58f8dd069ec77d41c3 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 13 Aug 2025 15:20:38 +0200 Subject: [PATCH 1/4] feat(frontend): virtualize song list, gate DnD, remove double mapping, memoize PlaylistManager; fix TS build warnings --- package-lock.json | 26 +++ packages/frontend/package.json | 1 + packages/frontend/src/App.tsx | 3 +- .../src/components/BackgroundJobProgress.tsx | 6 +- .../src/components/PaginatedSongList.tsx | 172 +++++++++--------- .../src/components/PlaylistManager.tsx | 6 +- .../src/components/PlaylistSelectionModal.tsx | 5 +- .../frontend/src/hooks/usePaginatedSongs.ts | 2 +- .../frontend/src/pages/S3Configuration.tsx | 3 +- 9 files changed, 129 insertions(+), 95 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4de3c11..6365a91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3569,6 +3569,31 @@ "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": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -8018,6 +8043,7 @@ "@chakra-ui/transition": "^2.1.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@tanstack/react-virtual": "^3.13.12", "@types/uuid": "^10.0.0", "events": "^3.3.0", "framer-motion": "^12.5.0", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e75b8ee..1622203 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,6 +19,7 @@ "@chakra-ui/transition": "^2.1.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@tanstack/react-virtual": "^3.13.12", "@types/uuid": "^10.0.0", "events": "^3.3.0", "framer-motion": "^12.5.0", diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 62e07c5..6e16b9f 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -305,8 +305,7 @@ const RekordboxReader: React.FC = () => { return nodes.map(node => { if (node.type === 'playlist' && node.name === currentPlaylist) { const remainingTracks = (node.tracks || []).filter(id => !songIds.includes(id)); - const remainingOrder = (node.order || []).filter(id => !songIds.includes(id)); - return { ...node, tracks: remainingTracks, order: remainingOrder } as PlaylistNode; + return { ...node, tracks: remainingTracks } as PlaylistNode; } if (node.type === 'folder' && node.children) { return { ...node, children: applyRemove(node.children) } as PlaylistNode; diff --git a/packages/frontend/src/components/BackgroundJobProgress.tsx b/packages/frontend/src/components/BackgroundJobProgress.tsx index 810fe6f..1e2fe5b 100644 --- a/packages/frontend/src/components/BackgroundJobProgress.tsx +++ b/packages/frontend/src/components/BackgroundJobProgress.tsx @@ -8,7 +8,6 @@ import { VStack, HStack, Badge, - IconButton, useDisclosure, Modal, ModalOverlay, @@ -24,7 +23,6 @@ import { Td, Spinner, } from '@chakra-ui/react'; -import { CloseIcon } from '@chakra-ui/icons'; import { api } from '../services/api'; interface JobProgress { @@ -55,7 +53,7 @@ export const BackgroundJobProgress: React.FC = ({ const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const { isOpen, onOpen, onClose } = useDisclosure(); + const { isOpen, onClose } = useDisclosure(); const intervalRef = useRef(null); // Load all jobs @@ -180,7 +178,7 @@ export const BackgroundJobProgress: React.FC = ({ }; 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 ( <> diff --git a/packages/frontend/src/components/PaginatedSongList.tsx b/packages/frontend/src/components/PaginatedSongList.tsx index f947fae..edd6512 100644 --- a/packages/frontend/src/components/PaginatedSongList.tsx +++ b/packages/frontend/src/components/PaginatedSongList.tsx @@ -20,6 +20,8 @@ import { api } from '../services/api'; import { formatDuration, formatTotalDuration } from '../utils/formatters'; import { useDebounce } from '../hooks/useDebounce'; import { PlaylistSelectionModal } from './PlaylistSelectionModal'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { VirtualItem } from '@tanstack/react-virtual'; interface PaginatedSongListProps { songs: Song[]; @@ -51,11 +53,12 @@ const SongItem = memo<{ onToggleSelection: (songId: string) => void; showCheckbox: boolean; 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; onRowDrop?: (e: React.DragEvent) => void; onRowDragStartCapture?: (e: React.DragEvent) => void; -}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture }) => { +}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, showDropIndicatorTop, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture }) => { // Memoize the formatted duration to prevent recalculation const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]); const handleClick = useCallback(() => { @@ -89,14 +92,15 @@ const SongItem = memo<{ _hover={{ bg: isHighlighted ? "blue.800" : "gray.700" }} onClick={handleClick} transition="background-color 0.2s" - draggable - onDragStart={(e) => { - onDragStart(e, [song.id]); - }} - onDragOver={onRowDragOver} - onDrop={onRowDrop} - onDragStartCapture={onRowDragStartCapture} + {...(onDragStart ? { draggable: true, onDragStart: (e: React.DragEvent) => onDragStart(e, [song.id]) } : {})} + {...(onRowDragOver ? { onDragOver: onRowDragOver } : {})} + {...(onRowDrop ? { onDrop: onRowDrop } : {})} + {...(onRowDragStartCapture ? { onDragStartCapture: onRowDragStartCapture } : {})} + position="relative" > + {showDropIndicatorTop && ( + + )} {showCheckbox && ( = memo(({ dragSelectionRef.current = null; }, []); - // Memoized song items to prevent unnecessary re-renders - const songItems = useMemo(() => { - return songs.map((song, index) => ( - 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 + // Virtualizer for large lists + const rowVirtualizer = useVirtualizer({ + count: songs.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 64, + overscan: 8 + }); // Use total playlist duration if available, otherwise calculate from current songs const totalDuration = useMemo(() => { @@ -503,7 +453,7 @@ export const PaginatedSongList: React.FC = memo(({ - {/* Scrollable Song List */} + {/* Scrollable Song List (virtualized) */} = memo(({ mt={2} id="song-list-container" > - + - {songs.map((song, index) => ( - - {dragHoverIndex === index && ( - - )} - {songItems[index]} - - ))} + {rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => { + const index = virtualRow.index; + const song = songs[index]; + const allowReorder = Boolean(onReorder && currentPlaylist); + return ( + + {song && ( + 0 || depth === 0} + 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} + /> + )} + + ); + })} + {/* Drop zone to move item to end of playlist */} {onReorder && currentPlaylist && selectedSongs.size === 0 && isReorderDragging && ( @@ -607,7 +617,7 @@ export const PaginatedSongList: React.FC = memo(({ )} - + {/* Playlist Selection Modal */} diff --git a/packages/frontend/src/components/PlaylistManager.tsx b/packages/frontend/src/components/PlaylistManager.tsx index b269f6e..7dcfc3b 100644 --- a/packages/frontend/src/components/PlaylistManager.tsx +++ b/packages/frontend/src/components/PlaylistManager.tsx @@ -337,7 +337,7 @@ const PlaylistItem: React.FC = React.memo(({ ); }); -export const PlaylistManager: React.FC = ({ +const PlaylistManagerComponent: React.FC = ({ playlists, selectedItem, onPlaylistCreate, @@ -562,4 +562,6 @@ export const PlaylistManager: React.FC = ({ ); -}; \ No newline at end of file +}; + +export const PlaylistManager = React.memo(PlaylistManagerComponent); \ No newline at end of file diff --git a/packages/frontend/src/components/PlaylistSelectionModal.tsx b/packages/frontend/src/components/PlaylistSelectionModal.tsx index e90d037..ddc40cd 100644 --- a/packages/frontend/src/components/PlaylistSelectionModal.tsx +++ b/packages/frontend/src/components/PlaylistSelectionModal.tsx @@ -16,10 +16,9 @@ import { Icon, useToast, Box, - Divider, } from '@chakra-ui/react'; -import { SearchIcon, FolderIcon } from '@chakra-ui/icons'; -import { FiFolder, FiMusic } from 'react-icons/fi'; +import { SearchIcon } from '@chakra-ui/icons'; +import { FiMusic } from 'react-icons/fi'; import type { PlaylistNode } from '../types/interfaces'; interface PlaylistSelectionModalProps { diff --git a/packages/frontend/src/hooks/usePaginatedSongs.ts b/packages/frontend/src/hooks/usePaginatedSongs.ts index f3b6e3a..88f8a95 100644 --- a/packages/frontend/src/hooks/usePaginatedSongs.ts +++ b/packages/frontend/src/hooks/usePaginatedSongs.ts @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { api, type SongsResponse } from '../services/api'; import type { Song } from '../types/interfaces'; diff --git a/packages/frontend/src/pages/S3Configuration.tsx b/packages/frontend/src/pages/S3Configuration.tsx index 6372972..93288fb 100644 --- a/packages/frontend/src/pages/S3Configuration.tsx +++ b/packages/frontend/src/pages/S3Configuration.tsx @@ -18,13 +18,12 @@ import { CardBody, CardHeader, Spinner, - Divider, Badge, Icon, Switch, FormHelperText, } from '@chakra-ui/react'; -import { FiCheck, FiX, FiSettings, FiZap, FiSave } from 'react-icons/fi'; +import { FiSettings, FiZap, FiSave } from 'react-icons/fi'; interface S3Config { endpoint: string; From 449dfc708e8a3a4ff22829d6198061c6f15eda12 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 13 Aug 2025 15:35:27 +0200 Subject: [PATCH 2/4] perf(frontend): start playlist data load immediately on selection; useLayoutEffect for faster playlist switch --- packages/frontend/src/App.tsx | 7 +++++- .../frontend/src/hooks/usePaginatedSongs.ts | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 6e16b9f..7df6c84 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -130,7 +130,8 @@ const RekordboxReader: React.FC = () => { loadNextPage, searchSongs, searchQuery, - refresh + refresh, + switchPlaylistImmediately } = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist }); // Export library to XML @@ -209,6 +210,10 @@ const RekordboxReader: React.FC = () => { // Clear selected song immediately to prevent stale state 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 if (name === "All Songs") { navigate("/", { replace: true }); diff --git a/packages/frontend/src/hooks/usePaginatedSongs.ts b/packages/frontend/src/hooks/usePaginatedSongs.ts index 88f8a95..1f4f45c 100644 --- a/packages/frontend/src/hooks/usePaginatedSongs.ts +++ b/packages/frontend/src/hooks/usePaginatedSongs.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'; import { api, type SongsResponse } from '../services/api'; import type { Song } from '../types/interfaces'; @@ -139,7 +139,7 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => { }, []); // Handle playlist changes - streamlined for immediate response - useEffect(() => { + useLayoutEffect(() => { if (previousPlaylistRef.current !== playlistName) { // Update refs immediately currentPlaylistRef.current = playlistName; @@ -160,6 +160,25 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => { } }, [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 + setSongs([]); + setTotalSongs(0); + setTotalDuration(undefined); + setHasMore(true); + setCurrentPage(1); + setError(null); + + // Start loading right away + loadPage(1, initialSearch, targetPlaylistName); + }, [initialSearch, loadPage, searchQuery]); + return { songs, loading, @@ -174,5 +193,6 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => { searchSongs, reset, refresh: () => loadPage(1) + , switchPlaylistImmediately }; }; \ No newline at end of file From ebc6f31d3298db44db71b203b93dd6bd1b63e411 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 13 Aug 2025 15:43:23 +0200 Subject: [PATCH 3/4] perf(frontend): set loading immediately on playlist switch for instant indicator --- packages/frontend/src/hooks/usePaginatedSongs.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/frontend/src/hooks/usePaginatedSongs.ts b/packages/frontend/src/hooks/usePaginatedSongs.ts index 1f4f45c..48710dc 100644 --- a/packages/frontend/src/hooks/usePaginatedSongs.ts +++ b/packages/frontend/src/hooks/usePaginatedSongs.ts @@ -168,6 +168,8 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => { currentSearchQueryRef.current = searchQuery; // Clear state for instant visual feedback + loadingRef.current = true; + setLoading(true); setSongs([]); setTotalSongs(0); setTotalDuration(undefined); From 4bab1ae3a2e493944ac23232498dca8c65e39599 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 13 Aug 2025 15:47:25 +0200 Subject: [PATCH 4/4] fix(frontend): avoid blocking fetch by not setting loadingRef in immediate switch --- packages/frontend/src/hooks/usePaginatedSongs.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/frontend/src/hooks/usePaginatedSongs.ts b/packages/frontend/src/hooks/usePaginatedSongs.ts index 48710dc..b5890fe 100644 --- a/packages/frontend/src/hooks/usePaginatedSongs.ts +++ b/packages/frontend/src/hooks/usePaginatedSongs.ts @@ -168,7 +168,6 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => { currentSearchQueryRef.current = searchQuery; // Clear state for instant visual feedback - loadingRef.current = true; setLoading(true); setSongs([]); setTotalSongs(0);