From e5c679a1ee1bf43f76c31e7dfb74b5bdb012a9d6 Mon Sep 17 00:00:00 2001 From: Geert Rademakes Date: Wed, 6 Aug 2025 15:14:35 +0200 Subject: [PATCH] feat: Add persistent music player for main view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- packages/frontend/src/App.tsx | 41 +-- .../src/components/PersistentMusicPlayer.tsx | 331 ++++++++++++++++++ 2 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 packages/frontend/src/components/PersistentMusicPlayer.tsx diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index cc89052..0040bd3 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { PlaylistManager } from "./components/PlaylistManager"; import { SongDetails } from "./components/SongDetails"; import { Configuration } from "./pages/Configuration"; import { MusicStorage } from "./pages/MusicStorage"; +import { PersistentMusicPlayer } from "./components/PersistentMusicPlayer"; import { useXmlParser } from "./hooks/useXmlParser"; import { usePaginatedSongs } from "./hooks/usePaginatedSongs"; import { formatTotalDuration } from "./utils/formatters"; @@ -81,33 +82,17 @@ export default function RekordboxReader() { }, []); // 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); + const handlePlaySong = useCallback((song: Song) => { + // Check if song has S3 file + if (song.s3File?.hasS3File) { + setCurrentPlayingSong(song); } }, []); + + // Handle closing the music player + const handleCloseMusicPlayer = useCallback(() => { + setCurrentPlayingSong(null); + }, []); // Format total duration for display const getFormattedTotalDuration = useCallback((durationSeconds?: number): string => { @@ -678,6 +663,12 @@ export default function RekordboxReader() { + + {/* Persistent Music Player */} + ); } diff --git a/packages/frontend/src/components/PersistentMusicPlayer.tsx b/packages/frontend/src/components/PersistentMusicPlayer.tsx new file mode 100644 index 0000000..2d0bd77 --- /dev/null +++ b/packages/frontend/src/components/PersistentMusicPlayer.tsx @@ -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 = ({ + 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(null); + const [isLoading, setIsLoading] = useState(false); + + const audioRef = useRef(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 ( + + {/* Hidden audio element */} + + ); +}; \ No newline at end of file