Compare commits
10 Commits
2e32a3c3b6
...
e8bb2a4326
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8bb2a4326 | ||
|
|
08de2afa0e | ||
|
|
535dc16d2c | ||
|
|
743ed6a54e | ||
|
|
02ae12294c | ||
|
|
54bffbc25d | ||
|
|
65120ff654 | ||
|
|
50f29b900e | ||
|
|
5fb878a0b0 | ||
|
|
4f16c859db |
@ -148,6 +148,15 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
const totalSongs = await Song.countDocuments(query);
|
const totalSongs = await Song.countDocuments(query);
|
||||||
const totalPages = Math.ceil(totalSongs / limit);
|
const totalPages = Math.ceil(totalSongs / limit);
|
||||||
|
|
||||||
|
// Calculate total duration for the entire playlist
|
||||||
|
const allPlaylistSongs = await Song.find({ id: { $in: trackIds } }).lean();
|
||||||
|
const totalDuration = allPlaylistSongs.reduce((total, song: any) => {
|
||||||
|
if (!song.totalTime) return total;
|
||||||
|
const totalTimeStr = String(song.totalTime);
|
||||||
|
const seconds = Math.floor(Number(totalTimeStr) / (totalTimeStr.length > 4 ? 1000 : 1));
|
||||||
|
return total + seconds;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// Get songs with pagination
|
// Get songs with pagination
|
||||||
const songs = await Song.find(query)
|
const songs = await Song.find(query)
|
||||||
.sort({ title: 1 })
|
.sort({ title: 1 })
|
||||||
@ -166,7 +175,8 @@ router.get('/playlist/*', async (req: Request, res: Response) => {
|
|||||||
totalPages,
|
totalPages,
|
||||||
hasNextPage: page < totalPages,
|
hasNextPage: page < totalPages,
|
||||||
hasPrevPage: page > 1
|
hasPrevPage: page > 1
|
||||||
}
|
},
|
||||||
|
totalDuration: totalDuration
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching playlist songs:', error);
|
console.error('Error fetching playlist songs:', error);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { SongDetails } from "./components/SongDetails";
|
|||||||
import { Configuration } from "./pages/Configuration";
|
import { Configuration } from "./pages/Configuration";
|
||||||
import { useXmlParser } from "./hooks/useXmlParser";
|
import { useXmlParser } from "./hooks/useXmlParser";
|
||||||
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
|
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
|
||||||
|
import { formatTotalDuration } from "./utils/formatters";
|
||||||
|
|
||||||
import { api } from "./services/api";
|
import { api } from "./services/api";
|
||||||
import type { Song, PlaylistNode } from "./types/interfaces";
|
import type { Song, PlaylistNode } from "./types/interfaces";
|
||||||
@ -56,7 +57,7 @@ const findPlaylistByName = (playlists: PlaylistNode[], name: string): PlaylistNo
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
|
const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
|
||||||
if (!node.type || node.type === 'playlist') { // Handle both old and new playlist formats
|
if (!node.type || node.type === 'playlist') { // Handle both old and new playlist formats
|
||||||
return node.tracks || [];
|
return node.tracks || [];
|
||||||
}
|
}
|
||||||
@ -64,22 +65,32 @@ const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
|
|||||||
return node.children.flatMap(child => getAllPlaylistTracks(child));
|
return node.children.flatMap(child => getAllPlaylistTracks(child));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RekordboxReader() {
|
export default function RekordboxReader() {
|
||||||
const { playlists, setPlaylists, loading: xmlLoading } = useXmlParser();
|
const { playlists, setPlaylists, loading: xmlLoading } = useXmlParser();
|
||||||
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
|
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
|
||||||
|
const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false);
|
||||||
|
|
||||||
// Memoized song selection handler to prevent unnecessary re-renders
|
// Memoized song selection handler to prevent unnecessary re-renders
|
||||||
const handleSongSelect = useCallback((song: Song) => {
|
const handleSongSelect = useCallback((song: Song) => {
|
||||||
setSelectedSong(song);
|
setSelectedSong(song);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Format total duration for display
|
||||||
|
const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => {
|
||||||
|
if (!durationSeconds) return "";
|
||||||
|
return formatTotalDuration(durationSeconds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const initialLoadDone = useRef(false);
|
const initialLoadDone = useRef(false);
|
||||||
|
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const { isOpen: isWelcomeOpen, onClose: onWelcomeClose } = useDisclosure({ defaultIsOpen: true });
|
const { isOpen: isWelcomeOpen, onOpen: onWelcomeOpen, onClose: onWelcomeClose } = useDisclosure({ defaultIsOpen: false });
|
||||||
|
|
||||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(400);
|
const [sidebarWidth, setSidebarWidth] = useState(400);
|
||||||
@ -96,11 +107,38 @@ export default function RekordboxReader() {
|
|||||||
loading: songsLoading,
|
loading: songsLoading,
|
||||||
hasMore,
|
hasMore,
|
||||||
totalSongs,
|
totalSongs,
|
||||||
|
totalDuration,
|
||||||
loadNextPage,
|
loadNextPage,
|
||||||
searchSongs,
|
searchSongs,
|
||||||
searchQuery
|
searchQuery
|
||||||
} = usePaginatedSongs({ pageSize: 50, playlistName: currentPlaylist });
|
} = usePaginatedSongs({ pageSize: 50, playlistName: currentPlaylist });
|
||||||
|
|
||||||
|
// Check if database is initialized (has songs or playlists) - moved after useDisclosure
|
||||||
|
useEffect(() => {
|
||||||
|
const checkDatabaseInitialized = async () => {
|
||||||
|
try {
|
||||||
|
// Check if there are any songs in the database
|
||||||
|
const songCount = await api.getSongCount();
|
||||||
|
const hasPlaylists = playlists.length > 0;
|
||||||
|
const isInitialized = songCount > 0 || hasPlaylists;
|
||||||
|
setIsDatabaseInitialized(isInitialized);
|
||||||
|
|
||||||
|
// Only show welcome modal if database is truly empty
|
||||||
|
if (!isInitialized) {
|
||||||
|
onWelcomeOpen();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If we can't get the song count, assume database is not initialized
|
||||||
|
setIsDatabaseInitialized(false);
|
||||||
|
onWelcomeOpen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!xmlLoading) {
|
||||||
|
checkDatabaseInitialized();
|
||||||
|
}
|
||||||
|
}, [xmlLoading, playlists.length, onWelcomeOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run this check after the initial data load
|
// Only run this check after the initial data load
|
||||||
if (!xmlLoading && playlists.length > 0) {
|
if (!xmlLoading && playlists.length > 0) {
|
||||||
@ -313,16 +351,11 @@ export default function RekordboxReader() {
|
|||||||
};
|
};
|
||||||
}, [isResizing]);
|
}, [isResizing]);
|
||||||
|
|
||||||
if (xmlLoading || songsLoading) {
|
if (xmlLoading) {
|
||||||
return (
|
return (
|
||||||
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
|
<Flex height="100vh" align="center" justify="center" direction="column" gap={4}>
|
||||||
<Spinner size="xl" />
|
<Spinner size="xl" />
|
||||||
<Text>Loading your library...</Text>
|
<Text>Loading your library...</Text>
|
||||||
{currentPlaylist !== "All Songs" && (
|
|
||||||
<Text fontSize="sm" color="gray.500">
|
|
||||||
Navigating to playlist: {currentPlaylist}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -355,7 +388,7 @@ export default function RekordboxReader() {
|
|||||||
userSelect={isResizing ? 'none' : 'auto'}
|
userSelect={isResizing ? 'none' : 'auto'}
|
||||||
>
|
>
|
||||||
{/* Welcome Modal */}
|
{/* Welcome Modal */}
|
||||||
{!xmlLoading && !songsLoading && songs.length === 0 && (
|
{!xmlLoading && !isDatabaseInitialized && (
|
||||||
<Modal isOpen={isWelcomeOpen} onClose={onWelcomeClose} isCentered>
|
<Modal isOpen={isWelcomeOpen} onClose={onWelcomeClose} isCentered>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent bg="gray.800" maxW="md">
|
<ModalContent bg="gray.800" maxW="md">
|
||||||
@ -510,6 +543,7 @@ export default function RekordboxReader() {
|
|||||||
loading={songsLoading}
|
loading={songsLoading}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
totalSongs={totalSongs}
|
totalSongs={totalSongs}
|
||||||
|
totalPlaylistDuration={getFormattedTotalDuration(totalDuration)}
|
||||||
onLoadMore={loadNextPage}
|
onLoadMore={loadNextPage}
|
||||||
onSearch={searchSongs}
|
onSearch={searchSongs}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
|||||||
@ -34,6 +34,7 @@ interface PaginatedSongListProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
totalSongs: number;
|
totalSongs: number;
|
||||||
|
totalPlaylistDuration?: string; // Total duration of the entire playlist
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@ -111,6 +112,7 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
loading,
|
loading,
|
||||||
hasMore,
|
hasMore,
|
||||||
totalSongs,
|
totalSongs,
|
||||||
|
totalPlaylistDuration,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onSearch,
|
onSearch,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@ -121,6 +123,20 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
const loadingRef = useRef<HTMLDivElement>(null);
|
const loadingRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isTriggeringRef = useRef(false);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Store current values in refs to avoid stale closures
|
||||||
|
const hasMoreRef = useRef(hasMore);
|
||||||
|
const loadingRef_state = useRef(loading);
|
||||||
|
const onLoadMoreRef = useRef(onLoadMore);
|
||||||
|
|
||||||
|
// Update refs when props change
|
||||||
|
useEffect(() => {
|
||||||
|
hasMoreRef.current = hasMore;
|
||||||
|
loadingRef_state.current = loading;
|
||||||
|
onLoadMoreRef.current = onLoadMore;
|
||||||
|
}, [hasMore, loading, onLoadMore]);
|
||||||
|
|
||||||
// Debounce search to prevent excessive API calls
|
// Debounce search to prevent excessive API calls
|
||||||
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
|
const debouncedSearchQuery = useDebounce(localSearchQuery, 300);
|
||||||
@ -199,15 +215,19 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
));
|
));
|
||||||
}, [songs, selectedSongs, selectedSongId, handleSongSelect, toggleSelection, depth]);
|
}, [songs, selectedSongs, selectedSongId, handleSongSelect, toggleSelection, depth]);
|
||||||
|
|
||||||
// Memoized total duration calculation
|
// Use total playlist duration if available, otherwise calculate from current songs
|
||||||
const totalDuration = useMemo(() => {
|
const totalDuration = useMemo(() => {
|
||||||
|
if (totalPlaylistDuration) {
|
||||||
|
return totalPlaylistDuration;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
|
const seconds = Math.floor(Number(song.totalTime) / (song.totalTime.length > 4 ? 1000 : 1));
|
||||||
return total + seconds;
|
return total + seconds;
|
||||||
}, 0);
|
}, 0);
|
||||||
return formatTotalDuration(totalSeconds);
|
return formatTotalDuration(totalSeconds);
|
||||||
}, [songs]);
|
}, [songs, totalPlaylistDuration]);
|
||||||
|
|
||||||
// Memoized playlist options for bulk actions
|
// Memoized playlist options for bulk actions
|
||||||
const playlistOptions = useMemo(() => {
|
const playlistOptions = useMemo(() => {
|
||||||
@ -230,8 +250,14 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
if (loadingRef.current) {
|
if (loadingRef.current) {
|
||||||
observerRef.current = new IntersectionObserver(
|
observerRef.current = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
// Use current values from refs to avoid stale closure
|
||||||
onLoadMore();
|
if (entries[0].isIntersecting && hasMoreRef.current && !loadingRef_state.current && !isTriggeringRef.current) {
|
||||||
|
isTriggeringRef.current = true;
|
||||||
|
onLoadMoreRef.current();
|
||||||
|
// Reset the flag after a short delay to prevent multiple triggers
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
isTriggeringRef.current = false;
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -247,8 +273,11 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
if (observerRef.current) {
|
if (observerRef.current) {
|
||||||
observerRef.current.disconnect();
|
observerRef.current.disconnect();
|
||||||
}
|
}
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [hasMore, loading, onLoadMore]);
|
}, []); // Remove dependencies to prevent recreation
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" height="100%">
|
<Flex direction="column" height="100%">
|
||||||
@ -298,6 +327,11 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Text color="gray.400" fontSize="sm">
|
<Text color="gray.400" fontSize="sm">
|
||||||
{songs.length} of {totalSongs} songs • {totalDuration}
|
{songs.length} of {totalSongs} songs • {totalDuration}
|
||||||
|
{hasMore && songs.length > 0 && (
|
||||||
|
<Text as="span" color="blue.400" ml={2}>
|
||||||
|
• Scroll for more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
@ -354,14 +388,26 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
|
|
||||||
{/* Loading indicator for infinite scroll */}
|
{/* Loading indicator for infinite scroll */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<Flex justify="center" p={4} key="loading-spinner">
|
<Flex justify="center" align="center" p={6} key="loading-spinner" gap={3}>
|
||||||
<Spinner size="md" color="blue.400" />
|
<Spinner size="md" color="blue.400" />
|
||||||
|
<Text color="gray.400" fontSize="sm">
|
||||||
|
Loading more songs...
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 */}
|
{/* End of results message */}
|
||||||
{!hasMore && songs.length > 0 && (
|
{!hasMore && songs.length > 0 && (
|
||||||
<Flex justify="center" p={4} key="end-message">
|
<Flex justify="center" p={4} key="end-message">
|
||||||
|
|||||||
@ -78,7 +78,7 @@ const PlaylistItem: React.FC<PlaylistItemProps> = ({
|
|||||||
allFolders,
|
allFolders,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const indent = level * 10;
|
const indent = level * 10; // Reverted back to 10px per level
|
||||||
|
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
return (
|
return (
|
||||||
@ -89,7 +89,9 @@ const PlaylistItem: React.FC<PlaylistItemProps> = ({
|
|||||||
{...getButtonStyles(false)}
|
{...getButtonStyles(false)}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
ml={indent}
|
ml={indent}
|
||||||
|
pl={level > 0 ? 6 : 4} // Add extra padding for nested items
|
||||||
justifyContent="flex-start"
|
justifyContent="flex-start"
|
||||||
|
position="relative"
|
||||||
leftIcon={
|
leftIcon={
|
||||||
<Box position="relative" display="flex" alignItems="center">
|
<Box position="relative" display="flex" alignItems="center">
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon
|
||||||
@ -102,6 +104,18 @@ const PlaylistItem: React.FC<PlaylistItemProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{level > 0 && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
left="8px"
|
||||||
|
top="50%"
|
||||||
|
transform="translateY(-50%)"
|
||||||
|
width="2px"
|
||||||
|
height="12px"
|
||||||
|
bg="gray.500"
|
||||||
|
borderRadius="1px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Icon
|
<Icon
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
color="blue.300"
|
color="blue.300"
|
||||||
@ -143,10 +157,24 @@ const PlaylistItem: React.FC<PlaylistItemProps> = ({
|
|||||||
{...getButtonStyles(selectedItem === node.name)}
|
{...getButtonStyles(selectedItem === node.name)}
|
||||||
onClick={() => onPlaylistSelect(node.name)}
|
onClick={() => onPlaylistSelect(node.name)}
|
||||||
ml={indent}
|
ml={indent}
|
||||||
|
pl={level > 0 ? 6 : 4} // Add extra padding for nested items
|
||||||
borderRightRadius={0}
|
borderRightRadius={0}
|
||||||
borderRight="1px solid"
|
borderRight="1px solid"
|
||||||
borderRightColor="whiteAlpha.200"
|
borderRightColor="whiteAlpha.200"
|
||||||
|
position="relative"
|
||||||
>
|
>
|
||||||
|
{level > 0 && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
left="8px"
|
||||||
|
top="50%"
|
||||||
|
transform="translateY(-50%)"
|
||||||
|
width="2px"
|
||||||
|
height="12px"
|
||||||
|
bg="gray.500"
|
||||||
|
borderRadius="1px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{node.name}
|
{node.name}
|
||||||
</Button>
|
</Button>
|
||||||
<Menu>
|
<Menu>
|
||||||
@ -266,21 +294,9 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
|
|||||||
>
|
>
|
||||||
All Songs
|
All Songs
|
||||||
</Button>
|
</Button>
|
||||||
{playlists.map((node, index) => (
|
|
||||||
<PlaylistItem
|
|
||||||
key={node.id || index}
|
|
||||||
node={node}
|
|
||||||
level={0}
|
|
||||||
selectedItem={selectedItem}
|
|
||||||
onPlaylistSelect={onPlaylistSelect}
|
|
||||||
onPlaylistDelete={onPlaylistDelete}
|
|
||||||
onPlaylistMove={onPlaylistMove}
|
|
||||||
allFolders={allFolders}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Flex gap={2}>
|
{/* Add Playlist and Folder buttons at the top */}
|
||||||
|
<Flex gap={2} mb={2}>
|
||||||
<Button
|
<Button
|
||||||
onClick={onPlaylistModalOpen}
|
onClick={onPlaylistModalOpen}
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
@ -311,6 +327,20 @@ export const PlaylistManager: React.FC<PlaylistManagerProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{playlists.map((node, index) => (
|
||||||
|
<PlaylistItem
|
||||||
|
key={node.id || index}
|
||||||
|
node={node}
|
||||||
|
level={0}
|
||||||
|
selectedItem={selectedItem}
|
||||||
|
onPlaylistSelect={onPlaylistSelect}
|
||||||
|
onPlaylistDelete={onPlaylistDelete}
|
||||||
|
onPlaylistMove={onPlaylistMove}
|
||||||
|
allFolders={allFolders}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
{/* New Playlist Modal */}
|
{/* New Playlist Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isPlaylistModalOpen}
|
isOpen={isPlaylistModalOpen}
|
||||||
|
|||||||
@ -18,10 +18,13 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||||
const [totalSongs, setTotalSongs] = useState(0);
|
const [totalSongs, setTotalSongs] = useState(0);
|
||||||
|
const [totalDuration, setTotalDuration] = useState<number | undefined>(undefined);
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
const currentPlaylistRef = useRef(playlistName);
|
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
|
||||||
@ -34,10 +37,11 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load songs for a specific page
|
// Load songs for a specific page
|
||||||
const loadPage = useCallback(async (page: number, search: string = searchQuery, targetPlaylist?: string) => {
|
const loadPage = useCallback(async (page: number, search?: string, targetPlaylist?: string) => {
|
||||||
if (loadingRef.current) return;
|
if (loadingRef.current) return;
|
||||||
|
|
||||||
const playlistToUse = targetPlaylist || playlistName;
|
const searchToUse = search ?? currentSearchQueryRef.current;
|
||||||
|
const playlistToUse = targetPlaylist ?? currentPlaylistRef.current;
|
||||||
|
|
||||||
// Cleanup previous request
|
// Cleanup previous request
|
||||||
cleanup();
|
cleanup();
|
||||||
@ -54,10 +58,10 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
|
|
||||||
if (playlistToUse && playlistToUse !== 'All Songs') {
|
if (playlistToUse && playlistToUse !== 'All Songs') {
|
||||||
// Load songs for specific playlist
|
// Load songs for specific playlist
|
||||||
response = await api.getPlaylistSongsPaginated(playlistToUse, page, pageSize, search);
|
response = await api.getPlaylistSongsPaginated(playlistToUse, page, pageSize, searchToUse);
|
||||||
} else {
|
} else {
|
||||||
// Load all songs
|
// Load all songs
|
||||||
response = await api.getSongsPaginated(page, pageSize, search);
|
response = await api.getSongsPaginated(page, pageSize, searchToUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if request was aborted
|
// Check if request was aborted
|
||||||
@ -75,6 +79,7 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
|
|
||||||
setHasMore(response.pagination.hasNextPage);
|
setHasMore(response.pagination.hasNextPage);
|
||||||
setTotalSongs(response.pagination.totalSongs);
|
setTotalSongs(response.pagination.totalSongs);
|
||||||
|
setTotalDuration(response.totalDuration);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Don't set error if request was aborted
|
// Don't set error if request was aborted
|
||||||
@ -88,7 +93,7 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
setIsInitialLoad(false);
|
setIsInitialLoad(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pageSize, searchQuery, playlistName, cleanup]);
|
}, [pageSize, cleanup]); // Remove searchQuery and playlistName from dependencies
|
||||||
|
|
||||||
// Load next page (for infinite scroll)
|
// Load next page (for infinite scroll)
|
||||||
const loadNextPage = useCallback(() => {
|
const loadNextPage = useCallback(() => {
|
||||||
@ -100,7 +105,8 @@ 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);
|
||||||
// Don't clear songs immediately - let the new search results replace them
|
// Clear songs for new search to replace them
|
||||||
|
setSongs([]);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -131,10 +137,14 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
|
|
||||||
// Handle playlist changes
|
// Handle playlist changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPlaylistRef.current !== playlistName) {
|
if (!isInitialLoad && previousPlaylistRef.current !== playlistName) {
|
||||||
|
// Update refs
|
||||||
currentPlaylistRef.current = playlistName;
|
currentPlaylistRef.current = playlistName;
|
||||||
if (!isInitialLoad) {
|
currentSearchQueryRef.current = searchQuery;
|
||||||
// Don't clear songs immediately - let the new playlist results replace them
|
previousPlaylistRef.current = playlistName;
|
||||||
|
|
||||||
|
// Clear songs for new playlist to replace them
|
||||||
|
setSongs([]);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchQuery(initialSearch);
|
setSearchQuery(initialSearch);
|
||||||
@ -144,7 +154,6 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
loadPage(1, initialSearch, playlistName);
|
loadPage(1, initialSearch, playlistName);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [playlistName, isInitialLoad, initialSearch, loadPage]);
|
}, [playlistName, isInitialLoad, initialSearch, loadPage]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -153,6 +162,7 @@ export const usePaginatedSongs = (options: UsePaginatedSongsOptions = {}) => {
|
|||||||
error,
|
error,
|
||||||
hasMore,
|
hasMore,
|
||||||
totalSongs,
|
totalSongs,
|
||||||
|
totalDuration,
|
||||||
currentPage,
|
currentPage,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
isInitialLoad,
|
isInitialLoad,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface PaginationInfo {
|
|||||||
export interface SongsResponse {
|
export interface SongsResponse {
|
||||||
songs: Song[];
|
songs: Song[];
|
||||||
pagination: PaginationInfo;
|
pagination: PaginationInfo;
|
||||||
|
totalDuration?: number; // Total duration of the entire playlist in seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
class Api {
|
class Api {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user