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,11 +73,41 @@ export default function RekordboxReader() {
|
|||||||
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
|
const [selectedSong, setSelectedSong] = useState<Song | null>(null);
|
||||||
const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false);
|
const [isDatabaseInitialized, setIsDatabaseInitialized] = useState(false);
|
||||||
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
|
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
|
||||||
|
const [currentPlayingSong, setCurrentPlayingSong] = useState<Song | null>(null);
|
||||||
|
|
||||||
// Memoized song selection handler to prevent unnecessary re-renders
|
// Memoized song selection handler to prevent unnecessary re-renders
|
||||||
const handleSongSelect = useCallback((song: Song) => {
|
const handleSongSelect = useCallback((song: Song) => {
|
||||||
setSelectedSong(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
|
// Format total duration for display
|
||||||
const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => {
|
const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => {
|
||||||
@ -609,6 +639,7 @@ export default function RekordboxReader() {
|
|||||||
onSearch={searchSongs}
|
onSearch={searchSongs}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
isSwitchingPlaylist={isSwitchingPlaylist}
|
isSwitchingPlaylist={isSwitchingPlaylist}
|
||||||
|
onPlaySong={handlePlaySong}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@ -17,11 +17,12 @@ import {
|
|||||||
MenuDivider,
|
MenuDivider,
|
||||||
IconButton,
|
IconButton,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Badge,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
import { Search2Icon, ChevronDownIcon } from '@chakra-ui/icons';
|
||||||
import type { Song, PlaylistNode } from '../types/interfaces';
|
import type { Song, PlaylistNode } from '../types/interfaces';
|
||||||
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
import { formatDuration, formatTotalDuration } from '../utils/formatters';
|
||||||
|
import { FiPlay } from 'react-icons/fi';
|
||||||
|
|
||||||
interface PaginatedSongListProps {
|
interface PaginatedSongListProps {
|
||||||
songs: Song[];
|
songs: Song[];
|
||||||
@ -40,6 +41,7 @@ interface PaginatedSongListProps {
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
isSwitchingPlaylist?: boolean; // New prop to indicate playlist switching
|
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
|
// Memoized song item component to prevent unnecessary re-renders
|
||||||
@ -50,7 +52,8 @@ const SongItem = memo<{
|
|||||||
onSelect: (song: Song) => void;
|
onSelect: (song: Song) => void;
|
||||||
onToggleSelection: (songId: string) => void;
|
onToggleSelection: (songId: string) => void;
|
||||||
showCheckbox: boolean;
|
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
|
// Memoize the formatted duration to prevent recalculation
|
||||||
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
|
const formattedDuration = useMemo(() => formatDuration(song.totalTime || ''), [song.totalTime]);
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
@ -62,6 +65,17 @@ const SongItem = memo<{
|
|||||||
onToggleSelection(song.id);
|
onToggleSelection(song.id);
|
||||||
}, [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 (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
key={song.id}
|
key={song.id}
|
||||||
@ -83,9 +97,16 @@ const SongItem = memo<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Box flex={1} minW={0}>
|
<Box flex={1} minW={0}>
|
||||||
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
|
<Flex align="center" gap={2}>
|
||||||
{song.title}
|
<Text fontSize="sm" fontWeight="medium" color="white" noOfLines={1}>
|
||||||
</Text>
|
{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}>
|
<Text fontSize="xs" color="gray.400" noOfLines={1}>
|
||||||
{song.artist}
|
{song.artist}
|
||||||
</Text>
|
</Text>
|
||||||
@ -98,6 +119,18 @@ const SongItem = memo<{
|
|||||||
{song.averageBpm} BPM
|
{song.averageBpm} BPM
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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>
|
</Flex>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -120,7 +153,8 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
onSearch,
|
onSearch,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isSwitchingPlaylist = false
|
isSwitchingPlaylist = false,
|
||||||
|
onPlaySong
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
const [selectedSongs, setSelectedSongs] = useState<Set<string>>(new Set());
|
||||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||||
@ -217,9 +251,10 @@ export const PaginatedSongList: React.FC<PaginatedSongListProps> = memo(({
|
|||||||
onSelect={handleSongSelect}
|
onSelect={handleSongSelect}
|
||||||
onToggleSelection={toggleSelection}
|
onToggleSelection={toggleSelection}
|
||||||
showCheckbox={selectedSongs.size > 0 || depth === 0}
|
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
|
// Use total playlist duration if available, otherwise calculate from current songs
|
||||||
const totalDuration = useMemo(() => {
|
const totalDuration = useMemo(() => {
|
||||||
|
|||||||
@ -276,14 +276,57 @@ export const MusicStorage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
) : (
|
) : (
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
<VStack spacing={2} align="stretch">
|
||||||
{musicFiles.map((file) => (
|
{musicFiles.map((file) => (
|
||||||
<Card key={file._id} size="sm" bg="gray.800" borderColor="gray.700" borderWidth="1px">
|
<Box
|
||||||
<CardHeader pb={2}>
|
key={file._id}
|
||||||
<HStack justify="space-between">
|
p={4}
|
||||||
<Badge colorScheme="blue" variant="subtle" bg="blue.900" color="blue.200">
|
border="1px"
|
||||||
{file.format?.toUpperCase() || 'AUDIO'}
|
borderColor="gray.700"
|
||||||
</Badge>
|
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
|
<IconButton
|
||||||
aria-label="Delete file"
|
aria-label="Delete file"
|
||||||
icon={<FiTrash2 />}
|
icon={<FiTrash2 />}
|
||||||
@ -294,44 +337,10 @@ export const MusicStorage: React.FC = () => {
|
|||||||
_hover={{ bg: "red.900" }}
|
_hover={{ bg: "red.900" }}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardHeader>
|
</HStack>
|
||||||
<CardBody pt={0}>
|
</Box>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user