123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- 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
|