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:
Geert Rademakes 2025-08-06 15:09:38 +02:00
parent 1bb1f7d0d5
commit 47276728df
3 changed files with 126 additions and 51 deletions

View File

@ -73,11 +73,41 @@ 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 => {
@ -609,6 +639,7 @@ export default function RekordboxReader() {
onSearch={searchSongs}
searchQuery={searchQuery}
isSwitchingPlaylist={isSwitchingPlaylist}
onPlaySong={handlePlaySong}
/>
</Box>

View File

@ -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(() => {

View File

@ -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>