feat: Add S3 playback to main view and improve Music Library layout
- Add play functionality to main browser view for songs with S3 files
- Add onPlaySong prop to PaginatedSongList and SongItem components
- Show 🎵 badge and play buttons for songs with S3 files in main view
- Change Music Library from tile view to list view for better organization
- Improve Music Library layout with better spacing and information display
- Add proper audio playback handling in main App.tsx
- Show linked status badges in Music Library list view
Users can now play S3-linked songs directly from the main browser view,
and the Music Library has a cleaner list layout for easier browsing.
This commit is contained in:
parent
1bb1f7d0d5
commit
47276728df
@ -73,12 +73,42 @@ export default function RekordboxReader() {
|
||||
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
|
||||
const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false);
|
||||
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
|
||||
const [currentPlayingSong, setCurrentPlayingSong] = useState<Song | null>(null);
|
||||
|
||||
// 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(async (song: Song) => {
|
||||
try {
|
||||
// Check if song has S3 file
|
||||
if (song.s3File?.hasS3File) {
|
||||
setCurrentPlayingSong(song);
|
||||
|
||||
// Get streaming URL
|
||||
const response = await fetch(`/api/music/${song.s3File.musicFileId}/stream`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Create audio element and play
|
||||
const audio = new Audio(data.streamingUrl);
|
||||
audio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
|
||||
// Update current playing song
|
||||
setCurrentPlayingSong(song);
|
||||
} else {
|
||||
console.error('Failed to get streaming URL');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing song:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Format total duration for display
|
||||
const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => {
|
||||
if (!durationSeconds) return "";
|
||||
@ -609,6 +639,7 @@ export default function RekordboxReader() {
|
||||
onSearch={searchSongs}
|
||||
searchQuery={searchQuery}
|
||||
isSwitchingPlaylist={isSwitchingPlaylist}
|
||||
onPlaySong={handlePlaySong}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@ -17,11 +17,12 @@ import {
|
||||
MenuDivider,
|
||||
IconButton,
|
||||
Spinner,
|
||||
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import type { Song, PlaylistNode } from '../types/interfaces';
|
||||
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||
import { FiPlay } from 'react-icons/fi';
|
||||
|
||||
interface PaginatedSongListProps {
|
||||
songs: Song[];
|
||||
@ -40,6 +41,7 @@ interface PaginatedSongListProps {
|
||||
searchQuery: string;
|
||||
depth?: number;
|
||||
isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching
|
||||
onPlaySong?: (song: Song) => void; // New prop for playing songs
|
||||
}
|
||||
|
||||
// Memoized song item component to prevent unnecessary re-renders
|
||||
@ -50,7 +52,8 @@ const SongItem = memo<{
|
||||
onSelect: (song: Song) => void;
|
||||
onToggleSelection: (songId: string) => void;
|
||||
showCheckbox: boolean;
|
||||
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox }) => {
|
||||
onPlaySong?: (song: Song) => void;
|
||||
}>(({ song, isSelected, isHighlighted, onSelect, onToggleSelection, showCheckbox, onPlaySong }) => {
|
||||
// Memoize the formatted duration to prevent recalculation
|
||||
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
|
||||
const handleClick = useCallback(() => {
|
||||
@ -62,6 +65,17 @@ const SongItem = memo<{
|
||||
onToggleSelection(song.id);
|
||||
}, [onToggleSelection, song.id]);
|
||||
|
||||
const handlePlayClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onPlaySong && (song.s3File?.hasS3File || song.hasMusicFile)) {
|
||||
onPlaySong(song);
|
||||
}
|
||||
}, [onPlaySong, song]);
|
||||
|
||||
const hasMusicFile = (song: Song): boolean => {
|
||||
return song.s3File?.hasS3File || song.hasMusicFile || false;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={song.id}
|
||||
@ -83,9 +97,16 @@ const SongItem = memo<{
|
||||
/>
|
||||
)}
|
||||
<Box flex={1} minW={0}>
|
||||
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
|
||||
{song.title}
|
||||
</Text>
|
||||
<Flex align="center" gap={2}>
|
||||
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
|
||||
{song.title}
|
||||
</Text>
|
||||
{hasMusicFile(song) && (
|
||||
<Badge colorScheme="green" size="sm" variant="subtle" bg="green.900" color="green.200">
|
||||
🎵
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
<Text fontSize="xs" color="gray.400" noOfLines={1}>
|
||||
{song.artist}
|
||||
</Text>
|
||||
@ -98,6 +119,18 @@ const SongItem = memo<{
|
||||
{song.averageBpm} BPM
|
||||
</Text>
|
||||
</Box>
|
||||
{hasMusicFile(song) && onPlaySong && (
|
||||
<IconButton
|
||||
aria-label="Play song"
|
||||
icon={<FiPlay />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={handlePlayClick}
|
||||
ml={2}
|
||||
_hover={{ bg: "blue.900" }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@ -120,7 +153,8 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
||||
onSearch,
|
||||
searchQuery,
|
||||
depth = 0,
|
||||
isSwitchingPlaylist = false
|
||||
isSwitchingPlaylist = false,
|
||||
onPlaySong
|
||||
}) => {
|
||||
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
@ -217,9 +251,10 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
||||
onSelect={handleSongSelect}
|
||||
onToggleSelection={toggleSelection}
|
||||
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
||||
onPlaySong={onPlaySong}
|
||||
/>
|
||||
));
|
||||
}, [songs, selectedSongs, selectedSongId, toggleSelection, depth]); // Removed handleSongSelect since it's already memoized
|
||||
}, [songs, selectedSongs, selectedSongId, toggleSelection, depth, onPlaySong]); // Removed handleSongSelect since it's already memoized
|
||||
|
||||
// Use total playlist duration if available, otherwise calculate from current songs
|
||||
const totalDuration = useMemo(() => {
|
||||
|
||||
@ -276,14 +276,57 @@ export const MusicStorage: React.FC = () => {
|
||||
</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{musicFiles.map((file) => (
|
||||
<Card key={file._id} size="sm" bg="gray.800" borderColor="gray.700" borderWidth="1px">
|
||||
<CardHeader pb={2}>
|
||||
<HStack justify="space-between">
|
||||
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
|
||||
{file.format?.toUpperCase() || 'AUDIO'}
|
||||
</Badge>
|
||||
<Box
|
||||
key={file._id}
|
||||
p={4}
|
||||
border="1px"
|
||||
borderColor="gray.700"
|
||||
borderRadius="md"
|
||||
bg="gray.800"
|
||||
_hover={{ bg: "gray.750" }}
|
||||
>
|
||||
<HStack justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack spacing={2} align="center">
|
||||
<Text fontWeight="bold" fontSize="md" color="white" noOfLines={1}>
|
||||
{file.title || file.originalName}
|
||||
</Text>
|
||||
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
|
||||
{file.format?.toUpperCase() || 'AUDIO'}
|
||||
</Badge>
|
||||
{file.songId && (
|
||||
<Badge colorScheme="green" size="sm" bg="green.900" color="green.200">
|
||||
Linked to Rekordbox
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{file.artist && (
|
||||
<Text fontSize="sm" color="gray.400" noOfLines={1}>
|
||||
{file.artist}
|
||||
</Text>
|
||||
)}
|
||||
{file.album && (
|
||||
<Text fontSize="sm" color="gray.500" noOfLines={1}>
|
||||
{file.album}
|
||||
</Text>
|
||||
)}
|
||||
<HStack spacing={4} fontSize="xs" color="gray.500">
|
||||
<Text>{formatDuration(file.duration || 0)}</Text>
|
||||
<Text>{formatFileSize(file.size)}</Text>
|
||||
<Text>{file.format?.toUpperCase()}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Play file"
|
||||
icon={<FiPlay />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={() => setSelectedFile(file)}
|
||||
_hover={{ bg: "blue.700" }}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Delete file"
|
||||
icon={<FiTrash2 />}
|
||||
@ -294,44 +337,10 @@ export const MusicStorage: React.FC = () => {
|
||||
_hover={{ bg: "red.900" }}
|
||||
/>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text fontWeight="bold" fontSize="sm" noOfLines={1} color="white">
|
||||
{file.title || file.originalName}
|
||||
</Text>
|
||||
{file.artist && (
|
||||
<Text fontSize="xs" color="gray.400" noOfLines={1}>
|
||||
{file.artist}
|
||||
</Text>
|
||||
)}
|
||||
{file.album && (
|
||||
<Text fontSize="xs" color="gray.500" noOfLines={1}>
|
||||
{file.album}
|
||||
</Text>
|
||||
)}
|
||||
<HStack justify="space-between" fontSize="xs" color="gray.500">
|
||||
<Text>{formatDuration(file.duration || 0)}</Text>
|
||||
<Text>{formatFileSize(file.size)}</Text>
|
||||
</HStack>
|
||||
{file.songId && (
|
||||
<Badge colorScheme="green" size="sm" alignSelf="start" bg="green.900" color="green.200">
|
||||
Linked to Rekordbox
|
||||
</Badge>
|
||||
)}
|
||||
<IconButton
|
||||
aria-label="Play file"
|
||||
icon={<FiPlay />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={() => setSelectedFile(file)}
|
||||
_hover={{ bg: "blue.700" }}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user