718 lines
23 KiB
TypeScript

import { Box, Button, Flex, Heading, Spinner, Text, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack, useToast } from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon, SettingsIcon, DownloadIcon } from "@chakra-ui/icons";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useNavigate, useLocation, Routes, Route } from "react-router-dom";
import { PaginatedSongList } from "./components/PaginatedSongList";
import { PlaylistManager } from "./components/PlaylistManager";
import { SongDetails } from "./components/SongDetails";
import { Configuration } from "./pages/Configuration";
import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer";
import { BackgroundJobProgress } from "./components/BackgroundJobProgress";
import { MusicPlayerProvider, useMusicPlayer } from "./contexts/MusicPlayerContext";
import { useXmlParser } from "./hooks/useXmlParser";
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
import { formatTotalDuration } from "./utils/formatters";
import { api } from "./services/api";
import type { Song, PlaylistNode } from "./types/interfaces";
import { v4 as uuidv4 } from "uuid";
import "./App.css";
const ResizeHandle = ({ onMouseDown }: { onMouseDown: (e: React.MouseEvent) => void }) => (
<Box
position="absolute"
right="-4px"
top={0}
bottom={0}
width="8px"
cursor="col-resize"
zIndex={1}
_hover={{
'&::after': {
bg: 'blue.500',
}
}}
onMouseDown={onMouseDown}
>
<Box
position="absolute"
left="3px"
top={0}
bottom={0}
width="2px"
bg="gray.600"
transition="background-color 0.2s"
_hover={{ bg: 'blue.500' }}
/>
</Box>
);
const findPlaylistByName = (playlists: PlaylistNode[], name: string): PlaylistNode | null => {
for (const playlist of playlists) {
if (playlist.name === name) return playlist;
if (playlist.type === 'folder' && playlist.children) {
const found = findPlaylistByName(playlist.children, name);
if (found) return found;
}
}
return null;
};
const getAllPlaylistTracks = (node: PlaylistNode): string[] => {
if (!node.type || node.type === 'playlist') { // Handle both old and new playlist formats
return node.tracks || [];
}
if (node.type === 'folder' && node.children) {
return node.children.flatMap(child => getAllPlaylistTracks(child));
}
return [];
};
const RekordboxReader: React.FC = () => {
const { playlists, setPlaylists, loading: xmlLoading } = useXmlParser();
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false);
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
const { currentSong, playSong, closePlayer } = useMusicPlayer();
const toast = useToast();
// Memoized song selection handler to prevent unnecessary re-renders
const handleSongSelect = useCallback((song: Song) => {
setSelectedSong(song);
}, []);
// Handle playing a song from the main view
const handlePlaySong = useCallback((song: Song) => {
// Check if song has S3 file
if (song.s3File?.hasS3File) {
playSong(song);
}
}, [playSong]);
// Handle closing the music player
const handleCloseMusicPlayer = useCallback(() => {
closePlayer();
}, [closePlayer]);
// Format total duration for display
const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => {
if (!durationSeconds) return "";
return formatTotalDuration(durationSeconds);
}, []);
const navigate = useNavigate();
const location = useLocation();
const initialLoadDone = useRef(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isWelcomeOpen, onOpen: onWelcomeOpen, onClose: onWelcomeClose } = useDisclosure({ defaultIsOpen: false });
const isMobile = useBreakpointValue({ base: true, md: false });
const [sidebarWidth, setSidebarWidth] = useState(400);
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
// Get the current playlist from URL or default to "All Songs"
const currentPlaylist = location.pathname.startsWith("/playlists/")
? decodeURIComponent(location.pathname.slice("/playlists/".length))
: "All Songs";
const {
songs,
loading: songsLoading,
hasMore,
totalSongs,
totalDuration,
loadNextPage,
searchSongs,
searchQuery
} = usePaginatedSongs({ pageSize: 100, playlistName: currentPlaylist });
// Export library to XML
const handleExportLibrary = useCallback(async () => {
try {
const response = await fetch('/api/songs/export');
if (!response.ok) {
throw new Error('Failed to export library');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rekordbox-library-${new Date().toISOString().split('T')[0]}.xml`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export library:', error);
}
}, []);
// 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(() => {
// Only run this check after the initial data load
if (!xmlLoading && playlists.length > 0) {
initialLoadDone.current = true;
}
// If we've loaded the data and the playlist doesn't exist
if (initialLoadDone.current &&
currentPlaylist !== "All Songs" &&
!findPlaylistByName(playlists, currentPlaylist)) {
navigate("/", { replace: true });
}
}, [currentPlaylist, playlists, navigate, xmlLoading]);
// Reset switching state when loading starts (immediate transition)
useEffect(() => {
if (songsLoading && isSwitchingPlaylist) {
setIsSwitchingPlaylist(false);
}
}, [songsLoading, isSwitchingPlaylist]);
const handlePlaylistSelect = (name: string) => {
// Set switching state immediately for visual feedback
setIsSwitchingPlaylist(true);
// Clear selected song immediately to prevent stale state
setSelectedSong(null);
// Navigate immediately without any delays
if (name === "All Songs") {
navigate("/", { replace: true });
} else {
const encodedName = encodeURIComponent(name);
navigate(`/playlists/${encodedName}`, { replace: true });
}
};
const handleCreatePlaylist = async (name: string) => {
const newPlaylist: PlaylistNode = {
id: uuidv4(),
name,
type: 'playlist',
tracks: [],
children: undefined
};
const updatedPlaylists = [...playlists, newPlaylist];
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
handlePlaylistSelect(name); // Navigate to the new playlist
};
const handleAddSongsToPlaylist = async (songIds: string[], playlistName: string) => {
const updatedPlaylists = playlists.map(node => {
if (node.name === playlistName && node.type === 'playlist') {
return {
...node,
tracks: [...new Set([...(node.tracks || []), ...songIds])]
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: node.children.map(child => {
if (child.name === playlistName && child.type === 'playlist') {
return {
...child,
tracks: [...new Set([...(child.tracks || []), ...songIds])]
};
}
return child;
})
};
}
return node;
});
await api.savePlaylists(updatedPlaylists);
// Always normalize state to structure for consistent counters
const structure = await api.getPlaylistStructure();
setPlaylists(structure);
};
// Handle drop from song list into playlist (with duplicate check and user choice)
const handleDropSongsToPlaylist = async (playlistName: string, songIds: string[]) => {
// Find target playlist current tracks
const findNode = (nodes: PlaylistNode[]): PlaylistNode | null => {
for (const n of nodes) {
if (n.name === playlistName && n.type === 'playlist') return n;
if (n.type === 'folder' && n.children) {
const found = findNode(n.children);
if (found) return found;
}
}
return null;
};
const target = findNode(playlists);
if (!target) return;
const existing = new Set(target?.tracks || []);
const dupes = songIds.filter(id => existing.has(id));
let proceedMode: 'skip' | 'allow' = 'skip';
if (dupes.length > 0) {
// Simple confirm flow: OK = allow duplicates, Cancel = skip duplicates
const allow = window.confirm(`${dupes.length} duplicate${dupes.length>1?'s':''} detected in "${playlistName}". Press OK to add anyway (allow duplicates) or Cancel to skip duplicates.`);
proceedMode = allow ? 'allow' : 'skip';
}
const finalIds = proceedMode === 'skip' ? songIds.filter(id => !existing.has(id)) : songIds;
if (finalIds.length === 0) return;
await handleAddSongsToPlaylist(finalIds, playlistName);
toast({
title: 'Songs Added',
description: `${finalIds.length} song${finalIds.length === 1 ? '' : 's'} added to "${playlistName}"`,
status: 'success',
duration: 3000,
isClosable: true,
});
};
const handleRemoveFromPlaylist = async (songIds: string[]) => {
if (currentPlaylist === "All Songs") return;
const updatedPlaylists = playlists.map(node => {
if (node.name === currentPlaylist && node.type === 'playlist') {
return {
...node,
tracks: (node.tracks || []).filter(id => !songIds.includes(id))
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: node.children.map(child => {
if (child.name === currentPlaylist && child.type === 'playlist') {
return {
...child,
tracks: (child.tracks || []).filter(id => !songIds.includes(id))
};
}
return child;
})
};
}
return node;
});
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
const handlePlaylistDelete = async (name: string) => {
const updatedPlaylists = playlists.filter(p => p.name !== name);
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
if (currentPlaylist === name) {
navigate("/"); // Navigate back to All Songs if the current playlist is deleted
}
};
const handleCreateFolder = async (name: string) => {
const newFolder: PlaylistNode = {
id: uuidv4(),
name,
type: 'folder',
children: [],
};
const updatedPlaylists = [...playlists, newFolder];
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
const handleMovePlaylist = async (playlistName: string, targetFolderName: string | null) => {
let updatedPlaylists = [...playlists];
let playlistToMove: PlaylistNode | null = null;
// Helper function to remove playlist from its current location
const removePlaylist = (nodes: PlaylistNode[]): PlaylistNode[] => {
return nodes.reduce((acc: PlaylistNode[], node) => {
if (node.name === playlistName) {
playlistToMove = node;
return acc;
}
if (node.type === 'folder' && node.children) {
return [...acc, {
...node,
children: removePlaylist(node.children)
}];
}
return [...acc, node];
}, []);
};
// First, remove the playlist from its current location
updatedPlaylists = removePlaylist(updatedPlaylists);
if (!playlistToMove) return; // Playlist not found
if (targetFolderName === null) {
// Move to root level
updatedPlaylists.push(playlistToMove);
} else {
// Move to target folder
const addToFolder = (nodes: PlaylistNode[]): PlaylistNode[] => {
return nodes.map(node => {
if (node.name === targetFolderName && node.type === 'folder') {
return {
...node,
children: [...(node.children || []), playlistToMove!]
};
}
if (node.type === 'folder' && node.children) {
return {
...node,
children: addToFolder(node.children)
};
}
return node;
});
};
updatedPlaylists = addToFolder(updatedPlaylists);
}
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
setPlaylists(savedPlaylists);
};
// Note: For now, we're showing all songs with pagination
// TODO: Implement playlist filtering with pagination
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
resizeRef.current = {
startX: e.pageX,
startWidth: sidebarWidth,
};
};
const handleResizeMove = (e: MouseEvent) => {
if (!isResizing || !resizeRef.current) return;
const delta = e.pageX - resizeRef.current.startX;
const newWidth = Math.max(300, Math.min(800, resizeRef.current.startWidth + delta));
setSidebarWidth(newWidth);
};
const handleResizeEnd = () => {
setIsResizing(false);
resizeRef.current = null;
};
useEffect(() => {
if (isResizing) {
window.addEventListener('mousemove', handleResizeMove);
window.addEventListener('mouseup', handleResizeEnd);
}
return () => {
window.removeEventListener('mousemove', handleResizeMove);
window.removeEventListener('mouseup', handleResizeEnd);
};
}, [isResizing]);
if (xmlLoading) {
return (
<Flex height="100vh" width="100vw" align="center" justify="center" direction="column" gap={4}>
<Spinner size="xl" />
<Text>Loading your library...</Text>
</Flex>
);
}
const playlistManager = (
<PlaylistManager
playlists={playlists}
selectedItem={currentPlaylist}
onPlaylistCreate={handleCreatePlaylist}
onPlaylistSelect={(name) => {
handlePlaylistSelect(name || "All Songs");
if (isMobile) onClose();
}}
onPlaylistDelete={handlePlaylistDelete}
onFolderCreate={handleCreateFolder}
onPlaylistMove={handleMovePlaylist}
onDropSongs={handleDropSongsToPlaylist}
/>
);
return (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
overflow="hidden"
margin={0}
padding={0}
userSelect={isResizing ? 'none' : 'auto'}
>
{/* Welcome Modal */}
{!xmlLoading && !isDatabaseInitialized && (
<Modal isOpen={isWelcomeOpen} onClose={onWelcomeClose} isCentered>
<ModalOverlay />
<ModalContent bg="gray.800" maxW="md">
<ModalHeader color="white">Welcome to Rekordbox Reader</ModalHeader>
<ModalBody>
<VStack spacing={4} align="stretch">
<Text color="gray.300">
It looks like your library is empty. To get started, you'll need to import your Rekordbox XML file.
</Text>
<Text color="gray.400">
Head over to the configuration page to learn how to export your library from Rekordbox and import it here.
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
onClick={() => {
onWelcomeClose();
navigate('/config');
}}
>
Go to Configuration
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}
<Flex direction="column" h="100%" w="100%">
{/* Header */}
<Flex
px={isMobile ? 2 : 4}
py={2}
bg="gray.800"
borderBottom="1px"
borderColor="gray.700"
align="center"
gap={2}
w="full"
>
{isMobile && (
<IconButton
aria-label="Open menu"
icon={<ChevronRightIcon />}
onClick={onOpen}
variant="solid"
colorScheme="blue"
size="md"
fontSize="20px"
/>
)}
<Heading
size={isMobile ? "sm" : "md"}
cursor="pointer"
onClick={() => navigate('/')}
_hover={{ color: 'blue.300' }}
transition="color 0.2s"
>
Rekordbox Reader
</Heading>
{/* Configuration Button */}
<IconButton
icon={<SettingsIcon />}
aria-label="Configuration"
variant="ghost"
color="gray.300"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
onClick={() => navigate('/config')}
ml="auto"
mr={2}
/>
{/* Export Library Button */}
<IconButton
icon={<DownloadIcon />}
aria-label="Export Library"
variant="ghost"
mr={2}
color="gray.300"
_hover={{ color: "white", bg: "whiteAlpha.200" }}
onClick={handleExportLibrary}
isDisabled={songs.length === 0}
/>
</Flex>
{/* Main Content */}
<Box flex={1} overflow="hidden">
<Routes>
<Route path="/config" element={<Configuration />} />
<Route
path="*"
element={
<Flex flex={1} h="100%" overflow="hidden" w="full">
{/* Sidebar - Desktop */}
{!isMobile && (
<Box
position="relative"
w={`${sidebarWidth}px`}
minW={`${sidebarWidth}px`}
p={4}
borderRight="1px"
borderColor="gray.700"
overflowY="auto"
bg="gray.900"
>
{playlistManager}
<ResizeHandle onMouseDown={handleResizeStart} />
</Box>
)}
{/* Sidebar - Mobile */}
<Drawer
isOpen={isOpen}
placement="left"
onClose={onClose}
size="full"
>
<DrawerOverlay />
<DrawerContent bg="gray.900" p={0}>
<DrawerHeader
borderBottomWidth="1px"
bg="gray.800"
display="flex"
alignItems="center"
px={2}
py={2}
>
<Text>Playlists</Text>
<IconButton
aria-label="Close menu"
icon={<ChevronLeftIcon />}
onClick={onClose}
variant="ghost"
ml="auto"
color="blue.400"
_hover={{ color: "blue.300", bg: "whiteAlpha.200" }}
/>
</DrawerHeader>
<DrawerBody p={2}>
{playlistManager}
</DrawerBody>
</DrawerContent>
</Drawer>
{/* Main Content Area */}
<Flex
flex={1}
gap={4}
p={isMobile ? 2 : 4}
h="100%"
overflow="hidden"
>
{/* Song List */}
<Box flex={1} overflowY="auto" minH={0}>
<PaginatedSongList
songs={songs}
onAddToPlaylist={handleAddSongsToPlaylist}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
playlists={playlists}
onSongSelect={handleSongSelect}
selectedSongId={selectedSong?.id || null}
currentPlaylist={currentPlaylist}
loading={songsLoading}
hasMore={hasMore}
totalSongs={totalSongs}
totalPlaylistDuration={getFormattedTotalDuration(totalDuration)}
onLoadMore={loadNextPage}
onSearch={searchSongs}
searchQuery={searchQuery}
isSwitchingPlaylist={isSwitchingPlaylist}
onPlaySong={handlePlaySong}
/>
</Box>
{/* Details Panel */}
{!isMobile && (
<Box
w="350px"
minW="350px"
pl={4}
pr={4}
pb={4}
borderLeft="1px"
borderColor="gray.700"
overflowY="auto"
bg="gray.900"
minH={0}
sx={{
'&::-webkit-scrollbar': {
width: '8px',
borderRadius: '8px',
backgroundColor: 'gray.900',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'gray.700',
borderRadius: '8px',
},
}}
>
<SongDetails song={selectedSong} />
</Box>
)}
</Flex>
</Flex>
}
/>
</Routes>
</Box>
</Flex>
{/* Persistent Music Player */}
<PersistentMusicPlayer
currentSong={currentSong}
onClose={handleCloseMusicPlayer}
/>
{/* Background Job Progress */}
<BackgroundJobProgress />
</Box>
);
};
const RekordboxReaderApp: React.FC = () => {
return (
<MusicPlayerProvider>
<RekordboxReader />
</MusicPlayerProvider>
);
};
export default RekordboxReaderApp;