From 64f10863431997515ee405248fd1a32e048d40cd Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 12 Jun 2026 18:51:15 -0400 Subject: [PATCH] PEDAL-UI: Landscape layout + functional preset loading + bank navigation + API integration - Fixed layout for landscape mode: maxWidth 980px, preset sidebar on left - Preset chips now call API.loadPreset() on click - Bank up/down cycles through banks, loads first preset - Global bypass wired to API.bypassToggle() - Block toggle wired to API.toggleBlock() - Slider changes commit to API.updateBlock() on drag end - Presets loaded from API.listPresets() on mount - Status bar shows real API state --- src/App.jsx | 954 ++++++++++++++++++++-------------------------------- 1 file changed, 370 insertions(+), 584 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 6818aa3..9f237f4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,7 +23,7 @@ const API = { 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 }), + loadPreset: (bank, program) => api("GET", "/api/presets/{bank}/{program}".replace("{bank}",bank).replace("{program}",program)), savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }), searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`), searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`), @@ -81,47 +81,39 @@ function getBlockColor(type) { return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber; } -// ── CSS (Helix Stadium theme) ───────────────────────────────── +// ── 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; } - body { background: ${T.bg}; color: ${T.textPrimary}; font-family: 'Inter', sans-serif; } - + html, body { height: 100%; overflow: hidden; background: ${T.bg}; } + body { color: ${T.textPrimary}; font-family: 'Inter', sans-serif; } :root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; } .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-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: 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; } + .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: 10px 18px; border-radius: 7px; font-size: 13px; font-weight: 600; + .btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; letter-spacing: .04em; border: none; cursor: pointer; transition: all .12s; } .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; + .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: 16px; transition: all .12s; } + 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}; } @@ -132,58 +124,54 @@ const css = ` .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-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: 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: 44px; 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%; + .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: 32px; height: 32px; border-radius: 50%; border: 1px solid ${T.border}; - background: ${T.panel}; color: ${T.textPrimary}; font-size: 15px; font-weight: 700; + .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: 12px; justify-content: center; padding: 8px 0; } + .param-knobs { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; padding: 4px 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; } + /* ── 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 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 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: 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; } + 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 = 52, color = T.amber, min = 0, max = 100 }) { +function Knob({ label, value = 50, onChange, size = 48, color = T.amber, min = 0, max = 100 }) { const startRef = useRef(null); const norm = (value - min) / (max - min); const angle = -140 + norm * 280; @@ -200,121 +188,76 @@ function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0 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 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; + 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 ( -
-
- - - {norm > 0 && } +
+
+ + + {norm>0&&} -
-
+
+
-
{value}
+
{value}
{label}
); } -// ── Param Slider (big touch-friendly) ───────────────────────── -function ParamSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max = 100, color = T.amber }) { - const trackRef = useRef(null); - const dragRef = useRef(false); - const lastValRef = useRef(value); - const onChangeRef = useRef(onChange); - const onChangeEndRef = useRef(onChangeEnd); - const pct = ((value - min) / (max - min)) * 100; +// ── 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]); + 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]); + 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]); + 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); - }; + const handleStart=(e)=>{e.preventDefault();const v=valFromEvent(e);lastValRef.current=v;dragRef.current=true;onChangeRef.current?.(v);}; + const quick=(delta)=>(e)=>{e.preventDefault();e.stopPropagation();const next=Math.max(min,Math.min(max,value+delta));onChangeRef.current?.(next);onChangeEndRef.current?.(next);}; return (
- {label} -
+ {label} +
- {value} + {value}
-
+
-
-
+
+
@@ -322,433 +265,272 @@ function ParamSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max = } // ── 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(); - +function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView }) { + const defs=getBlockParams(block.type);const color=getBlockColor(block.type); return ( -
+
- - {blockName} - - + {block.name||block.type||''} +
- {viewMode === 'slider' ? ( - defs.map(p => ( - onParamChange?.(p.key, v)} - onChangeEnd={v => onParamChangeEnd?.(p.key, v)} - /> - )) - ) : ( -
- {defs.map(p => ( - { - onParamChange?.(p.key, v); - onParamChangeEnd?.(p.key, v); - }} - /> - ))} -
+ {viewMode==='slider'?defs.map(p=>( + onParamChange?.(p.key,v)} onChangeEnd={v=>onParamChangeEnd?.(p.key,v)}/> + )):( +
{defs.map(p=>( + {onParamChange?.(p.key,v);onParamChangeEnd?.(p.key,v);}}/> + ))}
)}
); } // ── VU Meter ────────────────────────────────────────────────── -function VUMeter({ level = 0, height = 28, vertical = false }) { - const segs = 12; - const active = Math.round((level / 100) * segs); - const segColor = (i) => { - if (i >= 10) return T.red; - if (i >= 8) return "#E8C030"; - return T.green; - }; - if (vertical) return ( -
- {Array.from({ length: segs }).map((_, i) => ( -
- ))} -
- ); - return ( -
- {Array.from({ length: segs }).map((_, i) => ( -
- ))} -
- ); +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(
+ {Array.from({length:segs}).map((_,i)=>(
))}
); + return(
+ {Array.from({length:segs}).map((_,i)=>(
))}
); } -// ── Scribble Info Bar ────────────────────────────────────── -function ScribbleBar({ state }) { - const vol = state.master_volume != null ? Math.round(state.master_volume * 100) : 0; - return ( -
- {[ - { label: "Preset", value: state.current_preset?.name || "—" }, - { label: "Routing", value: (state.routing_mode || "MONO").toUpperCase() }, - { label: "Volume", value: `${vol}%` }, - { label: "CPU", value: state.cpu_percent != null ? `${state.cpu_percent}%` : "—" }, - ].map(s => ( -
-
{s.label}
-
{s.value}
-
- ))} -
- ); +// ── Captures Screen ────────────────────────────────────────── +function CapturesScreen({onClose}){ + const[tab,setTab]=useState("models");const[query,setQuery]=useState(""); + const[results,setResults]=useState(null);const[models,setModels]=useState([]);const[loading,setLoading]=useState(false); + useEffect(()=>{API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});},[]); + const doSearch=async()=>{if(!query.trim())return;setLoading(true);try{const data=tab==="models"?await API.searchModels(query):await API.searchIrs(query);setResults(data.results||[]);}catch{setResults([]);}setLoading(false);}; + const install=async(item)=>{try{if(tab==="models"){await API.installModel(item.download_url,item.name);}else{await API.installIr(item.download_url,item.name);}API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});}catch(e){alert("Install failed: "+e.message);}}; + return(
+
Downloads
+
+ +
+
+
+ 🔍 + setQuery(e.target.value)}onKeyDown={e=>e.key==="Enter"&&doSearch()}style={{flex:1,background:"none",border:"none",color:T.textPrimary,fontSize:12,outline:"none"}}/>
+
+
+ {results===null&&models.length>0&&(<>
Installed Models
+ {models.map((m,i)=>(
+
🎛
+
{m.name}
{m.architecture}·{m.size_mb}MB{m.loaded?"· CURRENT":""}
))})} + {results!==null&&results.length===0&&
No results found
} + {results!==null&&results.map((r,i)=>(
+
{r.name}
{r.author}·{r.size_display}
+
))}
); } -// ── 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); +// ── MOCK DATA (used when pedal not connected) ──────────────── +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}}, +]; - useEffect(() => { - API.listModels().then(d => setModels(d.models || [])).catch(()=>{}); - }, []); +const MOCK_PRESETS=[ + {num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Modern Hi-Gain"}, + {num:3,bank:1,name:"Clean Chime"},{num:4,bank:1,name:"Blues 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"}, +]; - 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); - }; +// ── 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 install = async (item) => { - try { - if (tab === "models") { - await API.installModel(item.download_url, item.name); - } else { - await API.installIr(item.download_url, item.name); + // ── Poll pedal state from API ── + const loadState=useCallback(async()=>{ + try{ + const s=await API.getState(); + setState(s); + if(s.input_level!=null)setVuLevel(s.input_level); + // If API has preset info, use it + if(s.current_preset?.name){ + setCurrentPreset(s.current_preset.program!=null?s.current_preset.program+1:currentPreset); } - API.listModels().then(d => setModels(d.models || [])).catch(()=>{}); - } catch(e) { alert("Install failed: " + e.message); } - }; + // If API has blocks, use them + if(s.blocks&&s.blocks.length>0){setBlocks(s.blocks);} + }catch{} + },[currentPreset]); - return ( -
-
-
Downloads
- -
-
-
- - -
-
-
-
- 🔍 - setQuery(e.target.value)} - onKeyDown={e => e.key === "Enter" && doSearch()} - style={{ flex: 1, background: "none", border: "none", color: T.textPrimary, fontSize: 12, outline: "none" }} /> -
- -
-
- {results === null && models.length > 0 && ( - <> -
Installed Models
- {models.map((m, i) => ( -
-
🎛
-
-
{m.name}
-
- {m.architecture} · {m.size_mb}MB {m.loaded ? "· CURRENT" : ""} -
-
-
- ))} - - )} - {results !== null && results.length === 0 && ( -
No results found
- )} - {results !== null && results.map((r, i) => ( -
-
-
{r.name}
-
- {r.author} · {r.size_display} -
+ 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)=>{ + setLoadingPreset(true); + try{ + const p=await API.loadPreset(bank,program); + if(p?.chain)setBlocks(p.chain.map((b,i)=>({...b,id:b.id||(`block_${i}`),bypassed:!b.enabled,params:b.params||{}}))); + setCurrentPreset(program); + setSelectedBlockId(null); + }catch(e){console.warn("Preset load failed:",e);} + setLoadingPreset(false); + },[]); + + const handlePresetClick=useCallback(async(num)=>{ + // Find the preset by number + const p=presets.find(pr=>pr.num===num); + if(!p)return; + setCurrentPreset(num); + await handleLoadPreset(p.bank||1,num-1); + },[presets,handleLoadPreset]); + + // ── Blocks ── + const selectedBlock=useMemo(()=>blocks.find(b=>b.id===selectedBlockId),[blocks,selectedBlockId]); + + 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[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;});},[]); + const handleAddBlock=useCallback(()=>{const id="block_"+Date.now();setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);},[]); + const handleRemoveBlock=useCallback((id)=>{setBlocks(prev=>prev.filter(b=>b.id!==id));if(selectedBlockId===id)setSelectedBlockId(null);},[selectedBlockId]); + + // ── 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); + // Load first preset of new bank + const bp=presets.filter(p=>p.bank===next); + if(bp.length>0)handlePresetClick(bp[0].num); + },[currentBank,presets,handlePresetClick]); + + const handleBankDown=useCallback(()=>{ + const next=Math.max(currentBank-1,1); + setCurrentBank(next); + const bp=presets.filter(p=>p.bank===next); + if(bp.length>0)handlePresetClick(bp[0].num); + },[currentBank,presets,handlePresetClick]); + + const handleGlobalBypass=useCallback(()=>{ + setGlobalBypass(p=>!p); + API.bypassToggle().catch(()=>{}); + },[]); + + // ── Render ── + return( + <> +
+ + {view==="main"&&( + <> + {/* ══════ STATUS BAR ══════ */} +
+
+
+ {state.connected?"CONNECTED":"OFFLINE"} +
+ + {state.current_preset?.name||(presets.find(p=>p.num===currentPreset)?.name||"—")} + + {loadingPreset&&} +
+
+
+ + CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"} +
+
+ +
-
- ))} -
-
- ); -} -// ── 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 }, - ]; + {/* ══════ MAIN AREA: Preset sidebar + Content ══════ */} +
- return ( -
-
-
Presets
- -
-
-
- {["All", "Live", "Studio", "Practice"].map(t => ( - - ))} -
- {samplePresets.map((p, i) => ( -
{ setActiveIdx(i); onLoadPreset?.(p.num); }}> -
{p.num}
-
-
{p.name}
-
- {p.tags.map(t => ( - {t} + {/* ─── Preset Sidebar ─── */} +
+ {/* Bank header */} +
+
Bank {bankLetter}
+
Programs
+
+ {/* Preset list */} +
+ {currentBankPresets.map(p=>( +
handlePresetClick(p.num)}> +
0{p.num}
+
{p.name}
+
+
))}
- {activeIdx === i && } -
- ))} -
-
- ); -} -// ── App Shell — Helix Stadium Layout ────────────────────────── -const MOCK_BLOCKS = [ - { id: "gate", type: "gate", name: "Noise Gate", bypassed: false, params: { threshold: 40, release: 25, depth: 70, mix: 100 }}, - { id: "drive", type: "overdrive", name: "OD-808", bypassed: false, params: { drive: 65, tone: 55, level: 70 }}, - { id: "dist", type: "distortion", name: "RAT", bypassed: true, params: { drive: 75, filter: 50, level: 65 }}, - { id: "mod", type: "phaser", name: "Phase 90", bypassed: false, params: { rate: 35, depth: 60, feedback: 40, mix: 50 }}, - { id: "delay", type: "delay", name: "Digital Delay", bypassed: false, params: { time: 45, feedback: 25, mix: 40 }}, - { id: "reverb", type: "reverb", name: "Hall Reverb", bypassed: false, params: { decay: 60, mix: 35, tone: 50 }}, -]; + {/* ─── Main Content Area ─── */} +
-const MOCK_PRESETS = [ - { num: 1, name: "Plexi Crunch" }, - { num: 2, name: "Modern Hi-Gain" }, - { num: 3, name: "Clean Chime" }, - { num: 4, name: "Blues Drive" }, -]; - -export default function App() { - // ── State ── - const [view, setView] = useState("main"); // "main" | "captures" | "presets" - const [state, setState] = useState({ connected: false, master_volume: 0.72, routing_mode: "mono", cpu_percent: 0 }); - const [blocks, setBlocks] = useState(MOCK_BLOCKS); - const [selectedBlockId, setSelectedBlockId] = useState("gate"); - const [blockParams, setBlockParams] = useState({}); - const [paramView, setParamView] = useState("slider"); - const [vuLevel, setVuLevel] = useState(0); - const [currentPreset, setCurrentPreset] = useState(1); - const [currentBank, setCurrentBank] = useState(1); - - // Poll pedal state - useEffect(() => { - const poll = async () => { - try { - const s = await API.getState(); - setState(s); - if (s.cpu_percent != null) setState(prev => ({ ...prev, cpu_percent: s.cpu_percent })); - if (s.input_level != null) setVuLevel(s.input_level); - } catch {} - }; - poll(); - const id = setInterval(poll, 3000); - return () => clearInterval(id); - }, []); - - // Mock VU animation when no real data - useEffect(() => { - const id = setInterval(() => { - setVuLevel(prev => Math.max(0, Math.min(100, prev + (Math.random() - 0.45) * 8))); - }, 150); - return () => clearInterval(id); - }, []); - - // Selected block - const selectedBlock = useMemo(() => blocks.find(b => b.id === selectedBlockId), [blocks, selectedBlockId]); - - // Initialize params when block is selected - useEffect(() => { - if (selectedBlock && blockParams[selectedBlockId] == null) { - const defs = getBlockParams(selectedBlock.type); - const init = {}; - defs.forEach(p => { - init[p.key] = (selectedBlock.params?.[p.key] != null) ? selectedBlock.params[p.key] : p.def; - }); - setBlockParams(prev => ({ ...prev, [selectedBlockId]: init })); - } - }, [selectedBlockId, selectedBlock, blockParams]); - - const handleSelectBlock = useCallback((id) => { - setSelectedBlockId(prev => prev === id ? null : id); - }, []); - - const handleToggleBlock = useCallback((id) => { - setBlocks(prev => prev.map(b => b.id === id ? { ...b, bypassed: !b.bypassed } : b)); - API.toggleBlock(id, false).catch(() => {}); - }, []); - - const handleParamChange = useCallback((id, key, value) => { - setBlockParams(prev => ({ ...prev, [id]: { ...(prev[id] || {}), [key]: value } })); - }, []); - - const handleParamChangeEnd = useCallback((id, key, value) => { - API.updateBlock(id, { [key]: value }).catch(() => {}); - }, []); - - const handleReorder = useCallback((fromIdx, toIdx) => { - setBlocks(prev => { - const arr = [...prev]; - const [moved] = arr.splice(fromIdx, 1); - arr.splice(toIdx, 0, moved); - return arr; - }); - }, []); - - const handleAddBlock = useCallback(() => { - const id = "block_" + Date.now(); - setBlocks(prev => [...prev, { id, type: "overdrive", name: "New Drive", bypassed: false, params: { drive: 30, tone: 50, level: 70 } }]); - }, []); - - const handleRemoveBlock = useCallback((id) => { - setBlocks(prev => prev.filter(b => b.id !== id)); - if (selectedBlockId === id) setSelectedBlockId(null); - }, [selectedBlockId]); - - const handleBankUp = useCallback(() => { - setCurrentBank(prev => Math.min(prev + 1, 8)); - }, []); - - const handleBankDown = useCallback(() => { - setCurrentBank(prev => Math.max(prev - 1, 1)); - }, []); - - // Convert bank number to letter (1=A, 2=B, etc.) - const bankLetter = String.fromCharCode(64 + currentBank); - - // ── Render ── - return ( - <> - -
- {view === "main" && ( - <> - {/* ── Status Bar ── */} -
-
-
- - {state.connected ? "CONNECTED" : "OFFLINE"} - - - {state.current_preset?.name || "Plexi Crunch"} - + {/* Scribble info bar */} +
+ {[ + {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=>( +
+
{s.label}
+
{s.value}
+
+ ))}
-
- - - -
-
- {/* ── Scribble Info Bar ── */} - - - {/* ── Preset Strip ── */} -
- {MOCK_PRESETS.map(p => ( -
setCurrentPreset(p.num)}> -
{p.name}
-
0{p.num}
-
- ))} -
- - {/* ── Block Chain + Params ── */} -
-
+ {/* Block Chain */} +
- {/* ── Parameter Panel ── */} - {selectedBlock && blockParams[selectedBlockId] && ( + {/* Parameter Panel or placeholder */} + {selectedBlock&&blockParams[selectedBlockId]?( setParamView(p => p === 'slider' ? 'knob' : 'slider')} - onParamChange={(key, value) => handleParamChange(selectedBlockId, key, value)} - onParamChangeEnd={(key, value) => handleParamChangeEnd(selectedBlockId, key, value)} + onToggleView={()=>setParamView(p=>p==='slider'?'knob':'slider')} + onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)} + onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)} /> - )} - {!selectedBlock && ( -
- Tap a block to edit its parameters + ):( +
+ Tap a block to edit parameters
)}
+
- {/* ── Footswitch Bar ── */} - setState(prev => ({ ...prev, bypass: !prev.bypass }))} - globalBypass={state.bypass} - /> - - )} - - {/* ── Overlay screens ── */} - {view === "captures" && ( - setView("main")} /> - )} - {view === "presets" && ( - setView("main")} - onLoadPreset={(num) => setCurrentPreset(num)} + {/* ══════ FOOTSWITCH BAR ══════ */} + - )} -
- + + )} + + {/* ── Overlays ── */} + {view==="captures"&&setView("main")}/>} + {view==="presets"&&( +
+
Presets
+
+ {presets.map((p,i)=>( +
{handlePresetClick(p.num);setView("main");}}> +
{p.num}
+
{p.name}
Bank {p.bank||1}·Program {p.num-1}
+ {p.num===currentPreset&&} +
+ ))} +
+
+ )} +
); }