677 lines
22 KiB
TypeScript
677 lines
22 KiB
TypeScript
import { Box, Button, Flex, Heading, Spinner, Text, useBreakpointValue, IconButton, Drawer, DrawerBody, DrawerHeader, DrawerOverlay, DrawerContent, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, VStack } 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();
|
|
|
|
// 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;
|
|
});
|
|
const savedPlaylists = await api.savePlaylists(updatedPlaylists);
|
|
setPlaylists(savedPlaylists);
|
|
};
|
|
|
|
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" 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}
|
|
/>
|
|
);
|
|
|
|
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;
|