mirror of
				https://github.com/langgenius/dify.git
				synced 2025-10-31 10:53:02 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			320 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			320 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import React, { useCallback, useEffect, useRef, useState } from 'react'
 | |
| import { t } from 'i18next'
 | |
| import {
 | |
|   RiPauseCircleFill,
 | |
|   RiPlayLargeFill,
 | |
| } from '@remixicon/react'
 | |
| import Toast from '@/app/components/base/toast'
 | |
| import useTheme from '@/hooks/use-theme'
 | |
| import { Theme } from '@/types/app'
 | |
| import cn from '@/utils/classnames'
 | |
| 
 | |
| 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)
 | |
|   const { theme } = useTheme()
 | |
| 
 | |
|   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 ts/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 {
 | |
|       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 = theme === Theme.light ? '#296DFF' : '#84ABFF'
 | |
|       else if ((index * barWidth / width) * duration <= hoverTime)
 | |
|         color = theme === Theme.light ? 'rgba(21,90,239,.40)' : 'rgba(200, 206, 218, 0.28)'
 | |
|       else
 | |
|         color = theme === Theme.light ? 'rgba(21,90,239,.20)' : 'rgba(200, 206, 218, 0.14)'
 | |
| 
 | |
|       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, theme, 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='flex h-9 min-w-[240px] max-w-[420px] items-end gap-2 rounded-[10px] border border-components-panel-border-subtle bg-components-chat-input-audio-bg-alt p-2 shadow-xs backdrop-blur-sm'>
 | |
|       <audio ref={audioRef} src={src} preload="auto"/>
 | |
|       <button className='inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled' onClick={togglePlay} disabled={!isAudioAvailable}>
 | |
|         {isPlaying
 | |
|           ? (
 | |
|             <RiPauseCircleFill className='h-5 w-5' />
 | |
|           )
 | |
|           : (
 | |
|             <RiPlayLargeFill className='h-5 w-5' />
 | |
|           )}
 | |
|       </button>
 | |
|       <div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}>
 | |
|         <div className='flex h-8 items-center justify-center'>
 | |
|           <canvas
 | |
|             ref={canvasRef}
 | |
|             className='relative flex h-6 w-full grow cursor-pointer items-center justify-center'
 | |
|             onClick={handleCanvasInteraction}
 | |
|             onMouseMove={handleMouseMove}
 | |
|             onMouseDown={handleCanvasInteraction}
 | |
|           />
 | |
|           <div className='system-xs-medium inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary'>
 | |
|             <span className='rounded-[10px] px-0.5 py-1'>{formatTime(duration)}</span>
 | |
|           </div>
 | |
|         </div>
 | |
|       </div>
 | |
|       <div className='absolute left-0 top-0 flex h-full w-full items-center justify-center text-text-quaternary' hidden={isAudioAvailable}>{t('common.operation.audioSourceUnavailable')}</div>
 | |
|     </div>
 | |
|   )
 | |
| }
 | |
| 
 | |
| export default AudioPlayer
 | 
