You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
408 lines
13 KiB
408 lines
13 KiB
"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<AudioInputNodeData, "audioInput">;
|
|
|
|
export function AudioInputNode({ id, data, selected }: NodeProps<AudioInputNodeType>) {
|
|
const nodeData = data;
|
|
const commentNavigation = useCommentNavigation(id);
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
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);
|
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(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<HTMLInputElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<BaseNode
|
|
id={id}
|
|
title="Audio"
|
|
customTitle={nodeData.customTitle}
|
|
comment={nodeData.comment}
|
|
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })}
|
|
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
|
|
selected={selected}
|
|
commentNavigation={commentNavigation ?? undefined}
|
|
minWidth={250}
|
|
minHeight={150}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="audio/mp3,audio/mpeg,audio/wav,audio/ogg,audio/aac,audio/mp4,audio/*"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
|
|
{nodeData.audioFile ? (
|
|
<div className="relative group flex-1 flex flex-col min-h-0 gap-2">
|
|
{/* Filename and duration */}
|
|
<div className="flex items-center justify-between shrink-0">
|
|
<span className="text-[10px] text-neutral-400 truncate max-w-[150px]" title={nodeData.filename || ""}>
|
|
{nodeData.filename}
|
|
</span>
|
|
{nodeData.duration && (
|
|
<span className="text-[10px] text-neutral-500 bg-neutral-700/50 px-1.5 py-0.5 rounded">
|
|
{formatTime(nodeData.duration)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Waveform visualization */}
|
|
{isLoading ? (
|
|
<div className="flex-1 flex items-center justify-center bg-neutral-900/50 rounded min-h-[60px]">
|
|
<span className="text-xs text-neutral-500">Loading waveform...</span>
|
|
</div>
|
|
) : waveformData ? (
|
|
<div
|
|
ref={waveformContainerRef}
|
|
className="flex-1 min-h-[60px] bg-neutral-900/50 rounded cursor-pointer relative"
|
|
onClick={handleSeek}
|
|
>
|
|
<canvas ref={canvasRef} className="w-full h-full" />
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center bg-neutral-900/50 rounded min-h-[60px]">
|
|
<span className="text-xs text-neutral-500">Processing...</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Play/pause controls */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
onClick={handlePlayPause}
|
|
className="w-7 h-7 flex items-center justify-center bg-violet-600 hover:bg-violet-500 rounded transition-colors"
|
|
title={isPlaying ? "Pause" : "Play"}
|
|
>
|
|
{isPlaying ? (
|
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Progress bar / scrubber */}
|
|
<div className="flex-1 h-1 bg-neutral-700 rounded-full overflow-hidden relative">
|
|
{nodeData.duration && (
|
|
<div
|
|
className="h-full bg-violet-500 transition-all"
|
|
style={{ width: `${(currentTime / nodeData.duration) * 100}%` }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Current time */}
|
|
<span className="text-[10px] text-neutral-500 min-w-[32px] text-right">
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Remove button */}
|
|
<button
|
|
onClick={handleRemove}
|
|
className="absolute top-1 right-1 w-5 h-5 bg-black/60 text-white rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={() => 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"
|
|
>
|
|
<svg className="w-6 h-6 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
|
</svg>
|
|
<span className="text-[10px] text-neutral-400 mt-1">
|
|
Drop audio or click
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="audio"
|
|
data-handletype="audio"
|
|
style={{ background: "rgb(167, 139, 250)" }}
|
|
/>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="audio"
|
|
data-handletype="audio"
|
|
style={{ background: "rgb(167, 139, 250)" }}
|
|
/>
|
|
</BaseNode>
|
|
);
|
|
}
|
|
|