feat: Add persistent music player for main view
- Create PersistentMusicPlayer component with full audio controls
- Add fixed position player at bottom of screen when song is playing
- Include play/pause, skip forward/backward, volume control, and progress bar
- Auto-play songs when selected from main view
- Show song info (title, artist) and 🎵 badge
- Add close button to dismiss the player
- Simplify handlePlaySong to just set current song
- Player appears when clicking play buttons on songs with S3 files
Now users can see and control music playback from the main browser view
with a persistent, professional music player interface.
This commit is contained in:
parent
47276728df
commit
e5c679a1ee
@ -7,6 +7,7 @@ import { PlaylistManager } from "./components/PlaylistManager";
|
|||||||
import { SongDetails } from "./components/SongDetails";
|
import { SongDetails } from "./components/SongDetails";
|
||||||
import { Configuration } from "./pages/Configuration";
|
import { Configuration } from "./pages/Configuration";
|
||||||
import { MusicStorage } from "./pages/MusicStorage";
|
import { MusicStorage } from "./pages/MusicStorage";
|
||||||
|
import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer";
|
||||||
import { useXmlParser } from "./hooks/useXmlParser";
|
import { useXmlParser } from "./hooks/useXmlParser";
|
||||||
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
|
import { usePaginatedSongs } from "./hooks/usePaginatedSongs";
|
||||||
import { formatTotalDuration } from "./utils/formatters";
|
import { formatTotalDuration } from "./utils/formatters";
|
||||||
@ -81,34 +82,18 @@ export default function RekordboxReader() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle playing a song from the main view
|
// Handle playing a song from the main view
|
||||||
const handlePlaySong = useCallback(async (song: Song) => {
|
const handlePlaySong = useCallback((song: Song) => {
|
||||||
try {
|
// Check if song has S3 file
|
||||||
// Check if song has S3 file
|
if (song.s3File?.hasS3File) {
|
||||||
if (song.s3File?.hasS3File) {
|
setCurrentPlayingSong(song);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle closing the music player
|
||||||
|
const handleCloseMusicPlayer = useCallback(() => {
|
||||||
|
setCurrentPlayingSong(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Format total duration for display
|
// Format total duration for display
|
||||||
const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => {
|
const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => {
|
||||||
if (!durationSeconds) return "";
|
if (!durationSeconds) return "";
|
||||||
@ -678,6 +663,12 @@ export default function RekordboxReader() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
{/* Persistent Music Player */}
|
||||||
|
<PersistentMusicPlayer
|
||||||
|
currentSong={currentPlayingSong}
|
||||||
|
onClose={handleCloseMusicPlayer}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
331
packages/frontend/src/components/PersistentMusicPlayer.tsx
Normal file
331
packages/frontend/src/components/PersistentMusicPlayer.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Slider,
|
||||||
|
SliderTrack,
|
||||||
|
SliderFilledTrack,
|
||||||
|
SliderThumb,
|
||||||
|
Icon,
|
||||||
|
useToast,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import {
|
||||||
|
FiPlay,
|
||||||
|
FiPause,
|
||||||
|
FiSkipBack,
|
||||||
|
FiSkipForward,
|
||||||
|
FiVolume2,
|
||||||
|
FiVolumeX,
|
||||||
|
FiX,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
import type { Song } from '../types/interfaces';
|
||||||
|
import { formatDuration } from '../utils/formatters';
|
||||||
|
|
||||||
|
interface PersistentMusicPlayerProps {
|
||||||
|
currentSong: Song | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersistentMusicPlayer: React.FC<PersistentMusicPlayerProps> = ({
|
||||||
|
currentSong,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [streamingUrl, setStreamingUrl] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Format time in MM:SS
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
if (!seconds || isNaN(seconds)) return '00:00';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load streaming URL when current song changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentSong && currentSong.s3File?.hasS3File) {
|
||||||
|
loadStreamingUrl();
|
||||||
|
} else {
|
||||||
|
setStreamingUrl(null);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
}, [currentSong]);
|
||||||
|
|
||||||
|
const loadStreamingUrl = async () => {
|
||||||
|
if (!currentSong?.s3File?.musicFileId) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/music/${currentSong.s3File.musicFileId}/stream`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setStreamingUrl(data.streamingUrl);
|
||||||
|
// Auto-play when URL is loaded
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.play().then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error auto-playing:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to get streaming URL');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading streaming URL:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load music file',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.play().then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error playing:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
setCurrentTime(audioRef.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
setDuration(audioRef.current.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (value: number) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = value;
|
||||||
|
setCurrentTime(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (value: number) => {
|
||||||
|
setVolume(value);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = value;
|
||||||
|
}
|
||||||
|
if (value === 0) {
|
||||||
|
setIsMuted(true);
|
||||||
|
} else if (isMuted) {
|
||||||
|
setIsMuted(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (isMuted) {
|
||||||
|
audioRef.current.volume = volume;
|
||||||
|
setIsMuted(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current.volume = 0;
|
||||||
|
setIsMuted(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipBackward = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipForward = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = Math.min(
|
||||||
|
audioRef.current.duration,
|
||||||
|
audioRef.current.currentTime + 10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentSong || !currentSong.s3File?.hasS3File) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="fixed"
|
||||||
|
bottom={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
bg="gray.900"
|
||||||
|
borderTop="1px"
|
||||||
|
borderColor="gray.700"
|
||||||
|
p={4}
|
||||||
|
zIndex={1000}
|
||||||
|
boxShadow="lg"
|
||||||
|
>
|
||||||
|
{/* Hidden audio element */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={streamingUrl || undefined}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error('Audio error:', e);
|
||||||
|
toast({
|
||||||
|
title: 'Playback Error',
|
||||||
|
description: 'Failed to play audio file. The file may be corrupted or the streaming URL may have expired.',
|
||||||
|
status: 'error',
|
||||||
|
duration: 5000,
|
||||||
|
isClosable: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onLoadStart={() => setIsLoading(true)}
|
||||||
|
onCanPlay={() => setIsLoading(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HStack spacing={4} align="center">
|
||||||
|
{/* Song Info */}
|
||||||
|
<VStack align="start" spacing={1} flex={1} minW={0}>
|
||||||
|
<HStack spacing={2} align="center">
|
||||||
|
<Text fontWeight="bold" fontSize="md" color="white" noOfLines={1}>
|
||||||
|
{currentSong.title}
|
||||||
|
</Text>
|
||||||
|
<Badge colorScheme="green" size="sm" variant="subtle" bg="green.900" color="green.200">
|
||||||
|
🎵
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="gray.400" noOfLines={1}>
|
||||||
|
{currentSong.artist}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Skip backward"
|
||||||
|
icon={<FiSkipBack />}
|
||||||
|
onClick={skipBackward}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: "gray.700" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
icon={<Icon as={isPlaying ? FiPause : FiPlay} />}
|
||||||
|
onClick={isPlaying ? handlePause : handlePlay}
|
||||||
|
size="md"
|
||||||
|
colorScheme="blue"
|
||||||
|
isLoading={isLoading}
|
||||||
|
_hover={{ bg: "blue.700" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label="Skip forward"
|
||||||
|
icon={<FiSkipForward />}
|
||||||
|
onClick={skipForward}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: "gray.700" }}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<VStack spacing={1} flex={2} minW={0}>
|
||||||
|
<Slider
|
||||||
|
value={currentTime}
|
||||||
|
max={duration}
|
||||||
|
onChange={handleSeek}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
size="sm"
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
<SliderTrack bg="gray.700">
|
||||||
|
<SliderFilledTrack bg="blue.400" />
|
||||||
|
</SliderTrack>
|
||||||
|
<SliderThumb bg="blue.400" />
|
||||||
|
</Slider>
|
||||||
|
<HStack justify="space-between" w="full" fontSize="xs" color="gray.400">
|
||||||
|
<Text>{formatTime(currentTime)}</Text>
|
||||||
|
<Text>{formatTime(duration)}</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Volume Control */}
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<IconButton
|
||||||
|
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
icon={<Icon as={isMuted ? FiVolumeX : FiVolume2} />}
|
||||||
|
onClick={toggleMute}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: "gray.700" }}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
size="sm"
|
||||||
|
w="80px"
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
<SliderTrack bg="gray.700">
|
||||||
|
<SliderFilledTrack bg="blue.400" />
|
||||||
|
</SliderTrack>
|
||||||
|
<SliderThumb bg="blue.400" />
|
||||||
|
</Slider>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close player"
|
||||||
|
icon={<FiX />}
|
||||||
|
onClick={onClose}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray.400"
|
||||||
|
_hover={{ bg: "gray.700" }}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user