Geert Rademakes 1bb1f7d0d5 fix: Resolve scrolling and audio playback issues
- 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.
2025-08-06 15:05:33 +02:00

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