- Fix scrolling on Music Storage page by adding proper overflow handling - Add height constraints and flex layout for better tab panel scrolling - Update streaming endpoint to use presigned URLs instead of direct URLs - Improve audio error handling with better error messages - Update MusicPlayer component with dark theme styling - Add loading indicators and error states for better UX - Fix audio playback for files synced from S3 subdirectories The Music Storage page now has proper scrolling behavior and audio playback should work correctly for all music files.
326 lines
8.0 KiB
TypeScript
326 lines
8.0 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import {
|
|
Box,
|
|
VStack,
|
|
HStack,
|
|
Text,
|
|
IconButton,
|
|
Slider,
|
|
SliderTrack,
|
|
SliderFilledTrack,
|
|
SliderThumb,
|
|
Icon,
|
|
useToast,
|
|
} from '@chakra-ui/react';
|
|
import {
|
|
FiPlay,
|
|
FiPause,
|
|
FiSkipBack,
|
|
FiSkipForward,
|
|
FiVolume2,
|
|
FiVolumeX,
|
|
} from 'react-icons/fi';
|
|
|
|
interface MusicFile {
|
|
_id: string;
|
|
title?: string;
|
|
artist?: string;
|
|
album?: string;
|
|
duration?: number;
|
|
originalName: string;
|
|
}
|
|
|
|
interface MusicPlayerProps {
|
|
musicFile?: MusicFile;
|
|
onPlay?: () => void;
|
|
onPause?: () => void;
|
|
onEnded?: () => void;
|
|
}
|
|
|
|
export const MusicPlayer: React.FC<MusicPlayerProps> = ({
|
|
musicFile,
|
|
onPlay,
|
|
onPause,
|
|
onEnded,
|
|
}) => {
|
|
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 music file changes
|
|
useEffect(() => {
|
|
if (musicFile) {
|
|
loadStreamingUrl();
|
|
} else {
|
|
setStreamingUrl(null);
|
|
setIsPlaying(false);
|
|
}
|
|
}, [musicFile]);
|
|
|
|
const loadStreamingUrl = async () => {
|
|
if (!musicFile) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/music/${musicFile._id}/stream`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setStreamingUrl(data.streamingUrl);
|
|
} 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);
|
|
}
|
|
};
|
|
|
|
// Audio event handlers
|
|
const handlePlay = () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.play();
|
|
setIsPlaying(true);
|
|
onPlay?.();
|
|
}
|
|
};
|
|
|
|
const handlePause = () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
setIsPlaying(false);
|
|
onPause?.();
|
|
}
|
|
};
|
|
|
|
const handleTimeUpdate = () => {
|
|
if (audioRef.current) {
|
|
setCurrentTime(audioRef.current.currentTime);
|
|
}
|
|
};
|
|
|
|
const handleLoadedMetadata = () => {
|
|
if (audioRef.current) {
|
|
setDuration(audioRef.current.duration);
|
|
}
|
|
};
|
|
|
|
const handleEnded = () => {
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
onEnded?.();
|
|
};
|
|
|
|
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, currentTime - 10);
|
|
}
|
|
};
|
|
|
|
const skipForward = () => {
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = Math.min(duration, currentTime + 10);
|
|
}
|
|
};
|
|
|
|
if (!musicFile) {
|
|
return (
|
|
<Box p={4} textAlign="center" color="gray.500">
|
|
<Text>No music file selected</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<VStack spacing={4} align="stretch" w="full" p={4} bg="gray.800" borderRadius="lg" borderColor="gray.700" borderWidth="1px">
|
|
{/* 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)}
|
|
/>
|
|
|
|
{/* Track info */}
|
|
<VStack spacing={1} align="center">
|
|
<Text fontWeight="bold" fontSize="lg" noOfLines={1} color="white">
|
|
{musicFile.title || musicFile.originalName}
|
|
</Text>
|
|
{musicFile.artist && (
|
|
<Text fontSize="sm" color="gray.400" noOfLines={1}>
|
|
{musicFile.artist}
|
|
</Text>
|
|
)}
|
|
{musicFile.album && (
|
|
<Text fontSize="xs" color="gray.500" noOfLines={1}>
|
|
{musicFile.album}
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
|
|
{/* Progress bar */}
|
|
<VStack spacing={2}>
|
|
<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>
|
|
|
|
{/* Controls */}
|
|
<HStack justify="center" spacing={4}>
|
|
<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="lg"
|
|
colorScheme="blue"
|
|
isLoading={isLoading}
|
|
isDisabled={!streamingUrl}
|
|
_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>
|
|
|
|
{/* Volume control */}
|
|
<HStack spacing={2} justify="center">
|
|
<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="100px"
|
|
colorScheme="blue"
|
|
>
|
|
<SliderTrack bg="gray.700">
|
|
<SliderFilledTrack bg="blue.400" />
|
|
</SliderTrack>
|
|
<SliderThumb bg="blue.400" />
|
|
</Slider>
|
|
</HStack>
|
|
|
|
{/* Loading indicator */}
|
|
{isLoading && (
|
|
<Text fontSize="sm" color="gray.500" textAlign="center">
|
|
Loading audio...
|
|
</Text>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{!streamingUrl && (
|
|
<Text fontSize="sm" color="red.400" textAlign="center">
|
|
Unable to load audio file
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
);
|
|
};
|