Spaces:
Running
Running
| import { useRef, useEffect, useState, useCallback } from "react"; | |
| interface WaveformPlayerProps { | |
| audioUrl: string; | |
| duration?: number | null; | |
| } | |
| export default function WaveformPlayer({ audioUrl, duration }: WaveformPlayerProps) { | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const audioRef = useRef<HTMLAudioElement>(null); | |
| const animRef = useRef<number>(0); | |
| const waveformRef = useRef<number[]>([]); | |
| const [playing, setPlaying] = useState(false); | |
| const [currentTime, setCurrTime] = useState(0); | |
| const [totalDuration, setTotalDuration] = useState(0); | |
| const [hovering, setHovering] = useState(false); | |
| const [hoverX, setHoverX] = useState(0); | |
| // Decode audio and compute waveform peaks | |
| useEffect(() => { | |
| if (!audioUrl) return; | |
| const ctx = new AudioContext(); | |
| fetch(audioUrl) | |
| .then((r) => r.arrayBuffer()) | |
| .then((buf) => ctx.decodeAudioData(buf)) | |
| .then((decoded) => { | |
| const raw = decoded.getChannelData(0); | |
| const bars = 100; | |
| const blockSize = Math.floor(raw.length / bars); | |
| const peaks: number[] = []; | |
| for (let i = 0; i < bars; i++) { | |
| let sum = 0; | |
| for (let j = 0; j < blockSize; j++) { | |
| sum += Math.abs(raw[i * blockSize + j]); | |
| } | |
| peaks.push(sum / blockSize); | |
| } | |
| // Normalize | |
| const max = Math.max(...peaks, 0.01); | |
| waveformRef.current = peaks.map((p) => p / max); | |
| drawWaveform(); | |
| ctx.close(); | |
| }) | |
| .catch(() => {}); | |
| }, [audioUrl]); | |
| const drawWaveform = useCallback(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext("2d"); | |
| if (!ctx) return; | |
| const dpr = window.devicePixelRatio || 1; | |
| const rect = canvas.getBoundingClientRect(); | |
| canvas.width = rect.width * dpr; | |
| canvas.height = rect.height * dpr; | |
| ctx.scale(dpr, dpr); | |
| const w = rect.width; | |
| const h = rect.height; | |
| const peaks = waveformRef.current; | |
| const bars = peaks.length || 1; | |
| const audio = audioRef.current; | |
| const progress = audio && audio.duration ? audio.currentTime / audio.duration : 0; | |
| ctx.clearRect(0, 0, w, h); | |
| const barWidth = (w / bars) * 0.7; | |
| const gap = (w / bars) * 0.3; | |
| const mid = h / 2; | |
| for (let i = 0; i < bars; i++) { | |
| const x = (i / bars) * w; | |
| const barH = Math.max(2, (peaks[i] || 0) * mid * 0.9); | |
| const iPlayed = i / bars < progress; | |
| ctx.fillStyle = iPlayed ? "#c084fc" : hovering && x < hoverX ? "rgba(192,132,252,0.4)" : "#444"; | |
| ctx.beginPath(); | |
| ctx.roundRect(x + gap / 2, mid - barH, barWidth, barH * 2, 1.5); | |
| ctx.fill(); | |
| } | |
| }, [hovering, hoverX]); | |
| // Animation loop | |
| useEffect(() => { | |
| const tick = () => { | |
| const audio = audioRef.current; | |
| if (audio) setCurrTime(audio.currentTime); | |
| drawWaveform(); | |
| animRef.current = requestAnimationFrame(tick); | |
| }; | |
| animRef.current = requestAnimationFrame(tick); | |
| return () => cancelAnimationFrame(animRef.current); | |
| }, [drawWaveform]); | |
| const togglePlay = () => { | |
| const audio = audioRef.current; | |
| if (!audio) return; | |
| if (audio.paused) { | |
| audio.play(); | |
| setPlaying(true); | |
| } else { | |
| audio.pause(); | |
| setPlaying(false); | |
| } | |
| }; | |
| const seek = (e: React.MouseEvent<HTMLCanvasElement>) => { | |
| const audio = audioRef.current; | |
| const canvas = canvasRef.current; | |
| if (!audio || !canvas || !audio.duration) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const ratio = (e.clientX - rect.left) / rect.width; | |
| audio.currentTime = ratio * audio.duration; | |
| }; | |
| const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| setHoverX(e.clientX - rect.left); | |
| }; | |
| const fmt = (s: number) => { | |
| const m = Math.floor(s / 60); | |
| const sec = Math.floor(s % 60); | |
| return `${m}:${sec.toString().padStart(2, "0")}`; | |
| }; | |
| return ( | |
| <div className="waveform-player"> | |
| <audio | |
| ref={audioRef} | |
| src={audioUrl} | |
| onLoadedMetadata={() => setTotalDuration(audioRef.current?.duration || 0)} | |
| onEnded={() => setPlaying(false)} | |
| /> | |
| <button className="waveform-play" onClick={togglePlay} aria-label={playing ? "Pause" : "Play"}> | |
| {playing ? ( | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> | |
| <rect x="3" y="2" width="4" height="12" rx="1" /> | |
| <rect x="9" y="2" width="4" height="12" rx="1" /> | |
| </svg> | |
| ) : ( | |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> | |
| <path d="M4 2.5v11l9-5.5z" /> | |
| </svg> | |
| )} | |
| </button> | |
| <canvas | |
| ref={canvasRef} | |
| className="waveform-canvas" | |
| onClick={seek} | |
| onMouseEnter={() => setHovering(true)} | |
| onMouseLeave={() => setHovering(false)} | |
| onMouseMove={handleMouseMove} | |
| /> | |
| <span className="waveform-time"> | |
| {fmt(currentTime)} / {fmt(totalDuration)} | |
| </span> | |
| {duration !== null && duration !== undefined && ( | |
| <span className="waveform-gen-time"> | |
| {(duration / 1000).toFixed(1)}s | |
| </span> | |
| )} | |
| </div> | |
| ); | |
| } | |