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 }) => ( ); 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(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) => { // Fetch FULL playlists to avoid losing tracks (structure view strips them) const fullTree = await api.getPlaylists(); const applyAdd = (nodes: PlaylistNode[]): PlaylistNode[] => { return nodes.map(node => { if (node.type === 'playlist' && node.name === playlistName) { const current = Array.isArray(node.tracks) ? node.tracks : []; const merged = Array.from(new Set([...current, ...songIds])); return { ...node, tracks: merged }; } if (node.type === 'folder' && node.children) { return { ...node, children: applyAdd(node.children) }; } return node; }); }; const updatedFullTree = applyAdd(fullTree); await api.savePlaylists(updatedFullTree); // Reload structure for UI 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 ( Loading your library... ); } const playlistManager = ( { handlePlaylistSelect(name || "All Songs"); if (isMobile) onClose(); }} onPlaylistDelete={handlePlaylistDelete} onFolderCreate={handleCreateFolder} onPlaylistMove={handleMovePlaylist} onDropSongs={handleDropSongsToPlaylist} /> ); return ( {/* Welcome Modal */} {!xmlLoading && !isDatabaseInitialized && ( Welcome to Rekordbox Reader It looks like your library is empty. To get started, you'll need to import your Rekordbox XML file. Head over to the configuration page to learn how to export your library from Rekordbox and import it here. )} {/* Header */} {isMobile && ( } onClick={onOpen} variant="solid" colorScheme="blue" size="md" fontSize="20px" /> )} navigate('/')} _hover={{ color: 'blue.300' }} transition="color 0.2s" > Rekordbox Reader {/* Configuration Button */} } aria-label="Configuration" variant="ghost" color="gray.300" _hover={{ color: "white", bg: "whiteAlpha.200" }} onClick={() => navigate('/config')} ml="auto" mr={2} /> {/* Export Library Button */} } aria-label="Export Library" variant="ghost" mr={2} color="gray.300" _hover={{ color: "white", bg: "whiteAlpha.200" }} onClick={handleExportLibrary} isDisabled={songs.length === 0} /> {/* Main Content */} } /> {/* Sidebar - Desktop */} {!isMobile && ( {playlistManager} )} {/* Sidebar - Mobile */} Playlists } onClick={onClose} variant="ghost" ml="auto" color="blue.400" _hover={{ color: "blue.300", bg: "whiteAlpha.200" }} /> {playlistManager} {/* Main Content Area */} {/* Song List */} {/* Details Panel */} {!isMobile && ( )} } /> {/* Persistent Music Player */} {/* Background Job Progress */} ); }; const RekordboxReaderApp: React.FC = () => { return ( ); }; export default RekordboxReaderApp;