From dff6027b3e81383cfd1b9c59ab1d40fff3eea0eb Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 18 Feb 2026 22:14:09 +1300 Subject: [PATCH] refactor: extract useAudioPlayback hook from audio node components 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 --- src/components/nodes/AudioInputNode.tsx | 188 ++---------------- src/components/nodes/GenerateAudioNode.tsx | 192 ++----------------- src/hooks/useAudioPlayback.ts | 210 +++++++++++++++++++++ 3 files changed, 244 insertions(+), 346 deletions(-) create mode 100644 src/hooks/useAudioPlayback.ts diff --git a/src/components/nodes/AudioInputNode.tsx b/src/components/nodes/AudioInputNode.tsx index 06f11335..a02fcdb8 100644 --- a/src/components/nodes/AudioInputNode.tsx +++ b/src/components/nodes/AudioInputNode.tsx @@ -7,6 +7,7 @@ import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { useWorkflowStore } from "@/store/workflowStore"; import { AudioInputNodeData } from "@/types"; import { useAudioVisualization } from "@/hooks/useAudioVisualization"; +import { useAudioPlayback } from "@/hooks/useAudioPlayback"; type AudioInputNodeType = Node; @@ -15,18 +16,9 @@ export function AudioInputNode({ id, data, selected }: NodeProps state.updateNodeData); const fileInputRef = useRef(null); - const audioRef = useRef(null); - const canvasRef = useRef(null); - const waveformContainerRef = useRef(null); - const animationFrameRef = useRef(undefined); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); - // Use the audio visualization hook - const { waveformData, isLoading } = useAudioVisualization(audioBlob); - // Convert base64 data URL to Blob for the hook useEffect(() => { if (nodeData.audioFile) { @@ -39,135 +31,21 @@ export function AudioInputNode({ id, data, selected }: NodeProps { - if (nodeData.audioFile && !audioRef.current) { - const audio = new Audio(nodeData.audioFile); - 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; - }; - } - - return () => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } - }; - }, [nodeData.audioFile]); - - // 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)"; // violet-500 played, violet-500/40 unplayed - 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(); - } - }, - [] - ); - - // Effect A: 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]); - - // Effect B: 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 { waveformData, isLoading } = useAudioVisualization(audioBlob); + const { + audioRef, + canvasRef, + waveformContainerRef, + isPlaying, + currentTime, + handlePlayPause, + handleSeek, + formatTime, + } = useAudioPlayback({ + audioSrc: nodeData.audioFile ?? null, + waveformData, + isLoadingWaveform: isLoading, + }); const handleFileChange = useCallback( (e: React.ChangeEvent) => { @@ -232,8 +110,6 @@ export function AudioInputNode({ id, data, selected }: NodeProps { - if (!audioRef.current) return; - - if (isPlaying) { - audioRef.current.pause(); - setIsPlaying(false); - } else { - audioRef.current.play(); - setIsPlaying(true); - } - }, [isPlaying]); - - const handleSeek = useCallback((e: React.MouseEvent) => { - 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 = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; - }; + }, [id, updateNodeData, audioRef]); return ( ; @@ -21,12 +22,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps state.generationsPath); const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isLoadingCarouselAudio, setIsLoadingCarouselAudio] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const audioRef = useRef(null); - const canvasRef = useRef(null); - const waveformContainerRef = useRef(null); - const animationFrameRef = useRef(undefined); // Get the current selected provider (default to fal) const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal"; @@ -46,176 +41,29 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps { - if (!nodeData.outputAudio) { - 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(nodeData.outputAudio); - 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; - }; - }, [nodeData.outputAudio]); - - // Draw waveform 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)"; // violet-500 played, violet-500/40 unplayed - 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(); - } - }, - [] - ); - - // Effect: ResizeObserver for waveform canvas - 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]); - - // Effect: Redraw waveform with playback progress - 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 { + audioRef, + canvasRef, + waveformContainerRef, + isPlaying, + currentTime, + handlePlayPause, + handleSeek, + formatTime, + } = useAudioPlayback({ + audioSrc: nodeData.outputAudio ?? null, + waveformData, + isLoadingWaveform, + }); const handleClearAudio = useCallback(() => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } - setIsPlaying(false); - setCurrentTime(0); setAudioBlob(null); updateNodeData(id, { outputAudio: null, status: "idle", error: null, duration: null, format: null }); - }, [id, updateNodeData]); - - const handleSeek = useCallback((e: React.MouseEvent) => { - 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 handlePlayPause = useCallback(() => { - if (!audioRef.current) return; - - if (isPlaying) { - audioRef.current.pause(); - setIsPlaying(false); - } else { - audioRef.current.play(); - setIsPlaying(true); - } - }, [isPlaying]); + }, [id, updateNodeData, audioRef]); const handleParametersChange = useCallback( (parameters: Record) => { @@ -343,12 +191,6 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; - }; - // Provider badge as title prefix const titlePrefix = useMemo(() => ( diff --git a/src/hooks/useAudioPlayback.ts b/src/hooks/useAudioPlayback.ts new file mode 100644 index 00000000..3f18613f --- /dev/null +++ b/src/hooks/useAudioPlayback.ts @@ -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; + canvasRef: React.RefObject; + waveformContainerRef: React.RefObject; + isPlaying: boolean; + currentTime: number; + handlePlayPause: () => void; + handleSeek: (e: React.MouseEvent) => void; + formatTime: (seconds: number) => string; + waveformData: WaveformData | null; + isLoadingWaveform: boolean; +} + +export function useAudioPlayback({ audioSrc, waveformData, isLoadingWaveform }: UseAudioPlaybackOptions): UseAudioPlaybackResult { + const audioRef = useRef(null); + const canvasRef = useRef(null); + const waveformContainerRef = useRef(null); + const animationFrameRef = useRef(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) => { + 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, + }; +}