"use client"; import { useCallback, useRef, useState, useEffect } from "react"; import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { useWorkflowStore } from "@/store/workflowStore"; import { AudioInputNodeData } from "@/types"; import { useAudioVisualization } from "@/hooks/useAudioVisualization"; type AudioInputNodeType = Node; export function AudioInputNode({ id, data, selected }: NodeProps) { const nodeData = data; const commentNavigation = useCommentNavigation(id); const updateNodeData = useWorkflowStore((state) => 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) { fetch(nodeData.audioFile) .then((r) => r.blob()) .then(setAudioBlob) .catch(() => setAudioBlob(null)); } else { setAudioBlob(null); } }, [nodeData.audioFile]); // Setup audio element useEffect(() => { 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 const drawWaveform = useCallback( (ctx: CanvasRenderingContext2D, width: number, height: number, peaks: number[]) => { ctx.clearRect(0, 0, width, height); const barCount = Math.min(peaks.length, width); const barWidth = width / barCount; const barGap = 1; ctx.fillStyle = "rgb(167, 139, 250)"; // violet-400 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.fillRect(x, y, barWidth - barGap, barHeight); } }, [] ); // 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 position (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; drawWaveform(ctx, width, height, waveformData.peaks); // Draw playback position if (isPlaying && nodeData.duration) { const progress = currentTime / nodeData.duration; const x = progress * width; ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } }, [isPlaying, currentTime, nodeData.duration, 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 handleFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (!file.type.match(/^audio\//)) { alert("Unsupported format. Use MP3, WAV, OGG, AAC, or other audio formats."); return; } if (file.size > 50 * 1024 * 1024) { alert("Audio file too large. Maximum size is 50MB."); return; } const reader = new FileReader(); reader.onload = (event) => { const base64 = event.target?.result as string; // Extract duration using HTML Audio element const audio = new Audio(base64); audio.onloadedmetadata = () => { updateNodeData(id, { audioFile: base64, filename: file.name, format: file.type, duration: audio.duration, }); }; }; reader.readAsDataURL(file); }, [id, updateNodeData] ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer.files?.[0]; if (!file) return; const dt = new DataTransfer(); dt.items.add(file); if (fileInputRef.current) { fileInputRef.current.files = dt.files; fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true })); } }, [] ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleRemove = useCallback(() => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } setIsPlaying(false); setCurrentTime(0); setAudioBlob(null); updateNodeData(id, { audioFile: null, filename: null, duration: null, format: null, }); }, [id, updateNodeData]); 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 || !nodeData.duration || !waveformContainerRef.current) return; const rect = waveformContainerRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const progress = x / rect.width; const newTime = progress * nodeData.duration; audioRef.current.currentTime = newTime; setCurrentTime(newTime); }, [nodeData.duration]); const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, "0")}`; }; return ( updateNodeData(id, { customTitle: title || undefined })} onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} selected={selected} commentNavigation={commentNavigation ?? undefined} minWidth={250} minHeight={150} > {nodeData.audioFile ? (
{/* Filename and duration */}
{nodeData.filename} {nodeData.duration && ( {formatTime(nodeData.duration)} )}
{/* Waveform visualization */} {isLoading ? (
Loading waveform...
) : waveformData ? (
) : (
Processing...
)} {/* Play/pause controls */}
{/* Progress bar / scrubber */}
{nodeData.duration && (
)}
{/* Current time */} {formatTime(currentTime)}
{/* Remove button */}
) : (
fileInputRef.current?.click()} onDrop={handleDrop} onDragOver={handleDragOver} className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center cursor-pointer hover:border-neutral-500 hover:bg-neutral-700/50 transition-colors" > Drop audio or click
)} ); }