'use client'; import { useDraggable } from '@dnd-kit/core'; import { formatSoundName } from '@/lib/utils'; import type { SoundAsset } from '@/lib/types'; import { useState, useRef, useEffect, useCallback } from 'react'; interface SoundUnitProps { sound: SoundAsset; isAssigned: boolean; onPreview: (path: string) => void; isOverlay?: boolean; onSelectAssign?: () => void; // if set, card click assigns instead of plays packLabel?: string; // shown when browsing multiple packs simultaneously } const BARS = 16; export function SoundUnit({ sound, isAssigned, onPreview, isOverlay, onSelectAssign, packLabel }: SoundUnitProps) { const [isPlaying, setIsPlaying] = useState(false); const [isHovered, setIsHovered] = useState(false); const [bars, setBars] = useState(sound.waveform.map(h => h / 10)); const ctxRef = useRef(null); const sourceRef = useRef(null); const rafRef = useRef(null); // Track whether a drag actually happened so we can suppress the click const didDragRef = useRef(false); useEffect(() => { setBars(sound.waveform.map(h => h / 10)); }, [sound.waveform]); const stop = useCallback(() => { if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } if (sourceRef.current) { try { sourceRef.current.stop(); } catch {} sourceRef.current = null; } if (ctxRef.current) { ctxRef.current.close(); ctxRef.current = null; } setIsPlaying(false); setBars(sound.waveform.map(h => h / 10)); }, [sound.waveform]); const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: sound.path, data: { sound }, disabled: isOverlay, }); // Track drag so we can suppress click-to-play after a drag useEffect(() => { if (isDragging) didDragRef.current = true; }, [isDragging]); const playSound = useCallback(async () => { if (isPlaying) { stop(); return; } setIsPlaying(true); const ctx = new AudioContext(); ctxRef.current = ctx; const analyser = ctx.createAnalyser(); analyser.fftSize = 256; analyser.smoothingTimeConstant = 0.6; analyser.connect(ctx.destination); let audioBuffer: AudioBuffer; try { const res = await fetch(`/api/audio/${sound.path}`); const arrayBuf = await res.arrayBuffer(); audioBuffer = await ctx.decodeAudioData(arrayBuf); } catch { stop(); return; } const source = ctx.createBufferSource(); source.buffer = audioBuffer; source.connect(analyser); source.start(); sourceRef.current = source; source.onended = stop; const freqData = new Uint8Array(analyser.frequencyBinCount); const binsPerBar = Math.floor(analyser.frequencyBinCount / BARS); const draw = () => { analyser.getByteFrequencyData(freqData); const next = Array.from({ length: BARS }, (_, i) => { let sum = 0; for (let j = 0; j < binsPerBar; j++) sum += freqData[i * binsPerBar + j]; return Math.max(0.04, (sum / binsPerBar) / 255); }); setBars(next); rafRef.current = requestAnimationFrame(draw); }; draw(); }, [isPlaying, sound.path, stop]); const handleCardClick = useCallback(() => { if (didDragRef.current) { didDragRef.current = false; return; } if (onSelectAssign) { onSelectAssign(); } else { playSound(); } }, [playSound, onSelectAssign]); if (isOverlay) { return (
{bars.map((v, i) => (
))}
{formatSoundName(sound.filename)}
); } const lit = isHovered || isPlaying; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={handleCardClick} {...listeners} {...attributes} > {isAssigned && !isPlaying && (
)}
{bars.map((v, i) => (
))}
{formatSoundName(sound.filename)} {onSelectAssign && !isPlaying && ( )} {isPlaying && ( )}
{packLabel && ( {packLabel} )}
); }