import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import BlockChain from "./BlockChain.jsx"; import FootswitchBar from "./FootswitchBar.jsx"; // ── API Layer ────────────────────────────────────────────────────── const API_BASE = ""; async function api(method, path, body) { const opts = { method, headers: { "Content-Type": "application/json" } }; if (body) opts.body = JSON.stringify(body); const res = await fetch(`${API_BASE}${path}`, opts); if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`); return res.json(); } const API = { getState: () => api("GET", "/api/state"), getVu: () => api("GET", "/api/vu"), updateBlock: (id, params) => api("PATCH", "/api/block-params", { id, ...params }), toggleBlock: (id, enabled) => api("PATCH", "/api/blocks", { id, enabled }), bypassToggle: () => api("POST", "/api/bypass/toggle"), masterVolume: (v) => api("POST", "/api/volume", { volume: v / 100 }), listModels: () => api("GET", "/api/models"), loadModel: (path) => api("POST", "/api/models/load", { path }), listPresets: () => api("GET", "/api/presets"), loadPreset: (bank, program) => api("GET", "/api/presets", { bank, program }), savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }), searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`), searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`), installModel: (url, name) => api("POST", "/api/models/tonedownload/install", { download_url: url, name }), installIr: (url, name) => api("POST", "/api/irs/tonedownload/install", { download_url: url, name }), }; // ── 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 parameter definitions ───────────────────────────────── 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', compressor: '#D09040', gate: T.textSec, eq: '#7080D0', cabinet: '#D07090', ir: '#D07090', }; 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; } // ── CSS (Helix Stadium theme) ───────────────────────────────── const css = ` @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600&display=swap'); * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; } body { background: ${T.bg}; color: ${T.textPrimary}; font-family: 'Inter', sans-serif; } :root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; } .mono { font-family: 'JetBrains Mono', monospace; } ::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar-track { background: ${T.panel}; } ::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; } /* ── Knob ── */ .knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } .knob { border-radius: 50%; position: relative; touch-action: none; } .knob-inner { border-radius: 50%; width: 100%; height: 100%; position: relative; background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22); box-shadow: 0 2px 8px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07); } .knob-dot { position: absolute; width: 3px; border-radius: 2px; background: var(--amber); left: 50%; transform-origin: bottom center; box-shadow: 0 0 6px var(--amber); } .knob-label { font-size: 10px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: center; } .knob-value { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: ${T.amber}; } /* ── Toggle ── */ .toggle { display: flex; background: ${T.surface}; border-radius: 6px; border: 1px solid ${T.border}; overflow: hidden; } .toggle-btn { flex: 1; padding: 7px 12px; font-size: 12px; font-weight: 500; letter-spacing: .04em; background: none; border: none; color: ${T.textSec}; cursor: pointer; transition: all .15s; } .toggle-btn.active { background: ${T.amber}; color: #000; } /* ── Buttons ── */ .btn { padding: 10px 18px; border-radius: 7px; font-size: 13px; font-weight: 600; letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; } .btn-primary { background: ${T.amber}; color: #000; } .btn-primary:active { filter: brightness(.85); transform: scale(.97); } .btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; } .btn-ghost:active { background: ${T.border}; } .btn-danger { background: rgba(200,64,64,.2); color: ${T.red}; border: 1px solid rgba(200,64,64,.3); } .btn-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer; font-size: 16px; transition: all .12s; } .btn-icon:active { transform: scale(.93); } .btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; } /* ── Badge ── */ .badge { display: inline-flex; align-items: center; justify-content: center; padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } .badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; } .badge-green { background: rgba(58,184,122,.2); color: ${T.green}; } .badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; } .badge-red { background: rgba(200,64,64,.2); color: ${T.red}; } /* ── Screen header (for overlay screens) ── */ .screen-header { padding: 14px 16px 10px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid ${T.border}; } .screen-title { font-size: 13px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; } .screen-body { flex: 1; overflow-y: auto; padding: 14px 12px; display: flex; flex-direction: column; gap: 14px; } /* ── Param slider ── */ .param-panel { background: ${T.surface}; border-radius: 12px 12px 0 0; padding: 10px 14px 8px; flex-shrink: 0; overflow-y: auto; border-top: 1px solid ${T.border}; } .param-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } .param-panel-title { font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: ${T.textSec}; } .param-row { display: flex; flex-direction: column; gap: 2px; padding: 2px 0; } .param-header { display: flex; justify-content: space-between; align-items: center; } .param-label { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .06em; color: ${T.textSec}; } .param-val { font-family: 'JetBrains Mono',monospace; font-size: 12px; font-weight: 600; } .param-track-wrap { position: relative; height: 38px; display: flex; align-items: center; cursor: pointer; touch-action: none; } .param-track { width: 100%; height: 8px; background: ${T.border}; border-radius: 4px; position: relative; overflow: hidden; } .param-fill { height: 100%; border-radius: 4px; position: absolute; top: 0; left: 0; transition: width .04s; } .param-thumb { width: 26px; height: 26px; border-radius: 50%; position: absolute; top: 50%; transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none; border: 3px solid ${T.bg}; } .param-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid ${T.border}; background: ${T.panel}; color: ${T.textPrimary}; font-size: 15px; font-weight: 700; display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1; } .param-btn:active { background: ${T.border}; transform: scale(.92); } .param-knobs { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 8px 0; } /* ── Preset chip ── */ .preset-chip { padding: 5px 8px; border-radius: 6px; background: ${T.surface}; border: 1px solid ${T.border}; display: flex; flex-direction: column; align-items: center; cursor: pointer; transition: all .12s; flex: 1; min-width: 0; } .preset-chip.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); } .preset-chip .pc-name { font-size: 10px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; } .preset-chip .pc-num { font-family: 'JetBrains Mono', monospace; font-size: 8px; color: ${T.textDim}; margin-top: 1px; } /* ── Preset row (captures screen) ── */ .preset-row { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 8px; cursor: pointer; transition: background .12s; border: 1px solid transparent; } .preset-row:active, .preset-row.active { background: rgba(232,160,48,.08); border-color: rgba(232,160,48,.25); } .section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: ${T.textDim}; font-weight: 600; margin-bottom: 10px; } /* ── Card ── */ .card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 10px; padding: 16px; } .card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 8px; padding: 12px; } `; // ── Knob ────────────────────────────────────────────────────── function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0, max = 100 }) { 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 + 12; const cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 3; const lx = cx + r * Math.cos((-140 - 90) * (Math.PI / 180)); const ly = cy + r * Math.sin((-140 - 90) * (Math.PI / 180)); const ex = cx + r * Math.cos((angle - 90) * (Math.PI / 180)); const ey = cy + r * Math.sin((angle - 90) * (Math.PI / 180)); const large = norm * 280 > 180 ? 1 : 0; return (
{norm > 0 && }
{value}
{label}
); } // ── Param Slider (big touch-friendly) ───────────────────────── function ParamSlider({ 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; useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { 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]); useEffect(() => { const move = (e) => { if (!dragRef.current) return; e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; onChangeRef.current?.(v); }; const up = () => { if (dragRef.current) { onChangeEndRef.current?.(lastValRef.current); dragRef.current = false; } }; 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; 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}
{value}
); } // ── Parameter Panel ────────────────────────────────────────── function ParameterPanel({ block, params, onParamChange, onParamChangeEnd, viewMode, onToggleView }) { const defs = getBlockParams(block.type); const color = getBlockColor(block.type); const blockName = block.name || (block.type || '').toUpperCase(); return (
{blockName}
{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); }} /> ))}
)}
); } // ── VU Meter ────────────────────────────────────────────────── function VUMeter({ level = 0, height = 28, vertical = false }) { const segs = 12; const active = Math.round((level / 100) * segs); const segColor = (i) => { if (i >= 10) return T.red; if (i >= 8) return "#E8C030"; return T.green; }; if (vertical) return (
{Array.from({ length: segs }).map((_, i) => (
))}
); return (
{Array.from({ length: segs }).map((_, i) => (
))}
); } // ── Scribble Info Bar ────────────────────────────────────── function ScribbleBar({ state }) { const vol = state.master_volume != null ? Math.round(state.master_volume * 100) : 0; return (
{[ { label: "Preset", value: state.current_preset?.name || "—" }, { label: "Routing", value: (state.routing_mode || "MONO").toUpperCase() }, { label: "Volume", value: `${vol}%` }, { label: "CPU", value: state.cpu_percent != null ? `${state.cpu_percent}%` : "—" }, ].map(s => (
{s.label}
{s.value}
))}
); } // ── Captures Screen (Model/IR downloads) ────────────────────── function CapturesScreen({ onClose }) { const [tab, setTab] = useState("models"); const [query, setQuery] = useState(""); const [results, setResults] = useState(null); const [models, setModels] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { API.listModels().then(d => setModels(d.models || [])).catch(()=>{}); }, []); const doSearch = async () => { if (!query.trim()) return; setLoading(true); try { const data = tab === "models" ? await API.searchModels(query) : await API.searchIrs(query); setResults(data.results || []); } catch { setResults([]); } setLoading(false); }; const install = async (item) => { try { if (tab === "models") { await API.installModel(item.download_url, item.name); } else { await API.installIr(item.download_url, item.name); } API.listModels().then(d => setModels(d.models || [])).catch(()=>{}); } catch(e) { alert("Install failed: " + e.message); } }; return (
Downloads
🔍 setQuery(e.target.value)} onKeyDown={e => e.key === "Enter" && doSearch()} style={{ flex: 1, background: "none", border: "none", color: T.textPrimary, fontSize: 12, outline: "none" }} />
{results === null && models.length > 0 && ( <>
Installed Models
{models.map((m, i) => (
🎛
{m.name}
{m.architecture} · {m.size_mb}MB {m.loaded ? "· CURRENT" : ""}
))} )} {results !== null && results.length === 0 && (
No results found
)} {results !== null && results.map((r, i) => (
{r.name}
{r.author} · {r.size_display}
))}
); } // ── Presets Screen ───────────────────────────────────────── function PresetsScreen({ state, onClose, onLoadPreset }) { const [activeIdx, setActiveIdx] = useState(0); const samplePresets = [ { num: 1, name: "Plexi Crunch", tags: ["Crunch", "Vintage"], color: T.amber }, { num: 2, name: "Modern High Gain", tags: ["High Gain", "Lead"], color: T.red }, { num: 3, name: "Clean Chimey", tags: ["Clean", "Sparkle"], color: T.blue }, { num: 4, name: "Southern Blues", tags: ["Crunch", "Blues"], color: T.green }, { num: 5, name: "Metal Djent", tags: ["High Gain", "Djent"], color: T.red }, { num: 6, name: "Studio Direct", tags: ["Clean", "Studio"], color: T.blue }, { num: 7, name: "Acoustic Sim", tags: ["Acoustic", "Clean"], color: "#A060E0" }, { num: 8, name: "Nashville Twang", tags: ["Country", "Clean"], color: T.green }, ]; return (
Presets
{["All", "Live", "Studio", "Practice"].map(t => ( ))}
{samplePresets.map((p, i) => (
{ setActiveIdx(i); onLoadPreset?.(p.num); }}>
{p.num}
{p.name}
{p.tags.map(t => ( {t} ))}
{activeIdx === i && }
))}
); } // ── App Shell — Helix Stadium Layout ────────────────────────── const MOCK_BLOCKS = [ { id: "gate", type: "gate", name: "Noise Gate", bypassed: false, params: { threshold: 40, release: 25, depth: 70, mix: 100 }}, { id: "drive", type: "overdrive", name: "OD-808", bypassed: false, params: { drive: 65, tone: 55, level: 70 }}, { id: "dist", type: "distortion", name: "RAT", bypassed: true, params: { drive: 75, filter: 50, level: 65 }}, { id: "mod", type: "phaser", name: "Phase 90", bypassed: false, params: { rate: 35, depth: 60, feedback: 40, mix: 50 }}, { id: "delay", type: "delay", name: "Digital Delay", bypassed: false, params: { time: 45, feedback: 25, mix: 40 }}, { id: "reverb", type: "reverb", name: "Hall Reverb", bypassed: false, params: { decay: 60, mix: 35, tone: 50 }}, ]; const MOCK_PRESETS = [ { num: 1, name: "Plexi Crunch" }, { num: 2, name: "Modern Hi-Gain" }, { num: 3, name: "Clean Chime" }, { num: 4, name: "Blues Drive" }, ]; export default function App() { // ── State ── const [view, setView] = useState("main"); // "main" | "captures" | "presets" const [state, setState] = useState({ connected: false, master_volume: 0.72, routing_mode: "mono", cpu_percent: 0 }); const [blocks, setBlocks] = useState(MOCK_BLOCKS); const [selectedBlockId, setSelectedBlockId] = useState("gate"); const [blockParams, setBlockParams] = useState({}); const [paramView, setParamView] = useState("slider"); const [vuLevel, setVuLevel] = useState(0); const [currentPreset, setCurrentPreset] = useState(1); const [currentBank, setCurrentBank] = useState(1); // Poll pedal state useEffect(() => { const poll = async () => { try { const s = await API.getState(); setState(s); if (s.cpu_percent != null) setState(prev => ({ ...prev, cpu_percent: s.cpu_percent })); if (s.input_level != null) setVuLevel(s.input_level); } catch {} }; poll(); const id = setInterval(poll, 3000); return () => clearInterval(id); }, []); // Mock VU animation when no real data useEffect(() => { const id = setInterval(() => { setVuLevel(prev => Math.max(0, Math.min(100, prev + (Math.random() - 0.45) * 8))); }, 150); return () => clearInterval(id); }, []); // Selected block const selectedBlock = useMemo(() => blocks.find(b => b.id === selectedBlockId), [blocks, selectedBlockId]); // Initialize params when block is selected useEffect(() => { if (selectedBlock && blockParams[selectedBlockId] == null) { const defs = getBlockParams(selectedBlock.type); const init = {}; defs.forEach(p => { init[p.key] = (selectedBlock.params?.[p.key] != null) ? selectedBlock.params[p.key] : p.def; }); setBlockParams(prev => ({ ...prev, [selectedBlockId]: init })); } }, [selectedBlockId, selectedBlock, blockParams]); const handleSelectBlock = useCallback((id) => { setSelectedBlockId(prev => prev === id ? null : id); }, []); const handleToggleBlock = useCallback((id) => { setBlocks(prev => prev.map(b => b.id === id ? { ...b, bypassed: !b.bypassed } : b)); API.toggleBlock(id, false).catch(() => {}); }, []); const handleParamChange = useCallback((id, key, value) => { setBlockParams(prev => ({ ...prev, [id]: { ...(prev[id] || {}), [key]: value } })); }, []); const handleParamChangeEnd = useCallback((id, key, value) => { API.updateBlock(id, { [key]: value }).catch(() => {}); }, []); const handleReorder = useCallback((fromIdx, toIdx) => { setBlocks(prev => { const arr = [...prev]; const [moved] = arr.splice(fromIdx, 1); arr.splice(toIdx, 0, moved); return arr; }); }, []); const handleAddBlock = useCallback(() => { const id = "block_" + Date.now(); setBlocks(prev => [...prev, { id, type: "overdrive", name: "New Drive", bypassed: false, params: { drive: 30, tone: 50, level: 70 } }]); }, []); const handleRemoveBlock = useCallback((id) => { setBlocks(prev => prev.filter(b => b.id !== id)); if (selectedBlockId === id) setSelectedBlockId(null); }, [selectedBlockId]); const handleBankUp = useCallback(() => { setCurrentBank(prev => Math.min(prev + 1, 8)); }, []); const handleBankDown = useCallback(() => { setCurrentBank(prev => Math.max(prev - 1, 1)); }, []); // Convert bank number to letter (1=A, 2=B, etc.) const bankLetter = String.fromCharCode(64 + currentBank); // ── Render ── return ( <>
{view === "main" && ( <> {/* ── Status Bar ── */}
{state.connected ? "CONNECTED" : "OFFLINE"} {state.current_preset?.name || "Plexi Crunch"}
{/* ── Scribble Info Bar ── */} {/* ── Preset Strip ── */}
{MOCK_PRESETS.map(p => (
setCurrentPreset(p.num)}>
{p.name}
0{p.num}
))}
{/* ── Block Chain + Params ── */}
{/* ── Parameter Panel ── */} {selectedBlock && blockParams[selectedBlockId] && ( setParamView(p => p === 'slider' ? 'knob' : 'slider')} onParamChange={(key, value) => handleParamChange(selectedBlockId, key, value)} onParamChangeEnd={(key, value) => handleParamChangeEnd(selectedBlockId, key, value)} /> )} {!selectedBlock && (
Tap a block to edit its parameters
)}
{/* ── Footswitch Bar ── */} {}} onBankDown={() => {}} onGlobalBypass={() => setState(prev => ({ ...prev, bypass: !prev.bypass }))} globalBypass={state.bypass} /> )} {/* ── Overlay screens ── */} {view === "captures" && ( setView("main")} /> )} {view === "presets" && ( setView("main")} onLoadPreset={(num) => setCurrentPreset(num)} /> )}
); }