veza/apps/web/src/components/player/AudioPlayer.tsx

275 lines
7.8 KiB
TypeScript
Raw Normal View History

import React, { useRef, useEffect } from 'react';
import { usePlayerStore } from '@/stores/player';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Repeat, Shuffle, List } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { QueuePanel } from './QueuePanel';
import { useState } from 'react';
export function AudioPlayer() {
const audioRef = useRef<HTMLAudioElement>(null);
const {
currentTrack,
isPlaying,
currentTime,
duration,
volume,
muted,
repeat,
shuffle,
setCurrentTime,
setDuration,
pause,
resume,
next,
previous,
setVolume,
toggleMute,
toggleShuffle,
setRepeat,
} = usePlayerStore();
const { toast } = useToast();
const [showQueue, setShowQueue] = useState(false);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const handleTimeUpdate = () => {
setCurrentTime(audio.currentTime);
};
const handleLoadedMetadata = () => {
setDuration(audio.duration);
};
const handleEnded = () => {
if (repeat === 'track') {
audio.currentTime = 0;
audio.play();
} else {
next();
}
};
const handleError = () => {
toast({
title: 'Playback error',
description: 'Failed to play track',
variant: 'destructive',
});
};
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('error', handleError);
return () => {
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('error', handleError);
};
}, [setCurrentTime, setDuration, next, repeat, toast]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = muted ? 0 : volume / 100;
}, [volume, muted]);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentTrack) return;
// Set audio source
audio.src = currentTrack.url || '';
if (isPlaying) {
audio.play().catch(err => {
console.error('Playback error:', err);
pause();
});
} else {
audio.pause();
}
}, [currentTrack, isPlaying, pause]);
const handlePlayPause = () => {
if (isPlaying) {
pause();
} else {
resume();
}
};
const handleSeek = (value: number[]) => {
const audio = audioRef.current;
if (audio) {
audio.currentTime = value[0];
setCurrentTime(value[0]);
}
};
const handleVolumeChange = (value: number[]) => {
setVolume(value[0]);
};
const handleRepeatCycle = () => {
const modes: Array<'off' | 'track' | 'playlist'> = ['off', 'track', 'playlist'];
const currentIndex = modes.indexOf(repeat);
setRepeat(modes[(currentIndex + 1) % modes.length]);
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
// Space: play/pause
if (e.code === 'Space' && !e.repeat) {
e.preventDefault();
handlePlayPause();
}
// Arrow left: seek backward
if (e.code === 'ArrowLeft') {
e.preventDefault();
const audio = audioRef.current;
if (audio) {
audio.currentTime = Math.max(0, audio.currentTime - 10);
}
}
// Arrow right: seek forward
if (e.code === 'ArrowRight') {
e.preventDefault();
const audio = audioRef.current;
if (audio) {
audio.currentTime = Math.min(audio.duration, audio.currentTime + 10);
}
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [isPlaying]);
const formatTime = (seconds: number) => {
if (!isFinite(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
if (!currentTrack) {
return null;
}
return (
<>
<audio ref={audioRef} preload="metadata" />
<div className="fixed bottom-0 left-0 right-0 bg-background border-t border-border shadow-lg z-50">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center gap-4">
{/* Track Info */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{currentTrack.cover_url && (
<img
src={currentTrack.cover_url}
alt={currentTrack.title}
className="w-12 h-12 rounded object-cover"
/>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{currentTrack.title}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
</div>
{/* Player Controls */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => toggleShuffle()}
className={shuffle ? 'text-primary' : ''}
>
<Shuffle className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={previous}>
<SkipBack className="h-5 w-5" />
</Button>
<Button size="icon" onClick={handlePlayPause}>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
<Button variant="ghost" size="icon" onClick={next}>
<SkipForward className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRepeatCycle}
className={repeat !== 'off' ? 'text-primary' : ''}
>
<Repeat className="h-4 w-4" />
</Button>
</div>
{/* Progress Bar */}
<div className="flex items-center gap-2 flex-1">
<span className="text-xs text-muted-foreground w-12 text-right">
{formatTime(currentTime)}
</span>
<Slider
value={[currentTime]}
max={duration || 1}
step={0.1}
onValueChange={handleSeek}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-12">
{formatTime(duration)}
</span>
</div>
{/* Volume Controls */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleMute}>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
<Slider
value={[volume]}
max={100}
step={1}
onValueChange={handleVolumeChange}
className="w-24"
/>
</div>
{/* Queue Toggle */}
<Button
variant="ghost"
size="icon"
onClick={() => setShowQueue(!showQueue)}
className={showQueue ? 'text-primary' : ''}
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Queue Panel */}
{showQueue && <QueuePanel onClose={() => setShowQueue(false)} />}
</>
);
}