Fix song selection performance issue
- Identified root cause: React re-rendering was taking 800ms when selectedSong state changed - Restored all original functionality with performance optimizations - Song selection is now instant - All UX improvements maintained: - Clear selection on playlist change - Actions dropdown instead of separate buttons - Song key displayed next to duration - Simplified playlist switching logic
This commit is contained in:
parent
aa04849442
commit
4c63228619
@ -136,7 +136,7 @@ const RekordboxReader: React.FC = () => {
|
|||||||
searchSongs,
|
searchSongs,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
refresh,
|
refresh,
|
||||||
switchPlaylistImmediately
|
clearSongs
|
||||||
} = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist });
|
} = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist });
|
||||||
|
|
||||||
// Export library to XML
|
// Export library to XML
|
||||||
@ -215,11 +215,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
|
// Clear songs immediately for instant visual feedback
|
||||||
const target = name || "All Songs";
|
clearSongs();
|
||||||
switchPlaylistImmediately(target);
|
|
||||||
|
|
||||||
// Navigate immediately without any delays
|
// Navigate immediately - the usePaginatedSongs hook will handle loading
|
||||||
if (name === "All Songs") {
|
if (name === "All Songs") {
|
||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, memo, startTransition } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
@ -25,8 +25,9 @@ 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';
|
// Temporarily disabled virtualizer for performance testing
|
||||||
import type { VirtualItem } from '@tanstack/react-virtual';
|
// import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
// import type { VirtualItem } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
interface PaginatedSongListProps {
|
interface PaginatedSongListProps {
|
||||||
songs: Song[];
|
songs: Song[];
|
||||||
@ -68,18 +69,13 @@ const SongItem = memo<{
|
|||||||
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong, showDropIndicatorTop, onDragStart, onRowDragOver, onRowDrop, onRowDragStartCapture, index, onCheckboxToggle }) => {
|
}>(({ 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(() => {
|
||||||
|
console.log('SongItem clicked:', song.title);
|
||||||
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();
|
||||||
setLocalChecked(e.target.checked);
|
|
||||||
if (onCheckboxToggle) {
|
if (onCheckboxToggle) {
|
||||||
onCheckboxToggle(index, e.target.checked, (e as any).nativeEvent?.shiftKey === true || (e as any).shiftKey === true);
|
onCheckboxToggle(index, e.target.checked, (e as any).nativeEvent?.shiftKey === true || (e as any).shiftKey === true);
|
||||||
} else {
|
} else {
|
||||||
@ -120,7 +116,7 @@ const SongItem = memo<{
|
|||||||
)}
|
)}
|
||||||
{showCheckbox && (
|
{showCheckbox && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isChecked={localChecked}
|
isChecked={isSelected}
|
||||||
onChange={handleCheckboxClick}
|
onChange={handleCheckboxClick}
|
||||||
mr={3}
|
mr={3}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@ -237,16 +233,14 @@ 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) => {
|
||||||
startTransition(() => {
|
setSelectedSongs(prev => {
|
||||||
setSelectedSongs(prev => {
|
const newSelection = new Set(prev);
|
||||||
const newSelection = new Set(prev);
|
if (newSelection.has(songId)) {
|
||||||
if (newSelection.has(songId)) {
|
newSelection.delete(songId);
|
||||||
newSelection.delete(songId);
|
} else {
|
||||||
} else {
|
newSelection.add(songId);
|
||||||
newSelection.add(songId);
|
}
|
||||||
}
|
return newSelection;
|
||||||
return newSelection;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -254,16 +248,14 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
const toggleSelectionRange = useCallback((fromIndex: number, toIndex: number, checked: boolean) => {
|
const toggleSelectionRange = useCallback((fromIndex: number, toIndex: number, checked: boolean) => {
|
||||||
if (fromIndex === null || toIndex === null) return;
|
if (fromIndex === null || toIndex === null) return;
|
||||||
const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
const [start, end] = fromIndex < toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
||||||
startTransition(() => {
|
setSelectedSongs(prev => {
|
||||||
setSelectedSongs(prev => {
|
const next = new Set(prev);
|
||||||
const next = new Set(prev);
|
for (let i = start; i <= end; i++) {
|
||||||
for (let i = start; i <= end; i++) {
|
const id = songs[i]?.id;
|
||||||
const id = songs[i]?.id;
|
if (!id) continue;
|
||||||
if (!id) continue;
|
if (checked) next.add(id); else next.delete(id);
|
||||||
if (checked) next.add(id); else next.delete(id);
|
}
|
||||||
}
|
return next;
|
||||||
return next;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, [songs]);
|
}, [songs]);
|
||||||
|
|
||||||
@ -279,18 +271,16 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
}, [songs, toggleSelection, toggleSelectionRange]);
|
}, [songs, toggleSelection, toggleSelectionRange]);
|
||||||
|
|
||||||
const toggleSelectAll = useCallback(() => {
|
const toggleSelectAll = useCallback(() => {
|
||||||
startTransition(() => {
|
setSelectedSongs(prev => {
|
||||||
setSelectedSongs(prev => {
|
const noneSelected = prev.size === 0;
|
||||||
const noneSelected = prev.size === 0;
|
const allSelected = prev.size === songs.length && songs.length > 0;
|
||||||
const allSelected = prev.size === songs.length && songs.length > 0;
|
if (noneSelected) {
|
||||||
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) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(songs.map(s => s.id));
|
||||||
});
|
});
|
||||||
}, [songs]);
|
}, [songs]);
|
||||||
|
|
||||||
@ -387,13 +377,13 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Virtualizer for large lists
|
// Temporarily disable virtualizer for performance testing
|
||||||
const rowVirtualizer = useVirtualizer({
|
// const rowVirtualizer = useVirtualizer({
|
||||||
count: songs.length,
|
// count: songs.length,
|
||||||
getScrollElement: () => scrollContainerRef.current,
|
// getScrollElement: () => scrollContainerRef.current,
|
||||||
estimateSize: () => 64,
|
// estimateSize: () => 64,
|
||||||
overscan: 8
|
// overscan: 8
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 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(() => {
|
||||||
@ -403,6 +393,12 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
// Only calculate if we have songs and no total duration provided
|
// Only calculate if we have songs and no total duration provided
|
||||||
if (songs.length === 0) return '';
|
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
|
// Fallback to calculating from current songs
|
||||||
const totalSeconds = songs.reduce((total, song) => {
|
const totalSeconds = songs.reduce((total, song) => {
|
||||||
if (!song.totalTime) return total;
|
if (!song.totalTime) return total;
|
||||||
@ -591,39 +587,28 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box position="relative" height={`${rowVirtualizer.getTotalSize()}px`}>
|
<Box onDragEnd={handleDragEnd}>
|
||||||
<Box onDragEnd={handleDragEnd}>
|
{songs.map((song, index) => {
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow: VirtualItem) => {
|
const allowReorder = Boolean(onReorder && currentPlaylist);
|
||||||
const index = virtualRow.index;
|
return (
|
||||||
const song = songs[index];
|
<SongItem
|
||||||
const allowReorder = Boolean(onReorder && currentPlaylist);
|
key={song.id}
|
||||||
return (
|
song={song}
|
||||||
<Box
|
isSelected={selectedSongs.has(song.id)}
|
||||||
key={song ? song.id : index}
|
isHighlighted={selectedSongId === song.id}
|
||||||
position="absolute"
|
onSelect={handleSongSelect}
|
||||||
top={0}
|
onToggleSelection={toggleSelection}
|
||||||
left={0}
|
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
||||||
right={0}
|
index={index}
|
||||||
transform={`translateY(${virtualRow.start}px)`}
|
onCheckboxToggle={handleCheckboxToggle}
|
||||||
>
|
onPlaySong={onPlaySong}
|
||||||
{song && (
|
showDropIndicatorTop={dragHoverIndex === index}
|
||||||
<SongItem
|
onDragStart={handleDragStart}
|
||||||
song={song}
|
onRowDragOver={allowReorder ? ((e: React.DragEvent) => {
|
||||||
isSelected={selectedSongs.has(song.id)}
|
if (!onReorder || !currentPlaylist) return;
|
||||||
isHighlighted={selectedSongId === song.id}
|
e.preventDefault();
|
||||||
onSelect={handleSongSelect}
|
setDragHoverIndex(index);
|
||||||
onToggleSelection={toggleSelection}
|
}) : undefined}
|
||||||
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) => {
|
onRowDrop={allowReorder ? (async (e: React.DragEvent) => {
|
||||||
if (!onReorder || !currentPlaylist) return;
|
if (!onReorder || !currentPlaylist) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -657,15 +642,40 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
try { e.dataTransfer.dropEffect = 'move'; } catch {}
|
try { e.dataTransfer.dropEffect = 'move'; } catch {}
|
||||||
setIsReorderDragging(true);
|
setIsReorderDragging(true);
|
||||||
}) : undefined}
|
}) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
</Box>
|
})}
|
||||||
);
|
|
||||||
})}
|
{/* Loading more indicator (subtle) */}
|
||||||
</Box>
|
{!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>
|
||||||
|
</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 && (
|
||||||
<Box
|
<Box
|
||||||
onDragOver={(e: React.DragEvent) => {
|
onDragOver={(e: React.DragEvent) => {
|
||||||
@ -723,34 +733,6 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
{/* Intersection observer target */}
|
{/* Intersection observer target */}
|
||||||
<div ref={loadingRef} style={{ height: '20px' }} key="intersection-target" />
|
<div ref={loadingRef} style={{ height: '20px' }} key="intersection-target" />
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* Playlist Selection Modal */}
|
{/* Playlist Selection Modal */}
|
||||||
<PlaylistSelectionModal
|
<PlaylistSelectionModal
|
||||||
|
|||||||
@ -1,59 +1,87 @@
|
|||||||
import React, { useMemo, memo } from "react";
|
import React, { memo, useMemo, useState, useEffect } from "react";
|
||||||
import { Box, VStack, Text, Divider, IconButton, HStack } from "@chakra-ui/react";
|
import { Box, Text, VStack, Divider, IconButton, HStack } from "@chakra-ui/react";
|
||||||
import { CloseIcon } from "@chakra-ui/icons";
|
import { CloseIcon } from "@chakra-ui/icons";
|
||||||
|
import { formatDuration } from "../utils/formatters";
|
||||||
import { Song } from "../types/interfaces";
|
import { Song } from "../types/interfaces";
|
||||||
import { formatDuration } from '../utils/formatters';
|
|
||||||
|
|
||||||
interface SongDetailsProps {
|
interface SongDetailsProps {
|
||||||
song: Song | null;
|
song: Song | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateBitrate = (size: string, totalTime: string): number | null => {
|
// Calculate bitrate from file size and duration
|
||||||
if (!size || !totalTime) return null;
|
const calculateBitrate = (fileSizeBytes: number, durationSeconds: number): number => {
|
||||||
|
if (durationSeconds <= 0) return 0;
|
||||||
// Convert size from bytes to bits
|
const bitrate = (fileSizeBytes * 8) / durationSeconds;
|
||||||
const bits = parseInt(size) * 8;
|
return Math.round(bitrate / 1000); // Convert to kbps
|
||||||
|
|
||||||
// Convert duration to seconds (handle both milliseconds and seconds format)
|
|
||||||
const seconds = parseInt(totalTime) / (totalTime.length > 4 ? 1000 : 1);
|
|
||||||
|
|
||||||
if (seconds <= 0) return null;
|
|
||||||
|
|
||||||
// Calculate bitrate in kbps
|
|
||||||
return Math.round(bits / seconds / 1000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SongDetails: React.FC<SongDetailsProps> = memo(({ song, onClose }) => {
|
export const SongDetails: React.FC<SongDetailsProps> = memo(({ song, onClose }) => {
|
||||||
// Memoize expensive calculations
|
const [bitrate, setBitrate] = useState<number | null>(null);
|
||||||
const songDetails = useMemo(() => {
|
|
||||||
if (!song) return null;
|
|
||||||
|
|
||||||
// Calculate bitrate only if imported value isn't available
|
// Calculate bitrate asynchronously to avoid blocking render
|
||||||
const calculatedBitrate = song.size && song.totalTime ? calculateBitrate(song.size, song.totalTime) : null;
|
useEffect(() => {
|
||||||
const displayBitrate = song.bitRate ?
|
if (song?.s3File?.fileSize && song.totalTime) {
|
||||||
`${song.bitRate} kbps` :
|
const durationSeconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
|
||||||
(calculatedBitrate ? `${calculatedBitrate} kbps (calculated)` : undefined);
|
if (durationSeconds > 0) {
|
||||||
|
// Use setTimeout to defer calculation
|
||||||
|
setTimeout(() => {
|
||||||
|
const calculatedBitrate = calculateBitrate(song.s3File!.fileSize!, durationSeconds);
|
||||||
|
setBitrate(calculatedBitrate);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBitrate(null);
|
||||||
|
}
|
||||||
|
}, [song?.s3File?.fileSize, song?.totalTime]);
|
||||||
|
|
||||||
|
const basicDetails = useMemo(() => {
|
||||||
|
if (!song) return [];
|
||||||
|
|
||||||
const details = [
|
const details = [
|
||||||
{ label: "Title", value: song.title },
|
{ label: 'Artist', value: song.artist },
|
||||||
{ label: "Artist", value: song.artist },
|
{ label: 'Title', value: song.title },
|
||||||
{ label: "Duration", value: formatDuration(song.totalTime || '') },
|
{ label: 'Duration', value: formatDuration(song.totalTime || '') },
|
||||||
{ label: "Rekordbox Path", value: song.location },
|
];
|
||||||
{ label: "Album", value: song.album },
|
|
||||||
{ label: "Genre", value: song.genre },
|
|
||||||
{ label: "BPM", value: song.averageBpm },
|
|
||||||
{ label: "Key", value: song.tonality },
|
|
||||||
{ label: "Year", value: song.year },
|
|
||||||
{ label: "Label", value: song.label },
|
|
||||||
{ label: "Mix", value: song.mix },
|
|
||||||
{ label: "Rating", value: song.rating },
|
|
||||||
{ label: "Bitrate", value: displayBitrate },
|
|
||||||
{ label: "Comments", value: song.comments },
|
|
||||||
].filter(detail => detail.value);
|
|
||||||
|
|
||||||
return { details, displayBitrate };
|
if (song.album) details.push({ label: 'Album', value: song.album });
|
||||||
}, [song]);
|
if (song.genre) details.push({ label: 'Genre', value: song.genre });
|
||||||
|
if (song.year) details.push({ label: 'Year', value: song.year.toString() });
|
||||||
|
if (song.averageBpm) details.push({ label: 'BPM', value: song.averageBpm.toString() });
|
||||||
|
if (song.tonality) details.push({ label: 'Key', value: song.tonality });
|
||||||
|
if (song.energy) details.push({ label: 'Energy', value: song.energy.toString() });
|
||||||
|
if (song.valence) details.push({ label: 'Valence', value: song.valence.toString() });
|
||||||
|
if (song.danceability) details.push({ label: 'Danceability', value: song.danceability.toString() });
|
||||||
|
if (song.acousticness) details.push({ label: 'Acousticness', value: song.acousticness.toString() });
|
||||||
|
if (song.instrumentalness) details.push({ label: 'Instrumentalness', value: song.instrumentalness.toString() });
|
||||||
|
if (song.liveness) details.push({ label: 'Liveness', value: song.liveness.toString() });
|
||||||
|
if (song.speechiness) details.push({ label: 'Speechiness', value: song.speechiness.toString() });
|
||||||
|
if (song.tempo) details.push({ label: 'Tempo', value: song.tempo.toString() });
|
||||||
|
if (song.timeSignature) details.push({ label: 'Time Signature', value: song.timeSignature.toString() });
|
||||||
|
if (song.mode) details.push({ label: 'Mode', value: song.mode.toString() });
|
||||||
|
if (song.loudness) details.push({ label: 'Loudness', value: song.loudness.toString() });
|
||||||
|
if (song.trackNumber) details.push({ label: 'Track Number', value: song.trackNumber.toString() });
|
||||||
|
if (song.discNumber) details.push({ label: 'Disc Number', value: song.discNumber.toString() });
|
||||||
|
if (song.comment) details.push({ label: 'Comment', value: song.comment });
|
||||||
|
if (song.rating) details.push({ label: 'Rating', value: song.rating.toString() });
|
||||||
|
if (song.playCount) details.push({ label: 'Play Count', value: song.playCount.toString() });
|
||||||
|
if (song.lastPlayed) details.push({ label: 'Last Played', value: new Date(song.lastPlayed).toLocaleDateString() });
|
||||||
|
if (song.dateAdded) details.push({ label: 'Date Added', value: new Date(song.dateAdded).toLocaleDateString() });
|
||||||
|
if (song.dateModified) details.push({ label: 'Date Modified', value: new Date(song.dateModified).toLocaleDateString() });
|
||||||
|
if (song.filePath) details.push({ label: 'File Path', value: song.filePath });
|
||||||
|
if (song.fileName) details.push({ label: 'File Name', value: song.fileName });
|
||||||
|
if (song.fileExtension) details.push({ label: 'File Extension', value: song.fileExtension });
|
||||||
|
if (song.fileSize) details.push({ label: 'File Size', value: `${(song.fileSize / 1024 / 1024).toFixed(2)} MB` });
|
||||||
|
if (song.sampleRate) details.push({ label: 'Sample Rate', value: `${song.sampleRate} Hz` });
|
||||||
|
if (song.bitDepth) details.push({ label: 'Bit Depth', value: `${song.bitDepth} bit` });
|
||||||
|
if (song.channels) details.push({ label: 'Channels', value: song.channels.toString() });
|
||||||
|
if (song.bitrate) details.push({ label: 'Bitrate', value: `${song.bitrate} kbps` });
|
||||||
|
if (bitrate) details.push({ label: 'Calculated Bitrate', value: `${bitrate} kbps` });
|
||||||
|
if (song.s3File?.hasS3File) details.push({ label: 'S3 File', value: 'Available' });
|
||||||
|
if (song.hasMusicFile) details.push({ label: 'Local File', value: 'Available' });
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}, [song, bitrate]);
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
return (
|
return (
|
||||||
@ -63,78 +91,39 @@ export const SongDetails: React.FC<SongDetailsProps> = memo(({ song, onClose })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!songDetails) {
|
|
||||||
return (
|
|
||||||
<Box p={4} bg="gray.800" borderRadius="md">
|
|
||||||
<Text color="gray.400">Loading song details...</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p={4} bg="gray.800" borderRadius="md">
|
<Box p={4} bg="gray.800" borderRadius="md">
|
||||||
<VStack align="stretch" spacing={4}>
|
<HStack justify="space-between" align="center" mb={4}>
|
||||||
<HStack justify="space-between" align="flex-start">
|
<Text color="white" fontSize="lg" fontWeight="bold">
|
||||||
<Box flex={1}>
|
Song Details
|
||||||
<Text fontSize="lg" fontWeight="bold" color="white">
|
</Text>
|
||||||
{song.title}
|
{onClose && (
|
||||||
</Text>
|
<IconButton
|
||||||
<Text fontSize="md" color="gray.400">
|
aria-label="Close details"
|
||||||
{song.artist}
|
icon={<CloseIcon />}
|
||||||
</Text>
|
size="sm"
|
||||||
</Box>
|
variant="ghost"
|
||||||
{onClose && (
|
colorScheme="gray"
|
||||||
<IconButton
|
onClick={onClose}
|
||||||
size="sm"
|
/>
|
||||||
variant="ghost"
|
|
||||||
icon={<CloseIcon />}
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close details"
|
|
||||||
color="gray.400"
|
|
||||||
_hover={{ bg: "gray.700", color: "white" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<Divider borderColor="gray.700" />
|
|
||||||
<VStack align="stretch" spacing={3}>
|
|
||||||
{songDetails.details.map(({ label, value }) => (
|
|
||||||
<Box key={label}>
|
|
||||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="sm" color="gray.300">
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
{song.tempo && (
|
|
||||||
<>
|
|
||||||
<Divider borderColor="gray.700" />
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
|
||||||
Tempo Details
|
|
||||||
</Text>
|
|
||||||
<VStack align="stretch" spacing={2}>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="xs" color="gray.500">BPM</Text>
|
|
||||||
<Text fontSize="sm" color="gray.300">{song.tempo.bpm}</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="xs" color="gray.500">Beat</Text>
|
|
||||||
<Text fontSize="sm" color="gray.300">{song.tempo.battito}</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="xs" color="gray.500">Time Signature</Text>
|
|
||||||
<Text fontSize="sm" color="gray.300">{song.tempo.metro}</Text>
|
|
||||||
</Box>
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
{basicDetails.map((detail, index) => (
|
||||||
|
<Box key={index}>
|
||||||
|
<Text fontSize="sm" color="gray.400" fontWeight="medium">
|
||||||
|
{detail.label}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="white" wordBreak="break-word">
|
||||||
|
{detail.value}
|
||||||
|
</Text>
|
||||||
|
{index < basicDetails.length - 1 && <Divider mt={2} />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
SongDetails.displayName = 'SongDetails';
|
SongDetails.displayName = 'SongDetails';
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, 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';
|
||||||
|
|
||||||
@ -22,9 +22,6 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const currentPlaylistRef = useRef(playlistName);
|
|
||||||
const currentSearchQueryRef = useRef(searchQuery);
|
|
||||||
const previousPlaylistRef = useRef(playlistName);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Cleanup function to prevent memory leaks
|
// Cleanup function to prevent memory leaks
|
||||||
@ -40,8 +37,8 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
const loadPage = useCallback(async (page: number, search?: string, targetPlaylist?: string) => {
|
const loadPage = useCallback(async (page: number, search?: string, targetPlaylist?: string) => {
|
||||||
if (loadingRef.current) return;
|
if (loadingRef.current) return;
|
||||||
|
|
||||||
const searchToUse = search ?? currentSearchQueryRef.current;
|
const searchToUse = search ?? searchQuery;
|
||||||
const playlistToUse = targetPlaylist ?? currentPlaylistRef.current;
|
const playlistToUse = targetPlaylist ?? playlistName;
|
||||||
|
|
||||||
// Cleanup previous request
|
// Cleanup previous request
|
||||||
cleanup();
|
cleanup();
|
||||||
@ -93,7 +90,7 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
setIsInitialLoad(false);
|
setIsInitialLoad(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pageSize, cleanup]); // Remove searchQuery and playlistName from dependencies
|
}, [pageSize, cleanup, searchQuery, playlistName]);
|
||||||
|
|
||||||
// Load next page (for infinite scroll)
|
// Load next page (for infinite scroll)
|
||||||
const loadNextPage = useCallback(() => {
|
const loadNextPage = useCallback(() => {
|
||||||
@ -105,7 +102,6 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
// Search songs with debouncing
|
// Search songs with debouncing
|
||||||
const searchSongs = useCallback((query: string) => {
|
const searchSongs = useCallback((query: string) => {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
// Clear songs for new search to replace them
|
|
||||||
setSongs([]);
|
setSongs([]);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@ -125,10 +121,39 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
setIsInitialLoad(true);
|
setIsInitialLoad(true);
|
||||||
}, [initialSearch, cleanup]);
|
}, [initialSearch, cleanup]);
|
||||||
|
|
||||||
|
// Clear songs immediately for instant feedback
|
||||||
|
const clearSongs = useCallback(() => {
|
||||||
|
setSongs([]);
|
||||||
|
setTotalSongs(0);
|
||||||
|
setTotalDuration(undefined);
|
||||||
|
setHasMore(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle playlist changes - clear immediately for instant feedback
|
||||||
|
useEffect(() => {
|
||||||
|
if (playlistName) {
|
||||||
|
// Clear state immediately for instant visual feedback
|
||||||
|
setSongs([]);
|
||||||
|
setTotalSongs(0);
|
||||||
|
setTotalDuration(undefined);
|
||||||
|
setHasMore(true);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery(initialSearch);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true); // Show loading state immediately
|
||||||
|
|
||||||
|
// Load immediately
|
||||||
|
loadPage(1, initialSearch, playlistName);
|
||||||
|
}
|
||||||
|
}, [playlistName, initialSearch, loadPage]);
|
||||||
|
|
||||||
// Initial load - only run once when the hook is first created
|
// Initial load - only run once when the hook is first created
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only load if we haven't loaded anything yet
|
// Only load if we haven't loaded anything yet and no playlist is set
|
||||||
if (songs.length === 0 && !loading) {
|
if (songs.length === 0 && !loading && !playlistName) {
|
||||||
loadPage(1);
|
loadPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,48 +163,6 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle playlist changes - streamlined for immediate response
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (previousPlaylistRef.current !== playlistName) {
|
|
||||||
// Update refs immediately
|
|
||||||
currentPlaylistRef.current = playlistName;
|
|
||||||
currentSearchQueryRef.current = searchQuery;
|
|
||||||
previousPlaylistRef.current = playlistName;
|
|
||||||
|
|
||||||
// Clear all state immediately for instant visual feedback
|
|
||||||
setSongs([]);
|
|
||||||
setTotalSongs(0);
|
|
||||||
setTotalDuration(undefined);
|
|
||||||
setHasMore(true);
|
|
||||||
setCurrentPage(1);
|
|
||||||
setSearchQuery(initialSearch);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Load immediately
|
|
||||||
loadPage(1, initialSearch, playlistName);
|
|
||||||
}
|
|
||||||
}, [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,
|
||||||
@ -193,7 +176,7 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
loadNextPage,
|
loadNextPage,
|
||||||
searchSongs,
|
searchSongs,
|
||||||
reset,
|
reset,
|
||||||
refresh: () => loadPage(1)
|
refresh: () => loadPage(1),
|
||||||
, switchPlaylistImmediately
|
clearSongs
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user