import { useState, useRef, useCallback, useMemo } from "react"; // ── Design tokens ── const T = { bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32", amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060", green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458", }; // ── Block param definitions (mirrored from App.jsx) ── const BLOCK_PARAMS = { overdrive: [{ key:'drive', label:'Drive', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }, { key:'level', label:'Level', min:0, max:100, def:70 }], distortion: [{ key:'drive', label:'Drive', min:0, max:100, def:50 }, { key:'tone', label:'Tone', min:0, max:100, def:40 }, { key:'level', label:'Level', min:0, max:100, def:60 }], fuzz: [{ key:'fuzz', label:'Fuzz', min:0, max:100, def:60 }, { key:'tone', label:'Tone', min:0, max:100, def:30 }, { key:'level', label:'Level', min:0, max:100, def:60 }], delay: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }], echo: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }], reverb: [{ key:'decay', label:'Decay', min:0, max:100, def:50 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }], chorus: [{ key:'rate', label:'Rate', min:0, max:100, def:40 }, { key:'depth', label:'Depth', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }], flanger: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }], phaser: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }], compressor: [{ key:'threshold', label:'Thresh', min:0, max:100, def:50 }, { key:'ratio', label:'Ratio', min:1, max:20, def:4 }, { key:'gain', label:'Gain', min:0, max:100, def:50 }], gate: [{ key:'threshold', label:'Thresh', min:0, max:100, def:30 }, { key:'release', label:'Release', min:0, max:100, def:50 }], eq: [{ key:'low', label:'Low', min:-12, max:12, def:0 }, { key:'mid', label:'Mid', min:-12, max:12, def:0 }, { key:'high', label:'High', min:-12, max:12, def:0 }, { key:'level', label:'Level', min:0, max:100, def:70 }], cabinet: [{ key:'level', label:'Level', min:0, max:100, def:75 }], ir: [{ key:'level', label:'Level', min:0, max:100, def:75 }], }; const BLOCK_COLORS = { overdrive: T.amber, distortion: T.red, fuzz: '#C07030', delay: T.blue, echo: T.blue, reverb: '#60B0D0', chorus: T.green, flanger: '#50C080', phaser: '#80C050', tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0', cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', boost: T.amber, wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0', }; const BLOCK_DISPLAY_NAMES = { overdrive: "OD-808", distortion: "RAT", fuzz: "Big Muff", delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb", chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90", tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate", eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model", boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner", loop: "FX Loop", }; function getBlockParams(type) { return BLOCK_PARAMS[(type || '').toLowerCase()] || [{ key:'level', label:'Level', min:0, max:100, def:50 }]; } function getBlockColor(type) { return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber; } function getBlockDisplayName(block) { const key = (block.type || '').toLowerCase(); if (block.nam_model_path) { const parts = block.nam_model_path.split('/').pop()?.replace(/\.(nam|wav|aiff?)$/i, '') || ''; return parts || 'NAM Model'; } if (block.ir_file_path) { const parts = block.ir_file_path.split('/').pop()?.replace(/\.(wav|aiff?|ir)$/i, '') || ''; return parts || 'IR Loader'; } return BLOCK_DISPLAY_NAMES[key] || key.charAt(0).toUpperCase() + key.slice(1); } // ── Large touch knob (bigger version for Focus View) ── function FocusKnob({ label, value = 50, onChange, size = 72, color = T.amber, min = 0, max = 100, compact = false }) { const startRef = useRef(null); const norm = (value - min) / (max - min); const angle = -140 + norm * 280; const dotH = size * 0.38; const dotTop = size * 0.5 - dotH; const handleStart = (e) => { e.preventDefault(); const clientY = e.touches ? e.touches[0].clientY : e.clientY; startRef.current = { y: clientY, val: value }; const move = (ev) => { const cy = ev.touches ? ev.touches[0].clientY : ev.clientY; const delta = (startRef.current.y - cy) / 120; const next = Math.max(min, Math.min(max, startRef.current.val + delta * (max - min))); onChange?.(Math.round(next)); }; const up = () => { window.removeEventListener("mousemove",move);window.removeEventListener("mouseup",up); window.removeEventListener("touchmove",move);window.removeEventListener("touchend",up); }; window.addEventListener("mousemove",move);window.addEventListener("mouseup",up); window.addEventListener("touchmove",move,{passive:false});window.addEventListener("touchend",up); }; const svgSize = size + 16, cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 4; const lx = cx + r * Math.cos((-140 - 90) * (Math.PI / 180)), ly = cy + r * Math.sin((-140 - 90) * (Math.PI / 180)); const ex = cx + r * Math.cos((angle - 90) * (Math.PI / 180)), ey = cy + r * Math.sin((angle - 90) * (Math.PI / 180)); const large = norm * 280 > 180 ? 1 : 0; return (
{norm > 0 && }
{value}
{label}
); } // ── Large Param Slider (bigger for Focus View) ── function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max = 100, color = T.amber }) { const trackRef = useRef(null); const dragRef = useRef(false); const lastValRef = useRef(value); const onChangeRef = useRef(onChange); const onChangeEndRef = useRef(onChangeEnd); const pct = ((value - min) / (max - min)) * 100; const [touchVal, setTouchVal] = useState(null); useMemo(() => { onChangeRef.current = onChange; }, [onChange]); useMemo(() => { onChangeEndRef.current = onChangeEnd; }, [onChangeEnd]); const valFromEvent = useCallback((e) => { const t = trackRef.current; if (!t) return value; const r = t.getBoundingClientRect(); const x = (e.touches ? e.touches[0].clientX : e.clientX) - r.left; return Math.round(min + Math.max(0, Math.min(1, x / r.width)) * (max - min)); }, [min, max, value]); useMemo(() => { const move = (e) => { if (!dragRef.current) return; e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; setTouchVal(v); onChangeRef.current?.(v); }; const up = () => { if (dragRef.current) { onChangeEndRef.current?.(lastValRef.current); dragRef.current = false; setTouchVal(null); } }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', up); return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); window.removeEventListener('touchmove', move); window.removeEventListener('touchend', up); }; }, [valFromEvent]); const handleStart = (e) => { e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; dragRef.current = true; setTouchVal(v); onChangeRef.current?.(v); }; const quick = (delta) => (e) => { e.preventDefault(); e.stopPropagation(); const next = Math.max(min, Math.min(max, value + delta)); onChangeRef.current?.(next); onChangeEndRef.current?.(next); }; return (
{label}
{touchVal ?? value}
); } // ── Parameter page (single page of up to 8 params) ── function ParamPage({ defs, params, viewMode, color, onParamChange, onParamChangeEnd, compact }) { return (
{viewMode === 'slider' ? ( defs.map(p => ( onParamChange(p.key, v)} onChangeEnd={v => onParamChangeEnd(p.key, v)} /> )) ) : (
{defs.map(p => ( { onParamChange(p.key, v); onParamChangeEnd(p.key, v); }} compact={compact} /> ))}
)}
); } // ── Focus View (full-screen overlay) ── export default function FocusView({ block, params, viewMode, onClose, onParamChange, onParamChangeEnd, onToggleView, onToggleBypass, onOpenModelBrowser, }) { const defs = getBlockParams(block.type); const color = getBlockColor(block.type); const displayName = getBlockDisplayName(block); // Pagination — max 8 params per page const PER_PAGE = 8; const pages = useMemo(() => { const p = []; for (let i = 0; i < defs.length; i += PER_PAGE) { p.push(defs.slice(i, i + PER_PAGE)); } return p; }, [defs]); const [currentPage, setCurrentPage] = useState(0); const scrollRef = useRef(null); const touchStartX = useRef(null); const [isCompact, setIsCompact] = useState(window.innerWidth < 500); // Track resize for compact layout useMemo(() => { const onResize = () => setIsCompact(window.innerWidth < 500); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); // ── Swipe handling ── const handleTouchStart = useCallback((e) => { touchStartX.current = e.touches[0].clientX; }, []); const handleTouchEnd = useCallback((e) => { if (touchStartX.current == null) return; const dx = e.changedTouches[0].clientX - touchStartX.current; touchStartX.current = null; if (Math.abs(dx) < 50) return; // threshold if (dx < 0 && currentPage < pages.length - 1) { setCurrentPage(p => Math.min(p + 1, pages.length - 1)); } else if (dx > 0 && currentPage > 0) { setCurrentPage(p => Math.max(p - 1, 0)); } }, [currentPage, pages.length]); // ── Keyboard nav ── useMemo(() => { const handler = (e) => { if (e.key === 'ArrowLeft' && currentPage > 0) setCurrentPage(p => p - 1); if (e.key === 'ArrowRight' && currentPage < pages.length - 1) setCurrentPage(p => p + 1); if (e.key === 'Escape') onClose?.(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [currentPage, pages.length, onClose]); return (
{/* ── Header ── */}
{displayName}
{(block.type || 'fx').replace(/_/g, ' ').toUpperCase()}
{/* Slider/Knob toggle */}
{/* ── Bypass toggle (prominent) ── */}
{/* ── Parameter area (scrollable with pages) ── */}
{pages.length === 0 ? (
No parameters for this block type
) : ( <> {/* Page content */} {/* Spacer so page dots don't crowd content */}
)}
{/* ── Bottom bar: Model browser + page indicators ── */}
{/* Page dots */} {pages.length > 1 && (
{pages.map((_, i) => (
)} {/* Page indicator text */} {pages.length > 1 && (
Page {currentPage + 1} / {pages.length} ← swipe to navigate →
)} {/* Model browser button / swap block */} {onOpenModelBrowser && ( )}
); }