864 lines
50 KiB
React
864 lines
50 KiB
React
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></>
|
||
);
|
||
}
|