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