PEDAL-UI: Landscape layout + functional preset loading + bank navigation + API integration

- Fixed layout for landscape mode: maxWidth 980px, preset sidebar on left
- Preset chips now call API.loadPreset() on click
- Bank up/down cycles through banks, loads first preset
- Global bypass wired to API.bypassToggle()
- Block toggle wired to API.toggleBlock()
- Slider changes commit to API.updateBlock() on drag end
- Presets loaded from API.listPresets() on mount
- Status bar shows real API state
This commit is contained in:
2026-06-12 18:51:15 -04:00
parent 027494db2c
commit 64f1086343
+275 -489
View File
@@ -23,7 +23,7 @@ const API = {
listModels: () => api("GET", "/api/models"), listModels: () => api("GET", "/api/models"),
loadModel: (path) => api("POST", "/api/models/load", { path }), loadModel: (path) => api("POST", "/api/models/load", { path }),
listPresets: () => api("GET", "/api/presets"), listPresets: () => api("GET", "/api/presets"),
loadPreset: (bank, program) => api("GET", "/api/presets", { bank, program }), loadPreset: (bank, program) => api("GET", "/api/presets/{bank}/{program}".replace("{bank}",bank).replace("{program}",program)),
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }), savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }),
searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`), searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`),
searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`), searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`),
@@ -81,47 +81,39 @@ function getBlockColor(type) {
return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber; return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber;
} }
// ── CSS (Helix Stadium theme) ───────────────────────────────── // ── CSS ─────────────────────────────────────────────────────
const css = ` 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'); @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; } * { 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; } html, body { height: 100%; overflow: hidden; background: ${T.bg}; }
body { color: ${T.textPrimary}; font-family: 'Inter', sans-serif; }
:root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; } :root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; }
.mono { font-family: 'JetBrains Mono', monospace; } .mono { font-family: 'JetBrains Mono', monospace; }
::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: ${T.panel}; } ::-webkit-scrollbar-track { background: ${T.panel}; }
::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; } ::-webkit-scrollbar-thumb { background: ${T.border}; border-radius: 2px; }
/* ── Knob ── */ /* ── Knob ── */
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } .knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: pointer; }
.knob { border-radius: 50%; position: relative; touch-action: none; } .knob { border-radius: 50%; position: relative; touch-action: none; }
.knob-inner { border-radius: 50%; width: 100%; height: 100%; position: relative; .knob-inner { border-radius: 50%; width: 100%; height: 100%; position: relative;
background: radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22); 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); } 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); .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); } 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-label { font-size: 9px; 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}; } .knob-value { font-family: 'JetBrains Mono', monospace; font-size: 10px; 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 ── */ /* ── Buttons ── */
.btn { padding: 10px 18px; border-radius: 7px; font-size: 13px; font-weight: 600; .btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; } letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; }
.btn-primary { background: ${T.amber}; color: #000; } .btn-primary { background: ${T.amber}; color: #000; }
.btn-primary:active { filter: brightness(.85); transform: scale(.97); } .btn-primary:active { filter: brightness(.85); transform: scale(.97); }
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; } .btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
.btn-ghost:active { background: ${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: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center;
.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; justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
font-size: 16px; transition: all .12s; } font-size: 15px; transition: all .12s; flex-shrink: 0; }
.btn-icon:active { transform: scale(.93); } .btn-icon:active { transform: scale(.93); }
.btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; } .btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; }
@@ -132,58 +124,54 @@ const css = `
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; } .badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; } .badge-green { background: rgba(58,184,122,.2); color: ${T.green}; }
.badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; } .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 slider ── */
.param-panel { background: ${T.surface}; border-radius: 12px 12px 0 0; padding: 10px 14px 8px; .param-panel { background: ${T.surface}; padding: 8px 12px 6px; border-top: 1px solid ${T.border}; flex-shrink: 0; }
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: 2px; }
.param-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } .param-panel-title { font-size: 10px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: ${T.textSec}; }
.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: 1px; padding: 1px 0; }
.param-row { display: flex; flex-direction: column; gap: 2px; padding: 2px 0; }
.param-header { display: flex; justify-content: space-between; align-items: center; } .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-label { font-size: 9px; 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-val { font-family: 'JetBrains Mono',monospace; font-size: 11px; font-weight: 600; }
.param-track-wrap { position: relative; height: 44px; display: flex; align-items: center; cursor: pointer; touch-action: none; } .param-track-wrap { position: relative; height: 30px; 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-track { width: 100%; height: 6px; background: ${T.border}; border-radius: 3px; position: relative; overflow: hidden; }
.param-fill { height: 100%; border-radius: 4px; position: absolute; top: 0; left: 0; transition: width .04s; } .param-fill { height: 100%; border-radius: 3px; position: absolute; top: 0; left: 0; transition: width .04s; }
.param-thumb { width: 26px; height: 26px; border-radius: 50%; position: absolute; top: 50%; .param-thumb { width: 22px; height: 22px; border-radius: 50%; position: absolute; top: 50%;
transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none; transform: translate(-50%,-50%); box-shadow: 0 1px 6px rgba(0,0,0,.6); pointer-events: none;
border: 3px solid ${T.bg}; } border: 3px solid ${T.bg}; }
.param-btn { width: 32px; height: 32px; border-radius: 50%; border: 1px solid ${T.border}; .param-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid ${T.border};
background: ${T.panel}; color: ${T.textPrimary}; font-size: 15px; font-weight: 700; background: ${T.panel}; color: ${T.textPrimary}; font-size: 14px; font-weight: 700;
display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1; } display: flex; align-items: center; justify-content: center; cursor: pointer; line-height: 1; }
.param-btn:active { background: ${T.border}; transform: scale(.92); } .param-btn:active { background: ${T.border}; transform: scale(.92); }
.param-knobs { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 8px 0; } .param-knobs { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; padding: 4px 0; }
/* ── Preset chip ── */ /* ── Screen header (overlays) ── */
.preset-chip { padding: 5px 8px; border-radius: 6px; background: ${T.surface}; border: 1px solid ${T.border}; .screen-header { padding: 10px 14px 8px; display: flex; align-items: center;
display: flex; flex-direction: column; align-items: center; cursor: pointer; transition: all .12s; flex: 1; justify-content: space-between; border-bottom: 1px solid ${T.border}; }
min-width: 0; } .screen-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; }
.preset-chip.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); } .screen-body { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; }
.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 sidebar chip ── */
.preset-row { display: flex; align-items: center; gap: 12px; padding: 11px 14px; .preset-chip-s { width: 100%; padding: 7px 6px; border-radius: 5px; background: ${T.surface}; border: 1px solid ${T.border};
border-radius: 8px; cursor: pointer; transition: background .12s; border: 1px solid transparent; } display: flex; flex-direction: column; align-items: center; cursor: pointer; transition: all .12s; }
.preset-chip-s.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
.preset-chip-s:active { transform: scale(.96); }
.preset-chip-s .pcs-num { font-family: 'JetBrains Mono',monospace; font-size: 14px; font-weight: 700; color: ${T.amber}; line-height: 1.1; }
.preset-chip-s .pcs-name { font-size: 9px; color: ${T.textSec}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; text-align: center; line-height: 1.2; }
.preset-chip-s .pcs-indicator { width: 4px; height: 4px; border-radius: 50%; margin-top: 3px; }
/* ── Preset row (captures) ── */
.preset-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px;
border-radius: 6px; 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); } .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; .section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: ${T.textDim}; font-weight: 600; margin-bottom: 10px; } color: ${T.textDim}; font-weight: 600; margin-bottom: 8px; }
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 8px; padding: 14px; }
/* ── Card ── */ .card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 6px; padding: 10px; }
.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 ────────────────────────────────────────────────────── // ── Knob ──────────────────────────────────────────────────────
function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0, max = 100 }) { function Knob({ label, value = 50, onChange, size = 48, color = T.amber, min = 0, max = 100 }) {
const startRef = useRef(null); const startRef = useRef(null);
const norm = (value - min) / (max - min); const norm = (value - min) / (max - min);
const angle = -140 + norm * 280; const angle = -140 + norm * 280;
@@ -203,33 +191,23 @@ function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0
const up = () => { window.removeEventListener("mousemove",move);window.removeEventListener("mouseup",up); const up = () => { window.removeEventListener("mousemove",move);window.removeEventListener("mouseup",up);
window.removeEventListener("touchmove",move);window.removeEventListener("touchend",up); }; window.removeEventListener("touchmove",move);window.removeEventListener("touchend",up); };
window.addEventListener("mousemove",move);window.addEventListener("mouseup",up); window.addEventListener("mousemove",move);window.addEventListener("mouseup",up);
window.addEventListener("touchmove", move, { passive: false }); window.addEventListener("touchmove",move,{passive:false});window.addEventListener("touchend",up);
window.addEventListener("touchend", up);
}; };
const svgSize = size + 12; const svgSize=size+12,cx=svgSize/2,cy=svgSize/2,r=size/2+3;
const cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 3; const lx=cx+r*Math.cos((-140-90)*(Math.PI/180)),ly=cy+r*Math.sin((-140-90)*(Math.PI/180));
const lx = cx + r * Math.cos((-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 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; const large=norm*280>180?1:0;
return ( return (
<div className="knob-wrap" style={{width:size+12}}> <div className="knob-wrap" style={{width:size+12}}>
<div className="knob" style={{width:size,height:size}} onMouseDown={handleStart} onTouchStart={handleStart}> <div className="knob" style={{width:size,height:size}} onMouseDown={handleStart} onTouchStart={handleStart}>
<svg style={{position:"absolute",inset:-6,width:svgSize,height:svgSize,overflow:"visible"}}> <svg style={{position:"absolute",inset:-6,width:svgSize,height:svgSize,overflow:"visible"}}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="2.5" strokeLinecap="round" opacity=".5" /> <circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="2" strokeLinecap="round" opacity=".5"/>
{norm > 0 && <path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} {norm>0&&<path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" style={{filter:`drop-shadow(0 0 3px ${color})`}}/>}
fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 3px ${color})` }} />}
</svg> </svg>
<div className="knob-inner" style={{"--amber":color}}> <div className="knob-inner" style={{"--amber":color}}>
<div className="knob-dot" style={{ <div className="knob-dot" style={{height:dotH,top:dotTop,transform:`translateX(-50%) rotate(${angle}deg)`,transformOrigin:`50% ${dotH}px`}}/>
height: dotH, top: dotTop, bottom: "auto",
transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: `50% ${dotH}px`,
}} />
</div> </div>
</div> </div>
<div className="knob-value mono" style={{color}}>{value}</div> <div className="knob-value mono" style={{color}}>{value}</div>
@@ -238,80 +216,45 @@ function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0
); );
} }
// ── Param Slider (big touch-friendly) ───────────────────────── // ── Param Slider ──────────────────────────────────────────────
function ParamSlider({ label,value=50,onChange,onChangeEnd,min=0,max=100,color=T.amber }) { function ParamSlider({ label,value=50,onChange,onChangeEnd,min=0,max=100,color=T.amber }) {
const trackRef = useRef(null); const trackRef=useRef(null);const dragRef=useRef(false);const lastValRef=useRef(value);
const dragRef = useRef(false); const onChangeRef=useRef(onChange);const onChangeEndRef=useRef(onChangeEnd);
const lastValRef = useRef(value);
const onChangeRef = useRef(onChange);
const onChangeEndRef = useRef(onChangeEnd);
const pct=((value-min)/(max-min))*100; const pct=((value-min)/(max-min))*100;
useEffect(()=>{onChangeRef.current=onChange;},[onChange]); useEffect(()=>{onChangeRef.current=onChange;},[onChange]);
useEffect(()=>{onChangeEndRef.current=onChangeEnd;},[onChangeEnd]); useEffect(()=>{onChangeEndRef.current=onChangeEnd;},[onChangeEnd]);
const valFromEvent=useCallback((e)=>{ const valFromEvent=useCallback((e)=>{
const t = trackRef.current; const t=trackRef.current;if(!t)return value;
if (!t) return value;
const r=t.getBoundingClientRect(); const r=t.getBoundingClientRect();
const x=(e.touches?e.touches[0].clientX:e.clientX)-r.left; 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)); return Math.round(min+Math.max(0,Math.min(1,x/r.width))*(max-min));
},[min,max,value]); },[min,max,value]);
useEffect(()=>{ useEffect(()=>{
const move = (e) => { const move=(e)=>{if(!dragRef.current)return;e.preventDefault();const v=valFromEvent(e);lastValRef.current=v;onChangeRef.current?.(v);};
if (!dragRef.current) return; const up=()=>{if(dragRef.current){onChangeEndRef.current?.(lastValRef.current);dragRef.current=false}};
e.preventDefault(); window.addEventListener('mousemove',move);window.addEventListener('mouseup',up);
const v = valFromEvent(e); window.addEventListener('touchmove',move,{passive:false});window.addEventListener('touchend',up);
lastValRef.current = v; return()=>{window.removeEventListener('mousemove',move);window.removeEventListener('mouseup',up);
onChangeRef.current?.(v); window.removeEventListener('touchmove',move);window.removeEventListener('touchend',up);};
};
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]); },[valFromEvent]);
const handleStart = (e) => { const handleStart=(e)=>{e.preventDefault();const v=valFromEvent(e);lastValRef.current=v;dragRef.current=true;onChangeRef.current?.(v);};
e.preventDefault(); const quick=(delta)=>(e)=>{e.preventDefault();e.stopPropagation();const next=Math.max(min,Math.min(max,value+delta));onChangeRef.current?.(next);onChangeEndRef.current?.(next);};
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 ( return (
<div className="param-row"> <div className="param-row">
<div className="param-header"> <div className="param-header">
<span className="param-label" style={{color}}>{label}</span> <span className="param-label" style={{color}}>{label}</span>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}> <div style={{display:'flex',gap:4,alignItems:'center'}}>
<button className="param-btn" onMouseDown={quick(-5)} onTouchStart={quick(-5)}></button> <button className="param-btn" onMouseDown={quick(-5)} onTouchStart={quick(-5)}></button>
<span className="param-val" style={{color}}>{value}</span> <span className="param-val" style={{color}}>{value}</span>
<button className="param-btn" onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button> <button className="param-btn" onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button>
</div> </div>
</div> </div>
<div className="param-track-wrap" ref={trackRef} <div className="param-track-wrap" ref={trackRef} onMouseDown={handleStart} onTouchStart={handleStart}>
onMouseDown={handleStart} onTouchStart={handleStart}>
<div className="param-track"> <div className="param-track">
<div className="param-fill" style={{width:`${pct}%`,background:color}}/> <div className="param-fill" style={{width:`${pct}%`,background:color}}/>
<div className="param-thumb" style={{left:`${pct}%`,background:color}}/> <div className="param-thumb" style={{left:`${pct}%`,background:color}}/>
@@ -323,261 +266,67 @@ function ParamSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max =
// ── Parameter Panel ────────────────────────────────────────── // ── Parameter Panel ──────────────────────────────────────────
function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView }) { function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView }) {
const defs = getBlockParams(block.type); const defs=getBlockParams(block.type);const color=getBlockColor(block.type);
const color = getBlockColor(block.type);
const blockName = block.name || (block.type || '').toUpperCase();
return ( return (
<div className="param-panel" style={{ maxHeight: '50%', flexShrink: 0 }}> <div className="param-panel">
<div className="param-panel-header"> <div className="param-panel-header">
<span className="param-panel-title" style={{ color: T.textSec }}> <span className="param-panel-title"><span style={{color}}></span> {block.name||block.type||''}</span>
<span style={{ color }}></span> {blockName} <button className="btn-icon" style={{width:26,height:26,fontSize:11,flexShrink:0}}
</span> onClick={onToggleView}>{viewMode==='slider'?'◉':'▦'}</button>
<button className="btn-icon" style={{ width: 28, height: 28, fontSize: 12, flexShrink: 0 }}
onClick={onToggleView} title={viewMode === 'slider' ? 'Switch to knobs' : 'Switch to sliders'}>
{viewMode === 'slider' ? '◉' : '▦'}
</button>
</div>
{viewMode === 'slider' ? (
defs.map(p => (
<ParamSlider key={p.key}
label={p.label}
value={params[p.key] != null ? params[p.key] : p.def}
min={p.min} max={p.max}
color={color}
onChange={v => onParamChange?.(p.key, v)}
onChangeEnd={v => onParamChangeEnd?.(p.key, v)}
/>
))
) : (
<div className="param-knobs">
{defs.map(p => (
<Knob key={p.key}
label={p.label}
value={params[p.key] != null ? params[p.key] : p.def}
min={p.min} max={p.max}
size={60}
color={color}
onChange={v => {
onParamChange?.(p.key, v);
onParamChangeEnd?.(p.key, v);
}}
/>
))}
</div> </div>
{viewMode==='slider'?defs.map(p=>(
<ParamSlider key={p.key} label={p.label}
value={params[p.key]!=null?params[p.key]:p.def} min={p.min} max={p.max} color={color}
onChange={v=>onParamChange?.(p.key,v)} onChangeEnd={v=>onParamChangeEnd?.(p.key,v)}/>
)):(
<div className="param-knobs">{defs.map(p=>(
<Knob key={p.key} label={p.label}
value={params[p.key]!=null?params[p.key]:p.def} min={p.min} max={p.max} size={48} color={color}
onChange={v=>{onParamChange?.(p.key,v);onParamChangeEnd?.(p.key,v);}}/>
))}</div>
)} )}
</div> </div>
); );
} }
// ── VU Meter ────────────────────────────────────────────────── // ── VU Meter ──────────────────────────────────────────────────
function VUMeter({ level = 0, height = 28, vertical = false }) { function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs);
const segs = 12; const c=i=>i>=10?T.red:i>=8?"#E8C030":T.green;
const active = Math.round((level / 100) * segs); if(vertical)return(<div style={{display:"flex",flexDirection:"column-reverse",gap:1,height,alignItems:"center"}}>
const segColor = (i) => { {Array.from({length:segs}).map((_,i)=>(<div key={i} style={{width:4,height:(height-segs)/segs,borderRadius:1,background:i<active?c(i):T.border,boxShadow:i<active?`0 0 4px ${c(i)}`:"none",transition:"background .05s"}}/>))}</div>);
if (i >= 10) return T.red; return(<div style={{display:"flex",gap:2,height:6,alignItems:"center",width:height}}>
if (i >= 8) return "#E8C030"; {Array.from({length:segs}).map((_,i)=>(<div key={i} style={{flex:1,height:"100%",borderRadius:1,background:i<active?c(i):T.border,transition:"background .05s"}}/>))}</div>);
return T.green;
};
if (vertical) return (
<div style={{ display: "flex", flexDirection: "column-reverse", gap: 1, height, alignItems: "center" }}>
{Array.from({ length: segs }).map((_, i) => (
<div key={i} style={{ width: 4, height: (height - segs) / segs,
borderRadius: 1, background: i < active ? segColor(i) : T.border,
boxShadow: i < active ? `0 0 4px ${segColor(i)}` : "none",
transition: "background .05s, box-shadow .05s" }} />
))}
</div>
);
return (
<div style={{ display: "flex", gap: 2, height: 6, alignItems: "center", width: height }}>
{Array.from({ length: segs }).map((_, i) => (
<div key={i} style={{ flex: 1, height: "100%", borderRadius: 1,
background: i < active ? segColor(i) : T.border,
transition: "background .05s" }} />
))}
</div>
);
} }
// ── Scribble Info Bar ────────────────────────────────────── // Captures Screen
function ScribbleBar({ state }) {
const vol = state.master_volume != null ? Math.round(state.master_volume * 100) : 0;
return (
<div style={{
display: "flex", gap: 4, padding: "4px 10px",
background: T.surface, borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
}}>
{[
{ 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 => (
<div key={s.label} style={{ flex: 1, textAlign: "center", padding: "3px 6px",
borderRadius: 4, background: T.panel, border: `1px solid ${T.border}` }}>
<div style={{ fontSize: 8, color: T.textDim, letterSpacing: ".06em", textTransform: "uppercase" }}>{s.label}</div>
<div className="mono" style={{ fontSize: 11, fontWeight: 600, color: T.amber, marginTop: 1 }}>{s.value}</div>
</div>
))}
</div>
);
}
// ── Captures Screen (Model/IR downloads) ──────────────────────
function CapturesScreen({onClose}){ function CapturesScreen({onClose}){
const [tab, setTab] = useState("models"); const[tab,setTab]=useState("models");const[query,setQuery]=useState("");
const [query, setQuery] = useState(""); const[results,setResults]=useState(null);const[models,setModels]=useState([]);const[loading,setLoading]=useState(false);
const [results, setResults] = useState(null); useEffect(()=>{API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});},[]);
const [models, setModels] = useState([]); 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 [loading, setLoading] = useState(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(<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
useEffect(() => { <div className="screen-header"><div className="screen-title">Downloads</div><button className="btn-icon" style={{width:28,height:28,fontSize:13}}onClick={onClose}></button></div>
API.listModels().then(d => setModels(d.models || [])).catch(()=>{}); <div style={{padding:"6px 12px",borderBottom:`1px solid ${T.border}`}}><div className="toggle">
}, []);
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: T.bg }}>
<div className="screen-header">
<div className="screen-title">Downloads</div>
<button className="btn-icon" style={{ width: 30, height: 30, fontSize: 14 }}
onClick={onClose}></button>
</div>
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
<div className="toggle">
<button className={`toggle-btn ${tab==="models"?"active":""}`}onClick={()=>setTab("models")}>NAM Models</button> <button className={`toggle-btn ${tab==="models"?"active":""}`}onClick={()=>setTab("models")}>NAM Models</button>
<button className={`toggle-btn ${tab === "irs" ? "active" : ""}`} onClick={() => setTab("irs")}>IR Cabs</button> <button className={`toggle-btn ${tab==="irs"?"active":""}`}onClick={()=>setTab("irs")}>IR Cabs</button></div></div>
</div>
</div>
<div style={{padding:"6px 12px",display:"flex",gap:6,borderBottom:`1px solid ${T.border}`}}> <div style={{padding:"6px 12px",display:"flex",gap:6,borderBottom:`1px solid ${T.border}`}}>
<div style={{ flex: 1, display: "flex", background: T.surface, borderRadius: 7, border: `1px solid ${T.border}`, <div style={{flex:1,display:"flex",background:T.surface,borderRadius:6,border:`1px solid ${T.border}`,padding:"6px 10px",gap:6,alignItems:"center"}}>
padding: "7px 10px", gap: 8, alignItems: "center" }}> <span style={{color:T.textDim,fontSize:11}}>🔍</span>
<span style={{ color: T.textDim, fontSize: 12 }}>🔍</span> <input placeholder={`Search ${tab==="models"?"NAM models":"IR cabs"}...`}value={query}onChange={e=>setQuery(e.target.value)}onKeyDown={e=>e.key==="Enter"&&doSearch()}style={{flex:1,background:"none",border:"none",color:T.textPrimary,fontSize:12,outline:"none"}}/></div>
<input placeholder={`Search ${tab === "models" ? "NAM models" : "IR cabs"}...`} <button className="btn btn-primary"style={{padding:"6px 11px",fontSize:11}}onClick={doSearch}>{loading?"...":"Search"}</button></div>
value={query} onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === "Enter" && doSearch()}
style={{ flex: 1, background: "none", border: "none", color: T.textPrimary, fontSize: 12, outline: "none" }} />
</div>
<button className="btn btn-primary" style={{ padding: "7px 12px", fontSize: 11 }} onClick={doSearch}>
{loading ? "..." : "Search"}
</button>
</div>
<div className="screen-body"> <div className="screen-body">
{results === null && models.length > 0 && ( {results===null&&models.length>0&&(<><div className="section-label">Installed Models</div>
<> {models.map((m,i)=>(<div key={i}className="preset-row"style={{cursor:"default"}}>
<div className="section-label">Installed Models</div> <div style={{width:26,height:26,borderRadius:5,display:"flex",alignItems:"center",justifyContent:"center",fontSize:11,background:m.loaded?`${T.green}22`:T.surface,color:m.loaded?T.green:T.textDim}}>🎛</div>
{models.map((m, i) => ( <div style={{flex:1}}><div style={{fontSize:12,fontWeight:500}}>{m.name}</div><div className="mono"style={{fontSize:10,color:T.textDim}}>{m.architecture}·{m.size_mb}MB{m.loaded?"· CURRENT":""}</div></div></div>))}</>)}
<div key={i} className="preset-row" style={{ cursor: "default" }}> {results!==null&&results.length===0&&<div className="card-sm"style={{textAlign:"center",color:T.textDim,padding:20}}>No results found</div>}
<div style={{ width: 28, height: 28, borderRadius: 6, display: "flex", {results!==null&&results.map((r,i)=>(<div key={i}className="preset-row"style={{cursor:"default"}}>
alignItems: "center", justifyContent: "center", fontSize: 12, <div style={{flex:1}}><div style={{fontSize:12,fontWeight:500}}>{r.name}</div><div className="mono"style={{fontSize:10,color:T.textDim}}>{r.author}·{r.size_display}</div></div>
background: m.loaded ? `${T.green}22` : T.surface, <button className="btn btn-ghost"style={{padding:"4px 9px",fontSize:11}}onClick={()=>install(r)}>Get</button></div>))}</div></div>);
color: m.loaded ? T.green : T.textDim }}>🎛</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>{m.name}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
{m.architecture} · {m.size_mb}MB {m.loaded ? "· CURRENT" : ""}
</div>
</div>
</div>
))}
</>
)}
{results !== null && results.length === 0 && (
<div className="card-sm" style={{ textAlign: "center", color: T.textDim, padding: 24 }}>No results found</div>
)}
{results !== null && results.map((r, i) => (
<div key={i} className="preset-row" style={{ cursor: "default" }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>{r.name}</div>
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
{r.author} · {r.size_display}
</div>
</div>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 11 }}
onClick={() => install(r)}>Get</button>
</div>
))}
</div>
</div>
);
} }
// ── Presets Screen ───────────────────────────────────────── // ── MOCK DATA (used when pedal not connected) ────────────────
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%", background: T.bg }}>
<div className="screen-header">
<div className="screen-title">Presets</div>
<button className="btn-icon" style={{ width: 30, height: 30, fontSize: 14 }}
onClick={onClose}></button>
</div>
<div className="screen-body">
<div className="toggle" style={{ marginBottom: 4 }}>
{["All", "Live", "Studio", "Practice"].map(t => (
<button key={t} className={`toggle-btn ${t === "All" ? "active" : ""}`}>{t}</button>
))}
</div>
{samplePresets.map((p, i) => (
<div key={i} className={`preset-row ${activeIdx === i ? "active" : ""}`}
onClick={() => { setActiveIdx(i); onLoadPreset?.(p.num); }}>
<div className="mono" style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700,
background: activeIdx === i ? `${p.color}22` : T.surface,
color: activeIdx === i ? p.color : T.textDim,
border: `1px solid ${activeIdx === i ? p.color + "60" : T.border}` }}>{p.num}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 3 }}>{p.name}</div>
<div style={{ display: "flex", gap: 4 }}>
{p.tags.map(t => (
<span key={t} className="badge" style={{ background: `${p.color}18`, color: p.color, fontSize: 9 }}>{t}</span>
))}
</div>
</div>
{activeIdx === i && <span style={{ color: T.amber, fontSize: 18 }}></span>}
</div>
))}
</div>
</div>
);
}
// ── App Shell — Helix Stadium Layout ──────────────────────────
const MOCK_BLOCKS=[ const MOCK_BLOCKS=[
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25,depth:70,mix:100}}, {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:"drive",type:"overdrive",name:"OD-808",bypassed:false,params:{drive:65,tone:55,level:70}},
@@ -588,15 +337,15 @@ const MOCK_BLOCKS = [
]; ];
const MOCK_PRESETS=[ const MOCK_PRESETS=[
{ num: 1, name: "Plexi Crunch" }, {num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Modern Hi-Gain"},
{ num: 2, name: "Modern Hi-Gain" }, {num:3,bank:1,name:"Clean Chime"},{num:4,bank:1,name:"Blues Drive"},
{ num: 3, name: "Clean Chime" }, {num:5,bank:2,name:"Metal Djent"},{num:6,bank:2,name:"Funky Wah"},
{ num: 4, name: "Blues Drive" }, {num:7,bank:2,name:"Ambient Pad"},{num:8,bank:2,name:"Shoegaze"},
]; ];
// ── App Shell — Landscape Helix Stadium ───────────────────────
export default function App(){ export default function App(){
// ── State ── const[view,setView]=useState("main");
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[state,setState]=useState({connected:false,master_volume:0.72,routing_mode:"mono",cpu_percent:0});
const[blocks,setBlocks]=useState(MOCK_BLOCKS); const[blocks,setBlocks]=useState(MOCK_BLOCKS);
const[selectedBlockId,setSelectedBlockId]=useState("gate"); const[selectedBlockId,setSelectedBlockId]=useState("gate");
@@ -605,149 +354,182 @@ export default function App() {
const[vuLevel,setVuLevel]=useState(0); const[vuLevel,setVuLevel]=useState(0);
const[currentPreset,setCurrentPreset]=useState(1); const[currentPreset,setCurrentPreset]=useState(1);
const[currentBank,setCurrentBank]=useState(1); const[currentBank,setCurrentBank]=useState(1);
const[presets,setPresets]=useState(MOCK_PRESETS);
const[globalBypass,setGlobalBypass]=useState(false);
const[loadingPreset,setLoadingPreset]=useState(false);
// Poll pedal state // ── Poll pedal state from API ──
useEffect(() => { const loadState=useCallback(async()=>{
const poll = async () => {
try{ try{
const s=await API.getState(); const s=await API.getState();
setState(s); setState(s);
if (s.cpu_percent != null) setState(prev => ({ ...prev, cpu_percent: s.cpu_percent }));
if(s.input_level!=null)setVuLevel(s.input_level); if(s.input_level!=null)setVuLevel(s.input_level);
// If API has preset info, use it
if(s.current_preset?.name){
setCurrentPreset(s.current_preset.program!=null?s.current_preset.program+1:currentPreset);
}
// If API has blocks, use them
if(s.blocks&&s.blocks.length>0){setBlocks(s.blocks);}
}catch{} }catch{}
}; },[currentPreset]);
poll();
const id = setInterval(poll, 3000);
return () => clearInterval(id);
}, []);
// Mock VU animation when no real data useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]);
// VU animation fallback when no real data
useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.45)*8)));},150);return()=>clearInterval(id);},[]);
// ── Load presets from API ──
useEffect(()=>{ useEffect(()=>{
const id = setInterval(() => { API.listPresets().then(data=>{
setVuLevel(prev => Math.max(0, Math.min(100, prev + (Math.random() - 0.45) * 8))); if(data?.banks){
}, 150); const all=[];
return () => clearInterval(id); data.banks.forEach(b=>{(b.presets||[]).forEach((p,i)=>{if(p)all.push({num:i+1,bank:b.number,name:p.name,chain:p.chain});});});
if(all.length>0)setPresets(all);
}
}).catch(()=>{});
},[]); },[]);
// Selected block // ── Change preset ──
const handleLoadPreset=useCallback(async(bank,program)=>{
setLoadingPreset(true);
try{
const p=await API.loadPreset(bank,program);
if(p?.chain)setBlocks(p.chain.map((b,i)=>({...b,id:b.id||(`block_${i}`),bypassed:!b.enabled,params:b.params||{}})));
setCurrentPreset(program);
setSelectedBlockId(null);
}catch(e){console.warn("Preset load failed:",e);}
setLoadingPreset(false);
},[]);
const handlePresetClick=useCallback(async(num)=>{
// Find the preset by number
const p=presets.find(pr=>pr.num===num);
if(!p)return;
setCurrentPreset(num);
await handleLoadPreset(p.bank||1,num-1);
},[presets,handleLoadPreset]);
// ── Blocks ──
const selectedBlock=useMemo(()=>blocks.find(b=>b.id===selectedBlockId),[blocks,selectedBlockId]); const selectedBlock=useMemo(()=>blocks.find(b=>b.id===selectedBlockId),[blocks,selectedBlockId]);
// Initialize params when block is selected
useEffect(()=>{ useEffect(()=>{
if(selectedBlock&&blockParams[selectedBlockId]==null){ if(selectedBlock&&blockParams[selectedBlockId]==null){
const defs = getBlockParams(selectedBlock.type); const defs=getBlockParams(selectedBlock.type);const init={};
const init = {}; defs.forEach(p=>{init[p.key]=(selectedBlock.params?.[p.key]!=null)?selectedBlock.params[p.key]:p.def;});
defs.forEach(p => {
init[p.key] = (selectedBlock.params?.[p.key] != null) ? selectedBlock.params[p.key] : p.def;
});
setBlockParams(prev=>({...prev,[selectedBlockId]:init})); setBlockParams(prev=>({...prev,[selectedBlockId]:init}));
} }
},[selectedBlockId,selectedBlock,blockParams]); },[selectedBlockId,selectedBlock,blockParams]);
const handleSelectBlock = useCallback((id) => { const handleSelectBlock=useCallback((id)=>{setSelectedBlockId(prev=>prev===id?null: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[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);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 handleToggleBlock = useCallback((id) => { // ── Bank navigation ──
setBlocks(prev => prev.map(b => b.id === id ? { ...b, bypassed: !b.bypassed } : b)); const currentBankPresets=useMemo(()=>presets.filter(p=>p.bank===currentBank),[presets,currentBank]);
API.toggleBlock(id, false).catch(() => {}); const bankLetter=String.fromCharCode(64+currentBank);
}, []);
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(()=>{ const handleBankUp=useCallback(()=>{
setCurrentBank(prev => Math.min(prev + 1, 8)); const next=Math.min(currentBank+1,8);
}, []); setCurrentBank(next);
// Load first preset of new bank
const bp=presets.filter(p=>p.bank===next);
if(bp.length>0)handlePresetClick(bp[0].num);
},[currentBank,presets,handlePresetClick]);
const handleBankDown=useCallback(()=>{ const handleBankDown=useCallback(()=>{
setCurrentBank(prev => Math.max(prev - 1, 1)); const next=Math.max(currentBank-1,1);
}, []); setCurrentBank(next);
const bp=presets.filter(p=>p.bank===next);
if(bp.length>0)handlePresetClick(bp[0].num);
},[currentBank,presets,handlePresetClick]);
// Convert bank number to letter (1=A, 2=B, etc.) const handleGlobalBypass=useCallback(()=>{
const bankLetter = String.fromCharCode(64 + currentBank); setGlobalBypass(p=>!p);
API.bypassToggle().catch(()=>{});
},[]);
// ── Render ── // ── Render ──
return( return(
<> <><style>{css}</style>
<style>{css}</style> <div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden",
<div style={{ fontFamily:"'Inter',sans-serif",margin:"0 auto",
width: "100%", maxWidth: 480, margin: "0 auto", height: "100vh", maxWidth:980,
display: "flex", flexDirection: "column", background: T.bg, overflow: "hidden",
fontFamily: "'Inter', sans-serif",
}}> }}>
{view==="main"&&( {view==="main"&&(
<> <>
{/* ── Status Bar ── */} {/* ══════ STATUS BAR ══════ */}
<div style={{ <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",
display: "flex", justifyContent: "space-between", alignItems: "center", padding:"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:32}}>
padding: "6px 12px 4px", background: T.panel, borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
}}>
<div style={{display:"flex",gap:8,alignItems:"center"}}> <div style={{display:"flex",gap:8,alignItems:"center"}}>
<div style={{width:6,height:6,borderRadius:"50%", <div style={{width:6,height:6,borderRadius:"50%",
background: state.connected ? T.green : T.red, background:state.connected?T.green:T.red,boxShadow:`0 0 6px ${state.connected?T.green:T.red}`}}/>
boxShadow: `0 0 6px ${state.connected ? T.green : T.red}` }} /> <span className="mono" style={{fontSize:9,color:T.textSec}}>{state.connected?"CONNECTED":"OFFLINE"}</span>
<span className="mono" style={{ fontSize: 9, color: T.textSec }}> <div style={{width:1,height:12,background:T.border}}/>
{state.connected ? "CONNECTED" : "OFFLINE"} <span className="mono" style={{fontSize:10,color:T.textPrimary,fontWeight:600}}>
</span> {state.current_preset?.name||(presets.find(p=>p.num===currentPreset)?.name||"—")}
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>
{state.current_preset?.name || "Plexi Crunch"}
</span> </span>
{loadingPreset&&<span className="mono" style={{fontSize:8,color:T.amber}}></span>}
</div> </div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}> <div style={{display:"flex",gap:8,alignItems:"center"}}>
<VUMeter level={vuLevel} height={28} /> <div style={{display:"flex",gap:8,alignItems:"center"}}>
<button className="btn-icon" style={{ width: 26, height: 26, fontSize: 11 }} <VUMeter level={vuLevel} height={24}/>
onClick={() => setView("captures")} title="Downloads">📁</button> <span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span>
<button className="btn-icon" style={{ width: 26, height: 26, fontSize: 11 }} </div>
onClick={() => setView("presets")} title="Presets"></button> <div style={{width:1,height:12,background:T.border}}/>
<button className="btn-icon" style={{width:24,height:24,fontSize:10}}onClick={()=>setView("captures")}title="Downloads">📁</button>
<button className="btn-icon" style={{width:24,height:24,fontSize:10}}onClick={()=>setView("presets")}title="Presets"></button>
</div> </div>
</div> </div>
{/* ── Scribble Info Bar ── */} {/* ══════ MAIN AREA: Preset sidebar + Content ══════ */}
<ScribbleBar state={state} /> <div style={{flex:1,display:"flex",overflow:"hidden"}}>
{/* ── Preset Strip ── */} {/* ── Preset Sidebar ─── */}
<div style={{ <div style={{width:90,flexShrink:0,background:T.surface,borderRight:`1px solid ${T.border}`,
display: "flex", gap: 4, padding: "5px 10px", display:"flex",flexDirection:"column",overflow:"hidden"}}>
flexShrink: 0, borderBottom: `1px solid ${T.border}`, {/* Bank header */}
}}> <div style={{padding:"6px 8px",textAlign:"center",borderBottom:`1px solid ${T.border}`,background:T.panel}}>
{MOCK_PRESETS.map(p => ( <div className="mono" style={{fontSize:14,fontWeight:700,color:T.amber}}>Bank {bankLetter}</div>
<div style={{fontSize:9,color:T.textDim,letterSpacing:".06em",textTransform:"uppercase"}}>Programs</div>
</div>
{/* Preset list */}
<div style={{flex:1,overflowY:"auto",padding:"4px 6px",display:"flex",flexDirection:"column",gap:3}}>
{currentBankPresets.map(p=>(
<div key={p.num} <div key={p.num}
className={`preset-chip ${p.num === currentPreset ? "active" : ""}`} className={`preset-chip-s ${p.num===currentPreset?"active":""}`}
onClick={() => setCurrentPreset(p.num)}> onClick={()=>handlePresetClick(p.num)}>
<div className="pc-name">{p.name}</div> <div className="pcs-num">0{p.num}</div>
<div className="pc-num">0{p.num}</div> <div className="pcs-name">{p.name}</div>
<div className="pcs-indicator" style={{background:p.num===currentPreset?T.amber:T.border}}/>
</div>
))}
</div>
</div>
{/* ─── Main Content Area ─── */}
<div style={{flex:1,display:"flex",flexDirection:"column",overflow:"hidden",minWidth:0}}>
{/* Scribble info bar */}
<div style={{display:"flex",gap:4,padding:"3px 8px",background:T.surface,borderBottom:`1px solid ${T.border}`,flexShrink:0}}>
{[
{label:"Preset",value:presets.find(p=>p.num===currentPreset)?.name||"—"},
{label:"Routing",value:(state.routing_mode||"MONO").toUpperCase()},
{label:"Volume",value:state.master_volume!=null?`${Math.round(state.master_volume*100)}%`:"—"},
{label:"Bank",value:bankLetter},
].map(s=>(
<div key={s.label} style={{flex:1,textAlign:"center",padding:"2px 6px",borderRadius:3,background:T.panel,border:`1px solid ${T.border}`}}>
<div style={{fontSize:7,color:T.textDim,letterSpacing:".06em",textTransform:"uppercase"}}>{s.label}</div>
<div className="mono" style={{fontSize:10,fontWeight:600,color:T.amber,marginTop:1}}>{s.value}</div>
</div> </div>
))} ))}
</div> </div>
{/* ── Block Chain + Params ── */} {/* Block Chain */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{flex:selectedBlock?"0 0 auto":1,overflow:"hidden"}}> <div style={{flex:selectedBlock?"0 0 auto":1,overflow:"hidden"}}>
<BlockChain <BlockChain
blocks={blocks} blocks={blocks}
@@ -760,8 +542,8 @@ export default function App() {
/> />
</div> </div>
{/* ── Parameter Panel ── */} {/* Parameter Panel or placeholder */}
{selectedBlock && blockParams[selectedBlockId] && ( {selectedBlock&&blockParams[selectedBlockId]?(
<ParameterPanel <ParameterPanel
block={selectedBlock} block={selectedBlock}
params={blockParams[selectedBlockId]||{}} params={blockParams[selectedBlockId]||{}}
@@ -770,18 +552,15 @@ export default function App() {
onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)} onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)}
onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)} onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)}
/> />
)} ):(
{!selectedBlock && ( <div style={{flex:1,display:"flex",alignItems:"center",justifyContent:"center",background:T.bg,color:T.textDim,fontSize:11}}>
<div style={{ Tap a block to edit parameters
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
background: T.bg, color: T.textDim, fontSize: 12,
}}>
Tap a block to edit its parameters
</div> </div>
)} )}
</div> </div>
</div>
{/* ── Footswitch Bar ── */} {/* ══════ FOOTSWITCH BAR ══════ */}
<FootswitchBar <FootswitchBar
blocks={blocks} blocks={blocks}
selectedBlockId={selectedBlockId} selectedBlockId={selectedBlockId}
@@ -790,24 +569,31 @@ export default function App() {
onToggleBlock={handleToggleBlock} onToggleBlock={handleToggleBlock}
onBankUp={handleBankUp} onBankUp={handleBankUp}
onBankDown={handleBankDown} onBankDown={handleBankDown}
onGlobalBypass={() => setState(prev => ({ ...prev, bypass: !prev.bypass }))} onGlobalBypass={handleGlobalBypass}
globalBypass={state.bypass} globalBypass={globalBypass}
/> />
</> </>
)} )}
{/* ── Overlay screens ── */} {/* ── Overlays ── */}
{view === "captures" && ( {view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
<CapturesScreen onClose={() => setView("main")} />
)}
{view==="presets"&&( {view==="presets"&&(
<PresetsScreen <div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
state={state} <div className="screen-header"><div className="screen-title">Presets</div><button className="btn-icon"style={{width:28,height:28,fontSize:13}}onClick={()=>setView("main")}></button></div>
onClose={() => setView("main")} <div className="screen-body">
onLoadPreset={(num) => setCurrentPreset(num)} {presets.map((p,i)=>(
/> <div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`}
)} onClick={()=>{handlePresetClick(p.num);setView("main");}}>
<div className="mono" style={{width:26,height:26,borderRadius:5,display:"flex",alignItems:"center",justifyContent:"center",fontSize:11,fontWeight:700,
background:p.num===currentPreset?`${T.amber}22`:T.surface,color:p.num===currentPreset?T.amber:T.textDim,
border:`1px solid ${p.num===currentPreset?T.amber+"60":T.border}`}}>{p.num}</div>
<div style={{flex:1}}><div style={{fontSize:12,fontWeight:600}}>{p.name}</div><div className="mono"style={{fontSize:9,color:T.textDim}}>Bank {p.bank||1}·Program {p.num-1}</div></div>
{p.num===currentPreset&&<span style={{color:T.amber,fontSize:16}}></span>}
</div> </div>
</> ))}
</div>
</div>
)}
</div></>
); );
} }