feat: audio

This commit is contained in:
codytseng
2025-07-29 22:44:43 +08:00
parent de09942124
commit 4ea5ea1705
37 changed files with 629 additions and 145 deletions

View File

@@ -0,0 +1,113 @@
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(duration)}</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')}`
}