116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
import { Button } from '@/components/ui/button'
|
|
import { Slider } from '@/components/ui/slider'
|
|
import { cn } from '@/lib/utils'
|
|
import mediaManager from '@/services/media-manager.service'
|
|
import { Pause, Play } from 'lucide-react'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
interface AudioPlayerProps {
|
|
src: string
|
|
className?: string
|
|
}
|
|
|
|
export default function AudioPlayer({ src, className }: AudioPlayerProps) {
|
|
const audioRef = useRef<HTMLAudioElement>(null)
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
const [currentTime, setCurrentTime] = useState(0)
|
|
const [duration, setDuration] = useState(0)
|
|
const seekTimeoutRef = useRef<NodeJS.Timeout>()
|
|
const isSeeking = useRef(false)
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current
|
|
if (!audio) return
|
|
|
|
const updateTime = () => {
|
|
if (!isSeeking.current) {
|
|
setCurrentTime(audio.currentTime)
|
|
}
|
|
}
|
|
const updateDuration = () => setDuration(audio.duration)
|
|
const handleEnded = () => setIsPlaying(false)
|
|
const handlePause = () => setIsPlaying(false)
|
|
const handlePlay = () => setIsPlaying(true)
|
|
|
|
audio.addEventListener('timeupdate', updateTime)
|
|
audio.addEventListener('loadedmetadata', updateDuration)
|
|
audio.addEventListener('ended', handleEnded)
|
|
audio.addEventListener('pause', handlePause)
|
|
audio.addEventListener('play', handlePlay)
|
|
|
|
return () => {
|
|
audio.removeEventListener('timeupdate', updateTime)
|
|
audio.removeEventListener('loadedmetadata', updateDuration)
|
|
audio.removeEventListener('ended', handleEnded)
|
|
audio.removeEventListener('pause', handlePause)
|
|
audio.removeEventListener('play', handlePlay)
|
|
}
|
|
}, [])
|
|
|
|
const togglePlay = () => {
|
|
const audio = audioRef.current
|
|
if (!audio) return
|
|
|
|
if (isPlaying) {
|
|
audio.pause()
|
|
setIsPlaying(false)
|
|
} else {
|
|
audio.play()
|
|
setIsPlaying(true)
|
|
mediaManager.play(audio)
|
|
}
|
|
}
|
|
|
|
const handleSeek = (value: number[]) => {
|
|
const audio = audioRef.current
|
|
if (!audio) return
|
|
|
|
isSeeking.current = true
|
|
setCurrentTime(value[0])
|
|
|
|
if (seekTimeoutRef.current) {
|
|
clearTimeout(seekTimeoutRef.current)
|
|
}
|
|
|
|
seekTimeoutRef.current = setTimeout(() => {
|
|
audio.currentTime = value[0]
|
|
isSeeking.current = false
|
|
}, 300)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-3 py-2 pl-2 pr-4 border rounded-full max-w-md',
|
|
className
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<audio ref={audioRef} src={src} preload="metadata" />
|
|
|
|
{/* Play/Pause Button */}
|
|
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>
|
|
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
|
|
</Button>
|
|
|
|
{/* Progress Section */}
|
|
<div className="flex-1 relative">
|
|
<Slider value={[currentTime]} max={duration || 100} step={1} onValueChange={handleSeek} />
|
|
</div>
|
|
|
|
<div className="text-sm font-mono text-muted-foreground">
|
|
{formatTime(Math.max(duration - currentTime, 0))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const formatTime = (time: number) => {
|
|
if (time === Infinity || isNaN(time)) {
|
|
return '-:--'
|
|
}
|
|
const minutes = Math.floor(time / 60)
|
|
const seconds = Math.floor(time % 60)
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
}
|