Files
pi-multifx-pedal-ui/src/App.jsx
T

864 lines
50 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import BlockChain from "./BlockChain.jsx";
import FootswitchBar from "./FootswitchBar.jsx";
import TunerScreen from "./Tuner.jsx";
import FocusView from "./FocusView.jsx";
import ModelBrowser from "./ModelBrowser.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"),
getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`),
activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`),
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 }),
tunerToggle: (enabled) => api("POST", "/api/tuner", { enabled }),
};
// ── 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 SPLIT_TYPES = [
{ value:'y', label:'Y — Split evenly', pan:true },
{ value:'ab', label:'A/B — Route to A or B', pan:false },
{ value:'cascade', label:'Cascade — A feeds B', pan:true },
{ value:'a_to_b', label:'A→B — Serial extension', pan:false },
];
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 }],
split: [{ key:'splitType', label:'Split Type', type:'select', options:SPLIT_TYPES, def:'y' },
{ key:'panA', label:'Pan A', min:0, max:100, def:50, conditional: (p) => p.splitType !== 'ab' },
{ key:'panB', label:'Pan B', min:0, max:100, def:50, conditional: (p) => p.splitType !== 'ab' },
{ key:'activePath', label:'Active Path', type:'select', options:[{value:'A',label:'A'},{value:'B',label:'B'}], def:'A', conditional: (p) => p.splitType === 'ab' }],
merge: [{ key:'blend', label:'Blend', min:0, max:100, def:100 },
{ key:'levelA', label:'Level A', min:0, max:100, def:100 },
{ key:'panA', label:'Pan A', min:-100, max:100, def:0 },
{ key:'levelB', label:'Level B', min:0, max:100, def:100 },
{ key:'panB', label:'Pan B', min:-100, max:100, def:0 }],
};
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',
split: '#B080C0', merge: '#80B0C0',
};
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",
split: "Split", merge: "Merge",
};
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 it has a NAM model path, show the model filename
if (block.nam_model_path) {
const parts = block.nam_model_path.split('/').pop()?.replace(/\.(nam|wav|aiff?)$/i, '') || '';
return parts || 'NAM Model';
}
// If it has an IR file path, show the filename
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);
}
// ── 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');
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
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}; }
.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: 4px; 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: 9px; color: ${T.textSec}; letter-spacing: .06em; text-transform: uppercase; text-align: center; }
.knob-value { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: ${T.amber}; }
/* ── Buttons ── */
.btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; 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-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center;
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
font-size: 15px; transition: all .12s; flex-shrink: 0; }
.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}; }
/* ── Param slider ── */
.param-panel { background: ${T.surface}; padding: 8px 12px 6px; border-top: 1px solid ${T.border}; flex-shrink: 0; }
.param-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2px; }
.param-panel-title { font-size: 10px; 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-header { display: flex; justify-content: space-between; align-items: center; }
.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: 11px; font-weight: 600; }
.param-track-wrap { position: relative; height: 30px; display: flex; align-items: center; cursor: pointer; touch-action: none; }
.param-track { width: 100%; height: 6px; background: ${T.border}; border-radius: 3px; position: relative; overflow: hidden; }
.param-fill { height: 100%; border-radius: 3px; position: absolute; top: 0; left: 0; transition: width .04s; }
.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;
border: 3px solid ${T.bg}; }
.param-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid ${T.border};
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; }
.param-btn:active { background: ${T.border}; transform: scale(.92); }
.param-knobs { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; padding: 4px 0; }
/* ── Screen header (overlays) ── */
.screen-header { padding: 10px 14px 8px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid ${T.border}; }
.screen-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: ${T.textSec}; }
.screen-body { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; }
/* ── Preset sidebar chip ── */
.preset-chip-s { width: 100%; padding: 7px 6px; border-radius: 5px; background: ${T.surface}; border: 1px solid ${T.border};
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); }
.section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
color: ${T.textDim}; font-weight: 600; margin-bottom: 8px; }
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 8px; padding: 14px; }
.card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 6px; padding: 10px; }
`;
// ── Knob ──────────────────────────────────────────────────────
function Knob({ label, value = 50, onChange, size = 48, 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,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 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 (
<div className="knob-wrap" style={{width:size+12}}>
<div className="knob" style={{width:size,height:size}} onMouseDown={handleStart} onTouchStart={handleStart}>
<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" strokeLinecap="round" opacity=".5"/>
{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})`}}/>}
</svg>
<div className="knob-inner" style={{"--amber":color}}>
<div className="knob-dot" style={{height:dotH,top:dotTop,transform:`translateX(-50%) rotate(${angle}deg)`,transformOrigin:`50% ${dotH}px`}}/>
</div>
</div>
<div className="knob-value mono" style={{color}}>{value}</div>
<div className="knob-label">{label}</div>
</div>
);
}
// ── Param Slider ──────────────────────────────────────────────
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 (
<div className="param-row">
<div className="param-header">
<span className="param-label" style={{color}}>{label}</span>
<div style={{display:'flex',gap:4,alignItems:'center'}}>
<button className="param-btn" onMouseDown={quick(-5)} onTouchStart={quick(-5)}></button>
<span className="param-val" style={{color}}>{value}</span>
<button className="param-btn" onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button>
</div>
</div>
<div className="param-track-wrap" ref={trackRef} onMouseDown={handleStart} onTouchStart={handleStart}>
<div className="param-track">
<div className="param-fill" style={{width:`${pct}%`,background:color}}/>
<div className="param-thumb" style={{left:`${pct}%`,background:color}}/>
</div>
</div>
</div>
);
}
// ── Select Param ────────────────────────────────────────────
function SelectParam({ def, value, color, onChange }) {
return (
<div className="param-row">
<div className="param-header">
<span className="param-label" style={{color}}>{def.label}</span>
</div>
<div style={{
display: "flex", gap: 4, flexWrap: "wrap",
background: "#0A0A10", borderRadius: 6, padding: 4,
}}>
{(def.options || []).map(opt => (
<button key={opt.value}
onClick={() => onChange?.(opt.value)}
style={{
flex: 1, padding: "5px 8px", borderRadius: 4, border: "none",
fontSize: 10, fontWeight: 600, cursor: "pointer",
background: value === opt.value ? color : "transparent",
color: value === opt.value ? "#000" : "#8888A0",
transition: "all .1s", letterSpacing: ".03em",
whiteSpace: "nowrap",
}}
>
{opt.label}
</button>
))}
</div>
</div>
);
}
// ── Parameter Panel ──────────────────────────────────────────
function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView,onFocus,onModelBrowser }) {
const defs=getBlockParams(block.type);const color=getBlockColor(block.type);
const visibleDefs = defs.filter(d => !d.conditional || d.conditional(params));
return (
<div className="param-panel">
<div className="param-panel-header">
<span className="param-panel-title" style={{cursor:onModelBrowser?'pointer':'default'}}
onClick={()=>onModelBrowser?.(block.id)}>
<span style={{color}}></span> {block.name||block.type||''}
{onModelBrowser&&<span style={{marginLeft:4,fontSize:8,color:T.textDim}}></span>}
</span>
<div style={{display:'flex',gap:4,alignItems:'center'}}>
{onFocus&&<button className="btn" style={{padding:'4px 8px',fontSize:9,fontWeight:600,background:`${T.amber}22`,border:`1px solid ${T.amber}44`,color:T.amber,borderRadius:5,letterSpacing:'.04em',cursor:'pointer'}}
onClick={onFocus}>🔍 Focus</button>}
<button className="btn-icon" style={{width:26,height:26,fontSize:11,flexShrink:0}}
onClick={onToggleView}>{viewMode==='slider'?'◉':'▦'}</button>
</div>
</div>
{viewMode==='slider'?visibleDefs.map(p=>(
p.type === 'select' ? (
<SelectParam key={p.key} def={p} value={params[p.key]!=null?params[p.key]:p.def}
color={color}
onChange={v=>{onParamChange?.(p.key,v);onParamChangeEnd?.(p.key,v);}}/>
) : (
<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)}/>
)
)):(
visibleDefs.filter(p => p.type !== 'select').length > 0 && (
<div className="param-knobs">{visibleDefs.filter(p => p.type !== 'select').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>
);
}
// ── VU Meter ──────────────────────────────────────────────────
function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs);
const c=i=>i>=10?T.red:i>=8?"#E8C030":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?c(i):T.border,boxShadow:i<active?`0 0 4px ${c(i)}`:"none",transition:"background .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?c(i):T.border,transition:"background .05s"}}/>))}</div>);
}
// Captures Screen
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(<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:28,height:28,fontSize:13}}onClick={onClose}></button></div>
<div style={{padding:"6px 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==="irs"?"active":""}`}onClick={()=>setTab("irs")}>IR Cabs</button></div></div>
<div style={{padding:"6px 12px",display:"flex",gap:6,borderBottom:`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"}}>
<span style={{color:T.textDim,fontSize:11}}>🔍</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>
<button className="btn btn-primary"style={{padding:"6px 11px",fontSize:11}}onClick={doSearch}>{loading?"...":"Search"}</button></div>
<div className="screen-body">
{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 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>
<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:20}}>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:"4px 9px",fontSize:11}}onClick={()=>install(r)}>Get</button></div>))}</div></div>);
}
// ── MOCK DATA (used when pedal not connected) ────────────────
const MOCK_BLOCKS=[
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25}},
{id:"comp",type:"compressor",name:"Compressor",bypassed:false,params:{threshold:50,ratio:4,gain:50}},
{id:"amp",type:"nam",name:"Twin Reverb",bypassed:false,params:{level:75}},
{id:"cab",type:"ir",name:"2x12 American",bypassed:false,params:{level:75}},
{id:"eq",type:"eq",name:"EQ",bypassed:false,params:{low:0,mid:0,high:0,level:70}},
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{subtype:"hall",decay:50,mix:30,tone:50}},
];
const MOCK_PRESETS=[
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Blues Rock Lead"},
{num:3,bank:1,name:"Deluxe Clean"},{num:4,bank:1,name:"British Drive"},
{num:5,bank:2,name:"Metal Djent"},{num:6,bank:2,name:"Funky Wah"},
{num:7,bank:2,name:"Ambient Pad"},{num:8,bank:2,name:"Shoegaze"},
];
// ── App Shell — Landscape Helix Stadium ───────────────────────
export default function App(){
const[view,setView]=useState("main");
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);
const[presets,setPresets]=useState(MOCK_PRESETS);
const[globalBypass,setGlobalBypass]=useState(false);
const[loadingPreset,setLoadingPreset]=useState(false);
const[focusBlockId,setFocusBlockId]=useState(null);
const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
const[footswitchMode,setFootswitchMode]=useState("stomp");
// ── Poll pedal state from API ──
const userChangedPreset = useRef(false);
const userChangedBlock = useRef(false);
const loadState=useCallback(async()=>{
try{
const s=await API.getState();
setState(s);
if(s.input_level!=null)setVuLevel(s.input_level);
if(s.current_preset?.name&&!userChangedPreset.current){
setCurrentPreset(s.current_preset.program!=null?s.current_preset.program+1:currentPreset);
}
// Only update blocks from API if user didn't just toggle/change them
if(s.blocks&&s.blocks.length>0&&!userChangedBlock.current){
setBlocks(s.blocks);
}
}catch{}
},[currentPreset]);
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(()=>{
API.listPresets().then(data=>{
if(data?.banks){
const all=[];
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(()=>{});
},[]);
// ── Change preset ──
const handleLoadPreset=useCallback(async(bank,program)=>{
userChangedPreset.current = true;
setLoadingPreset(true);
try{
// Activate on the backend first
await API.activatePreset(bank,program);
// Short delay for backend to apply
await new Promise(r => setTimeout(r, 300));
// Get updated state
const s=await API.getState();
setState(s);
if(s.current_preset?.name){
setCurrentPreset(s.current_preset.program!=null?s.current_preset.program+1:program+1);
}
// Get preset chain data and build blocks
try{
const p=await API.getPreset(bank,program);
if(p?.chain&&p.chain.length>0){
const newBlocks=p.chain.map((b,i)=>{
const raw = { type: b.fx_type || "fx",
enabled: b.enabled !== false, params: b.params || {},
nam_model_path: b.nam_model_path, ir_file_path: b.ir_file_path,
};
return {
id: b.id || `block_${b.fx_type || 'fx'}_${i}`,
type: raw.type,
name: getBlockDisplayName(raw),
bypassed: !b.enabled,
enabled: raw.enabled,
params: raw.params,
nam_model_path: raw.nam_model_path,
ir_file_path: raw.ir_file_path,
};
});
setBlocks(newBlocks);
userChangedBlock.current = true;
}
}catch(e){console.warn("Could not fetch chain:",e);}
setSelectedBlockId(null);
}catch(e){console.warn("Preset activate failed:",e);}
setLoadingPreset(false);
// Re-enable polling sync after 5s
setTimeout(() => { userChangedPreset.current = false; }, 5000);
setTimeout(() => { userChangedBlock.current = false; }, 5000);
},[]);
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]);
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)=>{userChangedBlock.current=true;setBlocks(prev=>prev.map(b=>b.id===id?{...b,bypassed:!b.bypassed}:b));API.toggleBlock(id,false).catch(()=>{});setTimeout(()=>{userChangedBlock.current=false;},3000);},[]);
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(()=>{userChangedBlock.current=true;const id="block_"+Date.now();setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);setTimeout(()=>{userChangedBlock.current=false;},3000);},[]);
const handleRemoveBlock=useCallback((id)=>{userChangedBlock.current=true;setBlocks(prev=>prev.filter(b=>b.id!==id));if(selectedBlockId===id)setSelectedBlockId(null);setTimeout(()=>{userChangedBlock.current=false;},3000);},[selectedBlockId]);
// ── Bank navigation ──
const currentBankPresets=useMemo(()=>presets.filter(p=>p.bank===currentBank),[presets,currentBank]);
const bankLetter=String.fromCharCode(64+currentBank);
const handleBankUp=useCallback(()=>{
const next=Math.min(currentBank+1,8);
setCurrentBank(next);
const bp=presets.filter(p=>p.bank===next);
if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1);
},[currentBank,presets,handleLoadPreset]);
const handleBankDown=useCallback(()=>{
const next=Math.max(currentBank-1,1);
setCurrentBank(next);
const bp=presets.filter(p=>p.bank===next);
if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1);
},[currentBank,presets,handleLoadPreset]);
const handleGlobalBypass=useCallback(()=>{
setGlobalBypass(p=>!p);
API.bypassToggle().catch(()=>{});
},[]);
// ── Tuner toggle ──
const handleTunerToggle=useCallback(()=>{
const newState = !state.tuner_enabled;
setState(prev=>({...prev, tuner_enabled: newState}));
API.tunerToggle(newState).catch(()=>{});
if (newState) {
setView("main"); // Return to main view when enabling tuner (or keep current)
}
},[state.tuner_enabled]);
// ── Focus View handlers ──
const handleFocusViewBypass=useCallback(()=>{
userChangedBlock.current=true;
const id=focusBlockId;
if(!id)return;
setBlocks(prev=>prev.map(b=>b.id===id?{...b,bypassed:!b.bypassed}:b));
API.toggleBlock(id,false).catch(()=>{});
setTimeout(()=>{userChangedBlock.current=false;},3000);
},[focusBlockId]);
const handleOpenModelBrowser=useCallback((blockId)=>{
setModelBrowserBlockId(blockId);
if(focusBlockId) setFocusBlockId(null);
},[focusBlockId]);
const handleSelectModel=useCallback(({type, name, modelPath, isBuiltin})=>{
const id=modelBrowserBlockId;
if(!id)return;
userChangedBlock.current=true;
setBlocks(prev=>prev.map(b=>{
if(b.id!==id)return b;
const defs=getBlockParams(type);
const init={};
defs.forEach(p=>{init[p.key]=p.def;});
return {
...b,
type,
name: name || getBlockDisplayName({type}),
params: init,
nam_model_path: (!isBuiltin && modelPath && type==='nam') ? modelPath : undefined,
ir_file_path: (!isBuiltin && modelPath && type==='ir') ? modelPath : undefined,
};
}));
setModelBrowserBlockId(null);
setBlockParams(prev=>{
const next={...prev};
delete next[id];
return next;
});
setTimeout(()=>{userChangedBlock.current=false;},3000);
},[modelBrowserBlockId]);
// ── Render ──
return(
<><style>{css}</style>
<div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden",
fontFamily:"'Inter',sans-serif",margin:"0 auto",
maxWidth:980,
}}>
{view==="main"&&(
<>
{/* ══════ STATUS BAR ══════ */}
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",
padding:"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:32}}>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<div style={{width:6,height:6,borderRadius:"50%",
background: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>
<div style={{width:1,height:12,background:T.border}}/>
<span className="mono" style={{fontSize:10,color:T.textPrimary,fontWeight:600}}>
{state.current_preset?.name||(presets.find(p=>p.num===currentPreset)?.name||"—")}
</span>
{loadingPreset&&<span className="mono" style={{fontSize:8,color:T.amber}}></span>}
</div>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<VUMeter level={vuLevel} height={24}/>
<span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span>
</div>
<div style={{width:1,height:12,background:T.border}}/>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("captures")}title="Downloads">
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
</button>
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("presets")}title="Presets">
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
</button>
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}>
{["stomp","preset"].map(m=>(<button key={m} onClick={()=>setFootswitchMode(m)} style={{
padding:"1px 5px",borderRadius:3,border:"none",height:20,
fontSize:8,fontWeight:700,letterSpacing:".04em",textTransform:"uppercase",
background:footswitchMode===m?T.amber:"transparent",
color:footswitchMode===m?"#000":T.textDim,
cursor:"pointer",lineHeight:1.5,minWidth:34,display:"flex",alignItems:"center",justifyContent:"center",
}}>{m==="stomp"?"SM":"PR"}</button>))}
</div>
<button className="btn-icon" style={{width:24,height:24,position:'relative',
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface,
borderColor:state.tuner_enabled?T.green:T.border}}
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'invert(0.9) brightness(2) sepia(0.4) hue-rotate(80deg)':'invert(1) brightness(2)'}}/>
</button>
<button className="btn-icon" style={{width:24,height:24,position:'relative',background:'rgba(232,160,48,.12)',borderColor:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen">
<svg viewBox="0 0 24 24" width="14" height="14" fill="#E8A030">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>
</svg>
</button>
</div>
</div>
{/* ══════ MAIN AREA: Preset sidebar + Content ══════ */}
<div style={{flex:1,display:"flex",overflow:"hidden"}}>
{/* ─── Preset Sidebar ─── */}
<div style={{width:90,flexShrink:0,background:T.surface,borderRight:`1px solid ${T.border}`,
display:"flex",flexDirection:"column",overflow:"hidden"}}>
{/* Bank header */}
<div style={{padding:"6px 8px",textAlign:"center",borderBottom:`1px solid ${T.border}`,background:T.panel}}>
<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}
className={`preset-chip-s ${p.num===currentPreset?"active":""}`}
onClick={()=>handlePresetClick(p.num)}>
<div className="pcs-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>
{/* Block Chain */}
<div style={{flex:selectedBlock?"0 0 auto":1,overflow:"hidden"}}>
<BlockChain
blocks={blocks}
selectedBlockId={selectedBlockId}
onSelectBlock={handleSelectBlock}
onToggleBlock={handleToggleBlock}
onReorder={handleReorder}
onAddBlock={handleAddBlock}
onRemoveBlock={handleRemoveBlock}
onModelBrowser={handleOpenModelBrowser}
/>
</div>
{/* Parameter Panel or placeholder */}
{selectedBlock&&blockParams[selectedBlockId]?(
<ParameterPanel
block={selectedBlock}
params={blockParams[selectedBlockId]||{}}
viewMode={paramView}
onToggleView={()=>setParamView(p=>p==='slider'?'knob':'slider')}
onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)}
onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)}
onFocus={()=>setFocusBlockId(selectedBlockId)}
onModelBrowser={handleOpenModelBrowser}
/>
):(
<div style={{flex:1,display:"flex",alignItems:"center",justifyContent:"center",background:T.bg,color:T.textDim,fontSize:11}}>
Tap a block to edit parameters
</div>
)}
</div>
</div>
{/* ══════ FOOTSWITCH BAR ══════ */}
<FootswitchBar
mode={footswitchMode}
blocks={blocks}
selectedBlockId={selectedBlockId}
currentBank={bankLetter}
currentPreset={currentPreset}
presets={currentBankPresets}
onSelectBlock={handleSelectBlock}
onToggleBlock={handleToggleBlock}
onBankUp={handleBankUp}
onBankDown={handleBankDown}
onGlobalBypass={handleGlobalBypass}
globalBypass={globalBypass}
onTunerToggle={handleTunerToggle}
tunerEnabled={state.tuner_enabled}
onPresetClick={(idx)=>{const p=currentBankPresets[parseInt(idx)];if(p)handleLoadPreset(p.bank,p.num-1);}}
/>
</>
)}
{/* ── Focus View overlay ── */}
{focusBlockId&&blocks.find(b=>b.id===focusBlockId)&&blockParams[focusBlockId]&&(
<FocusView
block={blocks.find(b=>b.id===focusBlockId)}
params={blockParams[focusBlockId]||{}}
viewMode={paramView}
onClose={()=>setFocusBlockId(null)}
onParamChange={(key,value)=>handleParamChange(focusBlockId,key,value)}
onParamChangeEnd={(key,value)=>handleParamChangeEnd(focusBlockId,key,value)}
onToggleView={()=>setParamView(p=>p==='slider'?'knob':'slider')}
onToggleBypass={handleFocusViewBypass}
onOpenModelBrowser={()=>handleOpenModelBrowser(focusBlockId)}
/>
)}
{/* ── Overlays ── */}
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
{view==="presets"&&(
<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:28,height:28,fontSize:13}}onClick={()=>setView("main")}></button></div>
<div className="screen-body">
{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>
)}
{/* ── Tuner overlay ── */}
{state.tuner_enabled&&(
<TunerScreen
tunerData={state}
connected={state.connected}
onExit={()=>{
setState(prev=>({...prev, tuner_enabled: false}));
API.tunerToggle(false).catch(()=>{});
}}
/>
)}
{/* ── Model Browser ── */}
{modelBrowserBlockId&&(()=>{
const block=blocks.find(b=>b.id===modelBrowserBlockId);
if(!block)return null;
return (
<ModelBrowser
currentBlockType={block.type}
currentBlockName={block.name}
currentModelPath={block.nam_model_path||block.ir_file_path}
onSelectModel={handleSelectModel}
onClose={()=>setModelBrowserBlockId(null)}
/>
);
})()}
</div></>
);
}