387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
import { useRef, useEffect } from 'react';
|
|
import { usePlayerStore } from '@/features/player/store/playerStore';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Slider } from '@/components/ui/slider';
|
|
import { Tooltip } from '@/components/ui/tooltip';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import {
|
|
Play,
|
|
Pause,
|
|
SkipBack,
|
|
SkipForward,
|
|
Volume2,
|
|
VolumeX,
|
|
Repeat,
|
|
Shuffle,
|
|
List,
|
|
} from 'lucide-react';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { QueuePanel } from './QueuePanel';
|
|
import { useState } from 'react';
|
|
|
|
export function AudioPlayer() {
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const { t } = useTranslation();
|
|
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({
|
|
message: 'Playback error: Failed to play track',
|
|
type: 'error',
|
|
});
|
|
};
|
|
|
|
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 && (
|
|
<img
|
|
src={currentTrack.cover}
|
|
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">
|
|
<Tooltip
|
|
content={
|
|
shuffle
|
|
? t('player.shuffleOn', 'Shuffle: On')
|
|
: t('player.shuffleOff', 'Shuffle: Off')
|
|
}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => toggleShuffle()}
|
|
className={shuffle ? 'text-primary' : ''}
|
|
aria-label={
|
|
shuffle
|
|
? t('player.shuffleOn', 'Shuffle: On')
|
|
: t('player.shuffleOff', 'Shuffle: Off')
|
|
}
|
|
>
|
|
<Shuffle className="h-4 w-4" />
|
|
</Button>
|
|
</Tooltip>
|
|
|
|
<Tooltip content={t('player.previous', 'Previous track')}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={previous}
|
|
aria-label={t('player.previous', 'Previous track')}
|
|
>
|
|
<SkipBack className="h-5 w-5" />
|
|
</Button>
|
|
</Tooltip>
|
|
|
|
<Tooltip
|
|
content={
|
|
isPlaying
|
|
? t('player.pause', 'Pause')
|
|
: t('player.play', 'Play')
|
|
}
|
|
>
|
|
<Button
|
|
size="icon"
|
|
onClick={handlePlayPause}
|
|
aria-label={
|
|
isPlaying ? t('player.pause', 'Pause') : t('player.play', 'Play')
|
|
}
|
|
>
|
|
{isPlaying ? (
|
|
<Pause className="h-5 w-5" />
|
|
) : (
|
|
<Play className="h-5 w-5" />
|
|
)}
|
|
</Button>
|
|
</Tooltip>
|
|
|
|
<Tooltip content={t('player.next', 'Next track')}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={next}
|
|
aria-label={t('player.next', 'Next track')}
|
|
>
|
|
<SkipForward className="h-5 w-5" />
|
|
</Button>
|
|
</Tooltip>
|
|
|
|
<Tooltip
|
|
content={
|
|
repeat === 'off'
|
|
? t('player.repeatOff', 'Repeat: Off')
|
|
: repeat === 'track'
|
|
? t('player.repeatTrack', 'Repeat: Track')
|
|
: t('player.repeatPlaylist', 'Repeat: Playlist')
|
|
}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleRepeatCycle}
|
|
className={repeat !== 'off' ? 'text-primary' : ''}
|
|
aria-label={
|
|
repeat === 'off'
|
|
? t('player.repeatOff', 'Repeat: Off')
|
|
: repeat === 'track'
|
|
? t('player.repeatTrack', 'Repeat: Track')
|
|
: t('player.repeatPlaylist', 'Repeat: Playlist')
|
|
}
|
|
>
|
|
<Repeat className="h-4 w-4" />
|
|
</Button>
|
|
</Tooltip>
|
|
</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">
|
|
<Tooltip
|
|
content={
|
|
muted
|
|
? t('player.unmute', 'Unmute')
|
|
: t('player.mute', 'Mute')
|
|
}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={toggleMute}
|
|
aria-label={
|
|
muted ? t('player.unmute', 'Unmute') : t('player.mute', 'Mute')
|
|
}
|
|
>
|
|
{muted ? (
|
|
<VolumeX className="h-4 w-4" />
|
|
) : (
|
|
<Volume2 className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</Tooltip>
|
|
<Slider
|
|
value={[volume]}
|
|
max={100}
|
|
step={1}
|
|
onValueChange={handleVolumeChange}
|
|
className="w-24"
|
|
/>
|
|
</div>
|
|
|
|
{/* Queue Toggle */}
|
|
<Tooltip
|
|
content={
|
|
showQueue
|
|
? t('player.hideQueue', 'Hide queue')
|
|
: t('player.showQueue', 'Show queue')
|
|
}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setShowQueue(!showQueue)}
|
|
className={showQueue ? 'text-primary' : ''}
|
|
aria-label={
|
|
showQueue
|
|
? t('player.hideQueue', 'Hide queue')
|
|
: t('player.showQueue', 'Show queue')
|
|
}
|
|
>
|
|
<List className="h-4 w-4" />
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Queue Panel */}
|
|
{showQueue && <QueuePanel onClose={() => setShowQueue(false)} />}
|
|
</>
|
|
);
|
|
}
|