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

405 lines
12 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';
import { logger } from '@/utils/logger';
import type { BaseComponentProps } from '../types';
/**
* Props for AudioPlayer component
* FE-TYPE-013: Fully typed component props
*/
export interface AudioPlayerProps extends BaseComponentProps {
// No additional props needed - uses global player store
}
export function AudioPlayer({ className: _className }: AudioPlayerProps = {}) {
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) => {
logger.error('Playback error', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
trackId: currentTrack?.id,
});
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)} />}
</>
);
}