index.tsx raw
1 import { Button } from '@/components/ui/button'
2 import { Slider } from '@/components/ui/slider'
3 import { cn } from '@/lib/utils'
4 import mediaManager from '@/services/media-manager.service'
5 import { Minimize2, Pause, Play, X } from 'lucide-react'
6 import { useEffect, useRef, useState } from 'react'
7 import ExternalLink from '../ExternalLink'
8
9 interface AudioPlayerProps {
10 src: string
11 autoPlay?: boolean
12 startTime?: number
13 isMinimized?: boolean
14 className?: string
15 }
16
17 export default function AudioPlayer({
18 src,
19 autoPlay = false,
20 startTime,
21 isMinimized = false,
22 className
23 }: AudioPlayerProps) {
24 const audioRef = useRef<HTMLAudioElement>(null)
25 const [isPlaying, setIsPlaying] = useState(false)
26 const [currentTime, setCurrentTime] = useState(0)
27 const [duration, setDuration] = useState(0)
28 const [error, setError] = useState(false)
29 const seekTimeoutRef = useRef<NodeJS.Timeout>()
30 const isSeeking = useRef(false)
31 const containerRef = useRef<HTMLDivElement>(null)
32
33 useEffect(() => {
34 const audio = audioRef.current
35 if (!audio) return
36
37 if (startTime) {
38 setCurrentTime(startTime)
39 audio.currentTime = startTime
40 }
41
42 if (autoPlay) {
43 togglePlay()
44 }
45
46 const updateTime = () => {
47 if (!isSeeking.current) {
48 setCurrentTime(audio.currentTime)
49 }
50 }
51 const updateDuration = () => setDuration(audio.duration)
52 const handleEnded = () => setIsPlaying(false)
53 const handlePause = () => setIsPlaying(false)
54 const handlePlay = () => setIsPlaying(true)
55
56 audio.addEventListener('timeupdate', updateTime)
57 audio.addEventListener('loadedmetadata', updateDuration)
58 audio.addEventListener('ended', handleEnded)
59 audio.addEventListener('pause', handlePause)
60 audio.addEventListener('play', handlePlay)
61
62 return () => {
63 audio.removeEventListener('timeupdate', updateTime)
64 audio.removeEventListener('loadedmetadata', updateDuration)
65 audio.removeEventListener('ended', handleEnded)
66 audio.removeEventListener('pause', handlePause)
67 audio.removeEventListener('play', handlePlay)
68 }
69 }, [])
70
71 useEffect(() => {
72 const audio = audioRef.current
73 const container = containerRef.current
74
75 if (!audio || !container) return
76
77 const observer = new IntersectionObserver(
78 ([entry]) => {
79 if (!entry.isIntersecting) {
80 audio.pause()
81 }
82 },
83 { threshold: 1 }
84 )
85
86 observer.observe(container)
87
88 return () => {
89 observer.unobserve(container)
90 }
91 }, [])
92
93 const togglePlay = () => {
94 const audio = audioRef.current
95 if (!audio) return
96
97 if (isPlaying) {
98 audio.pause()
99 setIsPlaying(false)
100 } else {
101 audio.play()
102 setIsPlaying(true)
103 mediaManager.play(audio)
104 }
105 }
106
107 const handleSeek = (value: number[]) => {
108 const audio = audioRef.current
109 if (!audio) return
110
111 isSeeking.current = true
112 setCurrentTime(value[0])
113
114 if (seekTimeoutRef.current) {
115 clearTimeout(seekTimeoutRef.current)
116 }
117
118 seekTimeoutRef.current = setTimeout(() => {
119 audio.currentTime = value[0]
120 isSeeking.current = false
121 }, 300)
122 }
123
124 if (error) {
125 return <ExternalLink url={src} />
126 }
127
128 return (
129 <div
130 ref={containerRef}
131 className={cn(
132 'flex items-center gap-3 py-2 px-2 border rounded-full max-w-md bg-background',
133 className
134 )}
135 onClick={(e) => e.stopPropagation()}
136 >
137 <audio ref={audioRef} src={src} preload="metadata" onError={() => setError(false)} />
138
139 {/* Play/Pause Button */}
140 <Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>
141 {isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
142 </Button>
143
144 {/* Progress Section */}
145 <div className="flex-1 relative">
146 <Slider
147 value={[currentTime]}
148 max={duration || 100}
149 step={1}
150 onValueChange={handleSeek}
151 hideThumb
152 enableHoverAnimation
153 />
154 </div>
155
156 <div className="text-sm font-mono text-muted-foreground">
157 {formatTime(Math.max(duration - currentTime, 0))}
158 </div>
159 {isMinimized ? (
160 <Button
161 variant="ghost"
162 size="icon"
163 className="rounded-full shrink-0 text-muted-foreground"
164 onClick={() => mediaManager.stopAudioBackground()}
165 >
166 <X />
167 </Button>
168 ) : (
169 <Button
170 variant="ghost"
171 size="icon"
172 className="rounded-full shrink-0 text-muted-foreground"
173 onClick={() => mediaManager.playAudioBackground(src, audioRef.current?.currentTime || 0)}
174 >
175 <Minimize2 />
176 </Button>
177 )}
178 </div>
179 )
180 }
181
182 const formatTime = (time: number) => {
183 if (time === Infinity || isNaN(time)) {
184 return '-:--'
185 }
186 const minutes = Math.floor(time / 60)
187 const seconds = Math.floor(time % 60)
188 return `${minutes}:${seconds.toString().padStart(2, '0')}`
189 }
190