import { useState, useRef, useEffect, useCallback } from "react"; import { ContextMenu } from "./AssetContextMenu"; import { basename, getAudioSubtype } from "./assetHelpers"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; import { usePlayerStore } from "../../player/store/playerStore"; import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior"; import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils"; export function AudioRow({ projectId, asset, used, meta, onCopy, isCopied, onDelete, onRename, onAddAssetToTimeline, }: { projectId: string; asset: string; used: boolean; meta?: { description?: string; duration?: number }; onCopy: (path: string) => void; isCopied: boolean; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; onAddAssetToTimeline?: (path: string) => void; }) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [playing, setPlaying] = useState(false); const [bars, setBars] = useState([]); const audioRef = useRef(null); const actxRef = useRef(null); const analyserRef = useRef(null); const sourceRef = useRef(null); const animRef = useRef(0); const name = basename(asset); const subtype = getAudioSubtype(asset); const serveUrl = resolveMediaPreviewUrl(asset, projectId); // CapCut-style click behavior: drag-threshold gate. const pointerDownRef = useRef<{ x: number; y: number } | null>(null); const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const requestClipReveal = usePlayerStore((s) => s.requestClipReveal); const elements = usePlayerStore((s) => s.elements); const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset); const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset); const handlePointerDown = useCallback((e: React.PointerEvent) => { pointerDownRef.current = { x: e.clientX, y: e.clientY }; }, []); const handlePointerUp = useCallback( (e: React.PointerEvent) => { const origin = pointerDownRef.current; pointerDownRef.current = null; if (!origin) return; if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return; if (used) { const clip = findClipForAsset(elements, asset); if (clip) { // Dismiss any open preview overlay (from another asset) — the reveal // must not leave a stale preview card floating over the canvas. clearPreviewAsset(); const clipKey = clip.key ?? clip.id; setSelectedElementId(clipKey); // Scroll the timeline so the selected clip is actually visible. requestClipReveal(clipKey); return; } } // Not added → preview overlay (audio player) setPreviewAsset(asset, projectId); }, [ used, elements, asset, projectId, setSelectedElementId, requestClipReveal, setPreviewAsset, clearPreviewAsset, ], ); useEffect(() => { return () => { cancelAnimationFrame(animRef.current); audioRef.current?.pause(); actxRef.current?.close(); }; }, []); useEffect(() => { if (playing) { const barCount = 24; const loop = () => { const analyser = analyserRef.current; if (!analyser) { animRef.current = requestAnimationFrame(loop); return; } const data = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(data); const step = Math.floor(data.length / barCount); const next: number[] = []; for (let i = 0; i < barCount; i++) { let sum = 0; for (let j = 0; j < step; j++) sum += data[i * step + j]; next.push(sum / step / 255); } setBars(next); if (audioRef.current && !audioRef.current.paused) animRef.current = requestAnimationFrame(loop); }; animRef.current = requestAnimationFrame(loop); } else { setBars([]); } return () => cancelAnimationFrame(animRef.current); }, [playing]); const togglePlay = useCallback(async () => { if (playing) { audioRef.current?.pause(); setPlaying(false); cancelAnimationFrame(animRef.current); return; } if (!actxRef.current) { actxRef.current = new AudioContext(); analyserRef.current = actxRef.current.createAnalyser(); analyserRef.current.fftSize = 256; analyserRef.current.smoothingTimeConstant = 0.7; } if (!audioRef.current) { const el = new Audio(); el.onended = () => { setPlaying(false); cancelAnimationFrame(animRef.current); }; audioRef.current = el; sourceRef.current = actxRef.current.createMediaElementSource(el); sourceRef.current.connect(analyserRef.current!); analyserRef.current!.connect(actxRef.current.destination); el.src = serveUrl; } if (actxRef.current.state === "suspended") await actxRef.current.resume(); audioRef.current.currentTime = 0; await audioRef.current.play(); setPlaying(true); }, [serveUrl, playing]); return ( <>
{ e.dataTransfer.effectAllowed = "copy"; e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); e.dataTransfer.setData("text/plain", asset); }} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }} className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${ playing ? "bg-panel-accent/[0.06]" : isCopied ? "bg-panel-accent/10" : "hover:bg-panel-surface-hover" }`} >
{name} {!playing && ( {meta?.duration ? `${meta.duration}s · ` : ""} {subtype} )} {used && ( in use )}
{bars.length > 0 && (
{bars.map((v, i) => (
))}
)}
{contextMenu && ( setContextMenu(null)} onCopy={onCopy} onDelete={onDelete} onRename={onRename} onAddAtPlayhead={onAddAssetToTimeline} /> )} ); }