| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 | import React, { useCallback, useEffect, useRef, useState } from 'react'import { t } from 'i18next'import styles from './AudioPlayer.module.css'import Toast from '@/app/components/base/toast'type AudioPlayerProps = {  src: string}const AudioPlayer: React.FC<AudioPlayerProps> = ({ src }) => {  const [isPlaying, setIsPlaying] = useState(false)  const [currentTime, setCurrentTime] = useState(0)  const [duration, setDuration] = useState(0)  const [waveformData, setWaveformData] = useState<number[]>([])  const [bufferedTime, setBufferedTime] = useState(0)  const audioRef = useRef<HTMLAudioElement>(null)  const canvasRef = useRef<HTMLCanvasElement>(null)  const [hasStartedPlaying, setHasStartedPlaying] = useState(false)  const [hoverTime, setHoverTime] = useState(0)  const [isAudioAvailable, setIsAudioAvailable] = useState(true)  useEffect(() => {    const audio = audioRef.current    if (!audio)      return    const handleError = () => {      setIsAudioAvailable(false)    }    const setAudioData = () => {      setDuration(audio.duration)    }    const setAudioTime = () => {      setCurrentTime(audio.currentTime)    }    const handleProgress = () => {      if (audio.buffered.length > 0)        setBufferedTime(audio.buffered.end(audio.buffered.length - 1))    }    const handleEnded = () => {      setIsPlaying(false)    }    audio.addEventListener('loadedmetadata', setAudioData)    audio.addEventListener('timeupdate', setAudioTime)    audio.addEventListener('progress', handleProgress)    audio.addEventListener('ended', handleEnded)    audio.addEventListener('error', handleError)    // Preload audio metadata    audio.load()    // Delayed generation of waveform data    // eslint-disable-next-line @typescript-eslint/no-use-before-define    const timer = setTimeout(() => generateWaveformData(src), 1000)    return () => {      audio.removeEventListener('loadedmetadata', setAudioData)      audio.removeEventListener('timeupdate', setAudioTime)      audio.removeEventListener('progress', handleProgress)      audio.removeEventListener('ended', handleEnded)      audio.removeEventListener('error', handleError)      clearTimeout(timer)    }  }, [src])  const generateWaveformData = async (audioSrc: string) => {    if (!window.AudioContext && !(window as any).webkitAudioContext) {      setIsAudioAvailable(false)      Toast.notify({        type: 'error',        message: 'Web Audio API is not supported in this browser',      })      return null    }    const url = new URL(src)    const isHttp = url.protocol === 'http:' || url.protocol === 'https:'    if (!isHttp) {      setIsAudioAvailable(false)      return null    }    const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()    const samples = 70    try {      const response = await fetch(audioSrc, { mode: 'cors' })      if (!response || !response.ok) {        setIsAudioAvailable(false)        return null      }      const arrayBuffer = await response.arrayBuffer()      const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)      const channelData = audioBuffer.getChannelData(0)      const blockSize = Math.floor(channelData.length / samples)      const waveformData: number[] = []      for (let i = 0; i < samples; i++) {        let sum = 0        for (let j = 0; j < blockSize; j++)          sum += Math.abs(channelData[i * blockSize + j])        // Apply nonlinear scaling to enhance small amplitudes        waveformData.push((sum / blockSize) * 5)      }      // Normalized waveform data      const maxAmplitude = Math.max(...waveformData)      const normalizedWaveform = waveformData.map(amp => amp / maxAmplitude)      setWaveformData(normalizedWaveform)      setIsAudioAvailable(true)    }    catch (error) {      const waveform: number[] = []      let prevValue = Math.random()      for (let i = 0; i < samples; i++) {        const targetValue = Math.random()        const interpolatedValue = prevValue + (targetValue - prevValue) * 0.3        waveform.push(interpolatedValue)        prevValue = interpolatedValue      }      const maxAmplitude = Math.max(...waveform)      const randomWaveform = waveform.map(amp => amp / maxAmplitude)      setWaveformData(randomWaveform)      setIsAudioAvailable(true)    }    finally {      await audioContext.close()    }  }  const togglePlay = useCallback(() => {    const audio = audioRef.current    if (audio && isAudioAvailable) {      if (isPlaying) {        setHasStartedPlaying(false)        audio.pause()      }      else {        setHasStartedPlaying(true)        audio.play().catch(error => console.error('Error playing audio:', error))      }      setIsPlaying(!isPlaying)    }    else {      Toast.notify({        type: 'error',        message: 'Audio element not found',      })      setIsAudioAvailable(false)    }  }, [isAudioAvailable, isPlaying])  const handleCanvasInteraction = useCallback((e: React.MouseEvent | React.TouchEvent) => {    e.preventDefault()    const getClientX = (event: React.MouseEvent | React.TouchEvent): number => {      if ('touches' in event)        return event.touches[0].clientX      return event.clientX    }    const updateProgress = (clientX: number) => {      const canvas = canvasRef.current      const audio = audioRef.current      if (!canvas || !audio)        return      const rect = canvas.getBoundingClientRect()      const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width      const newTime = percent * duration      // Removes the buffer check, allowing drag to any location      audio.currentTime = newTime      setCurrentTime(newTime)      if (!isPlaying) {        setIsPlaying(true)        audio.play().catch((error) => {          Toast.notify({            type: 'error',            message: `Error playing audio: ${error}`,          })          setIsPlaying(false)        })      }    }    updateProgress(getClientX(e))  }, [duration, isPlaying])  const formatTime = (time: number) => {    const minutes = Math.floor(time / 60)    const seconds = Math.floor(time % 60)    return `${minutes}:${seconds.toString().padStart(2, '0')}`  }  const drawWaveform = useCallback(() => {    const canvas = canvasRef.current    if (!canvas)      return    const ctx = canvas.getContext('2d')    if (!ctx)      return    const width = canvas.width    const height = canvas.height    const data = waveformData    ctx.clearRect(0, 0, width, height)    const barWidth = width / data.length    const playedWidth = (currentTime / duration) * width    const cornerRadius = 2    // Draw waveform bars    data.forEach((value, index) => {      let color      if (index * barWidth <= playedWidth)        color = '#296DFF'      else if ((index * barWidth / width) * duration <= hoverTime)        color = 'rgba(21,90,239,.40)'      else        color = 'rgba(21,90,239,.20)'      const barHeight = value * height      const rectX = index * barWidth      const rectY = (height - barHeight) / 2      const rectWidth = barWidth * 0.5      const rectHeight = barHeight      ctx.lineWidth = 1      ctx.fillStyle = color      if (ctx.roundRect) {        ctx.beginPath()        ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius)        ctx.fill()      }      else {        ctx.fillRect(rectX, rectY, rectWidth, rectHeight)      }    })  }, [currentTime, duration, hoverTime, waveformData])  useEffect(() => {    drawWaveform()  }, [drawWaveform, bufferedTime, hasStartedPlaying])  const handleMouseMove = useCallback((e: React.MouseEvent) => {    const canvas = canvasRef.current    const audio = audioRef.current    if (!canvas || !audio)      return    const rect = canvas.getBoundingClientRect()    const percent = Math.min(Math.max(0, e.clientX - rect.left), rect.width) / rect.width    const time = percent * duration    // Check if the hovered position is within a buffered range before updating hoverTime    for (let i = 0; i < audio.buffered.length; i++) {      if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {        setHoverTime(time)        break      }    }  }, [duration])  return (    <div className={styles.audioPlayer}>      <audio ref={audioRef} src={src} preload="auto"/>      <button className={styles.playButton} onClick={togglePlay} disabled={!isAudioAvailable}>        {isPlaying          ? (            <svg viewBox="0 0 24 24" width="16" height="16">              <rect x="7" y="6" width="3" height="12" rx="1.5" ry="1.5"/>              <rect x="15" y="6" width="3" height="12" rx="1.5" ry="1.5"/>            </svg>          )          : (            <svg viewBox="0 0 24 24" width="16" height="16">              <path d="M8 5v14l11-7z" fill="currentColor"/>            </svg>          )}      </button>      <div className={isAudioAvailable ? styles.audioControls : styles.audioControls_disabled} hidden={!isAudioAvailable}>        <div className={styles.progressBarContainer}>          <canvas            ref={canvasRef}            className={styles.waveform}            onClick={handleCanvasInteraction}            onMouseMove={handleMouseMove}            onMouseDown={handleCanvasInteraction}          />          {/* <div className={styles.currentTime} style={{ left: `${(currentTime / duration) * 81}%`, bottom: '29px' }}>            {formatTime(currentTime)}          </div> */}          <div className={styles.timeDisplay}>            <span className={styles.duration}>{formatTime(duration)}</span>          </div>        </div>      </div>      <div className={styles.source_unavailable} hidden={isAudioAvailable}>{t('common.operation.audioSourceUnavailable')}</div>    </div>  )}export default AudioPlayer
 |