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:
Geert Rademakes 2025-08-06 15:14:35 +02:00
parent 47276728df
commit e5c679a1ee
2 changed files with 347 additions and 25 deletions

View File

@ -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>
); );
} }

View 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>
);
};