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

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