Browse Source
Deduplicate 6 patterns (drawWaveform, ResizeObserver, redraw effect, animation loop, handlePlayPause, handleSeek) that were 100% identical between AudioInputNode and GenerateAudioNode into a shared hook. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
3 changed files with 244 additions and 346 deletions
@ -0,0 +1,210 @@ |
|||
import { useCallback, useRef, useState, useEffect } from "react"; |
|||
import { WaveformData } from "./useAudioVisualization"; |
|||
|
|||
interface UseAudioPlaybackOptions { |
|||
audioSrc: string | null; |
|||
waveformData: WaveformData | null; |
|||
isLoadingWaveform: boolean; |
|||
} |
|||
|
|||
interface UseAudioPlaybackResult { |
|||
audioRef: React.RefObject<HTMLAudioElement | null>; |
|||
canvasRef: React.RefObject<HTMLCanvasElement | null>; |
|||
waveformContainerRef: React.RefObject<HTMLDivElement | null>; |
|||
isPlaying: boolean; |
|||
currentTime: number; |
|||
handlePlayPause: () => void; |
|||
handleSeek: (e: React.MouseEvent<HTMLDivElement>) => void; |
|||
formatTime: (seconds: number) => string; |
|||
waveformData: WaveformData | null; |
|||
isLoadingWaveform: boolean; |
|||
} |
|||
|
|||
export function useAudioPlayback({ audioSrc, waveformData, isLoadingWaveform }: UseAudioPlaybackOptions): UseAudioPlaybackResult { |
|||
const audioRef = useRef<HTMLAudioElement | null>(null); |
|||
const canvasRef = useRef<HTMLCanvasElement>(null); |
|||
const waveformContainerRef = useRef<HTMLDivElement>(null); |
|||
const animationFrameRef = useRef<number | undefined>(undefined); |
|||
|
|||
const [isPlaying, setIsPlaying] = useState(false); |
|||
const [currentTime, setCurrentTime] = useState(0); |
|||
|
|||
// Setup audio element
|
|||
useEffect(() => { |
|||
if (!audioSrc) { |
|||
if (audioRef.current) { |
|||
audioRef.current.pause(); |
|||
audioRef.current = null; |
|||
} |
|||
setIsPlaying(false); |
|||
setCurrentTime(0); |
|||
return; |
|||
} |
|||
|
|||
// Clean up previous audio element
|
|||
if (audioRef.current) { |
|||
audioRef.current.pause(); |
|||
} |
|||
|
|||
const audio = new Audio(audioSrc); |
|||
audioRef.current = audio; |
|||
|
|||
const handleEnded = () => { |
|||
setIsPlaying(false); |
|||
setCurrentTime(0); |
|||
}; |
|||
const handleTimeUpdate = () => { |
|||
setCurrentTime(audio.currentTime); |
|||
}; |
|||
audio.addEventListener("ended", handleEnded); |
|||
audio.addEventListener("timeupdate", handleTimeUpdate); |
|||
|
|||
return () => { |
|||
audio.removeEventListener("ended", handleEnded); |
|||
audio.removeEventListener("timeupdate", handleTimeUpdate); |
|||
audio.pause(); |
|||
audioRef.current = null; |
|||
}; |
|||
}, [audioSrc]); |
|||
|
|||
// Helper to draw waveform bars on canvas with optional progress indicator
|
|||
const drawWaveform = useCallback( |
|||
(ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[], progress?: number) => { |
|||
ctx.clearRect(0, 0, width, height); |
|||
|
|||
const barCount = Math.min(peaks.length, width); |
|||
const barWidth = width / barCount; |
|||
const barGap = 1; |
|||
const progressX = progress !== undefined ? progress * width : -1; |
|||
|
|||
for (let i = 0; i < barCount; i++) { |
|||
const peakIndex = Math.floor((i / barCount) * peaks.length); |
|||
const peak = peaks[peakIndex] || 0; |
|||
const barHeight = peak * height; |
|||
const x = i * barWidth; |
|||
const y = (height - barHeight) / 2; |
|||
|
|||
ctx.fillStyle = x < progressX ? "rgb(139, 92, 246)" : "rgba(139, 92, 246, 0.4)"; |
|||
ctx.fillRect(x, y, barWidth - barGap, barHeight); |
|||
} |
|||
|
|||
// Draw playback position line
|
|||
if (progressX > 0) { |
|||
ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; |
|||
ctx.lineWidth = 2; |
|||
ctx.beginPath(); |
|||
ctx.moveTo(progressX, 0); |
|||
ctx.lineTo(progressX, height); |
|||
ctx.stroke(); |
|||
} |
|||
}, |
|||
[] |
|||
); |
|||
|
|||
// ResizeObserver — only recreated when waveformData changes
|
|||
useEffect(() => { |
|||
if (!waveformData || !canvasRef.current || !waveformContainerRef.current) return; |
|||
|
|||
const canvas = canvasRef.current; |
|||
const container = waveformContainerRef.current; |
|||
const ctx = canvas.getContext("2d"); |
|||
if (!ctx) return; |
|||
|
|||
const resizeObserver = new ResizeObserver((entries) => { |
|||
for (const entry of entries) { |
|||
const width = entry.contentRect.width; |
|||
const height = entry.contentRect.height; |
|||
|
|||
canvas.width = width; |
|||
canvas.height = height; |
|||
|
|||
drawWaveform(ctx, width, height, waveformData.peaks); |
|||
} |
|||
}); |
|||
|
|||
resizeObserver.observe(container); |
|||
|
|||
return () => { |
|||
resizeObserver.disconnect(); |
|||
}; |
|||
}, [waveformData, drawWaveform]); |
|||
|
|||
// Redraw waveform + playback progress (lightweight, no ResizeObserver)
|
|||
useEffect(() => { |
|||
if (!waveformData || !canvasRef.current) return; |
|||
|
|||
const canvas = canvasRef.current; |
|||
const ctx = canvas.getContext("2d"); |
|||
if (!ctx) return; |
|||
|
|||
const width = canvas.width; |
|||
const height = canvas.height; |
|||
if (width === 0 || height === 0) return; |
|||
|
|||
const duration = audioRef.current?.duration; |
|||
const progress = duration && isFinite(duration) && currentTime > 0 ? currentTime / duration : undefined; |
|||
drawWaveform(ctx, width, height, waveformData.peaks, progress); |
|||
}, [isPlaying, currentTime, waveformData, drawWaveform]); |
|||
|
|||
// Animation loop for smooth playback position updates
|
|||
useEffect(() => { |
|||
if (isPlaying && audioRef.current) { |
|||
const updatePosition = () => { |
|||
if (audioRef.current) { |
|||
setCurrentTime(audioRef.current.currentTime); |
|||
} |
|||
animationFrameRef.current = requestAnimationFrame(updatePosition); |
|||
}; |
|||
animationFrameRef.current = requestAnimationFrame(updatePosition); |
|||
} |
|||
|
|||
return () => { |
|||
if (animationFrameRef.current) { |
|||
cancelAnimationFrame(animationFrameRef.current); |
|||
} |
|||
}; |
|||
}, [isPlaying]); |
|||
|
|||
const handlePlayPause = useCallback(() => { |
|||
if (!audioRef.current) return; |
|||
|
|||
if (isPlaying) { |
|||
audioRef.current.pause(); |
|||
setIsPlaying(false); |
|||
} else { |
|||
audioRef.current.play(); |
|||
setIsPlaying(true); |
|||
} |
|||
}, [isPlaying]); |
|||
|
|||
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => { |
|||
if (!audioRef.current || !audioRef.current.duration || !isFinite(audioRef.current.duration) || !waveformContainerRef.current) return; |
|||
|
|||
const rect = waveformContainerRef.current.getBoundingClientRect(); |
|||
const x = e.clientX - rect.left; |
|||
const progress = x / rect.width; |
|||
const newTime = progress * audioRef.current.duration; |
|||
|
|||
audioRef.current.currentTime = newTime; |
|||
setCurrentTime(newTime); |
|||
}, []); |
|||
|
|||
const formatTime = useCallback((seconds: number) => { |
|||
const mins = Math.floor(seconds / 60); |
|||
const secs = Math.floor(seconds % 60); |
|||
return `${mins}:${secs.toString().padStart(2, "0")}`; |
|||
}, []); |
|||
|
|||
return { |
|||
audioRef, |
|||
canvasRef, |
|||
waveformContainerRef, |
|||
isPlaying, |
|||
currentTime, |
|||
handlePlayPause, |
|||
handleSeek, |
|||
formatTime, |
|||
waveformData, |
|||
isLoadingWaveform, |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue