5c51b3db28
Bank 7 — Bass: Ampeg PF20T, Aguilar Tone Hammer 500, GK 800RB (Gallien-Krueger), Aguilar DB751 Bank 7 — Orange: Orange Rocker 30 Dirty + Natural Bank 7 — Others: Fender Bassman dimed, Hiwatt DR103 Bright + Cornish, Supro Black Magick M3 All SlimmableContainer v0.7.0, ~287-295 KB, verified JSON. Also fixed Bank 2 Tone3000 storage keys to include .nam extension (Tone3000 changed URL format). Inventory updated: 54 → 64 total models.
2118 lines
96 KiB
React
2118 lines
96 KiB
React
import { useState, useRef, useEffect, useCallback } from "react";
|
|
|
|
// ── 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",
|
|
};
|
|
|
|
const API = window.location.origin;
|
|
const WS_URL = API.replace(/^http/, 'ws') + '/ws';
|
|
|
|
// ── Channel state (module-level, synced from localStorage) ─────
|
|
let _channel = 'guitar';
|
|
try {
|
|
const saved = localStorage.getItem('pedal_channel');
|
|
if (saved === 'guitar' || saved === 'bass') _channel = saved;
|
|
} catch {}
|
|
|
|
export function getChannel() { return _channel; }
|
|
export function setChannel(ch) { _channel = ch; }
|
|
|
|
function _ch(url) {
|
|
const c = _channel;
|
|
if (!c) return url;
|
|
return url + (url.includes('?') ? '&' : '?') + 'channel=' + encodeURIComponent(c);
|
|
}
|
|
|
|
// ── API helpers ────────────────────────────────────────────────
|
|
async function apiGet(path) {
|
|
const r = await fetch(`${API}${_ch(path)}`);
|
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
async function apiPost(path, data) {
|
|
const r = await fetch(`${API}${_ch(path)}`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: data != null ? JSON.stringify(data) : undefined,
|
|
});
|
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
async function apiPut(path, data) {
|
|
const r = await fetch(`${API}${_ch(path)}`, {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
async function apiDelete(path) {
|
|
const r = await fetch(`${API}${_ch(path)}`, { method: 'DELETE' });
|
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
async function apiUpload(path, file) {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const r = await fetch(`${API}${_ch(path)}`, { method: 'POST', body: fd });
|
|
if (!r.ok) throw new Error(`${path} => ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
// ── usePedalState hook ─────────────────────────────────────────
|
|
function usePedalState() {
|
|
const [state, setState] = useState(null);
|
|
const [connected, setConnected] = useState(false);
|
|
const wsRef = useRef(null);
|
|
const pollRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
// Fetch initial state
|
|
apiGet('/api/state').then(s => { setState(s); setConnected(true); }).catch(() => { setConnected(false); });
|
|
|
|
// WebSocket for real-time updates
|
|
let reconnectTimer;
|
|
function connect() {
|
|
try {
|
|
const ws = new WebSocket(WS_URL);
|
|
ws.onmessage = (e) => {
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'connected' || msg.type === 'state') {
|
|
setState(msg.state);
|
|
setConnected(true);
|
|
}
|
|
if (msg.type === 'preset_changed' && msg.state) setState(msg.state);
|
|
if (msg.type === 'bypass_changed') setState(prev => prev ? { ...prev, bypass: msg.bypass } : prev);
|
|
if (msg.type === 'tuner_changed') setState(prev => prev ? { ...prev, tuner_enabled: msg.tuner_enabled } : prev);
|
|
if (msg.type === 'routing_changed') setState(prev => prev ? { ...prev, routing_mode: msg.routing_mode, routing_breakpoint: msg.routing_breakpoint } : prev);
|
|
if (msg.type === 'channel_mode_changed') setState(prev => prev ? { ...prev, channel_mode: msg.channel_mode } : prev);
|
|
if (msg.type === 'model_loaded' || msg.type === 'ir_loaded') {
|
|
apiGet('/api/state').then(s => setState(s));
|
|
}
|
|
if (msg.type === 'snapshot_recalled' && msg.state) {
|
|
setState(msg.state);
|
|
}
|
|
if (msg.type === 'snapshot_saved') {
|
|
// Refresh state to pick up current_snapshot
|
|
apiGet('/api/state').then(s => setState(s));
|
|
}
|
|
} catch (e) { /* ignore parse errors */ }
|
|
};
|
|
ws.onclose = () => {
|
|
setConnected(false);
|
|
reconnectTimer = setTimeout(connect, 3000);
|
|
};
|
|
ws.onerror = () => ws.close();
|
|
wsRef.current = ws;
|
|
} catch (e) { reconnectTimer = setTimeout(connect, 3000); }
|
|
}
|
|
connect();
|
|
|
|
// Polling fallback every 5s
|
|
pollRef.current = setInterval(() => {
|
|
apiGet('/api/state').then(s => { setState(s); setConnected(true); }).catch(() => {});
|
|
}, 5000);
|
|
|
|
return () => {
|
|
clearInterval(pollRef.current);
|
|
clearTimeout(reconnectTimer);
|
|
if (wsRef.current) wsRef.current.close();
|
|
};
|
|
}, []);
|
|
|
|
const refresh = useCallback(() => {
|
|
apiGet('/api/state').then(s => { setState(s); setConnected(true); }).catch(() => {});
|
|
}, []);
|
|
|
|
return { state, connected, refresh };
|
|
}
|
|
|
|
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; }
|
|
body { background: ${T.bg}; color: ${T.textPrimary}; font-family: 'Inter', sans-serif; user-select: none; }
|
|
: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; }
|
|
|
|
input, textarea, select { font-family: inherit; }
|
|
|
|
.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-ring { position: absolute; inset: -5px; border-radius: 50%; }
|
|
.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 { 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; }
|
|
|
|
.vu-bar { display: flex; gap: 2px; align-items: flex-end; height: 100%; }
|
|
.vu-seg { width: 4px; border-radius: 1px; transition: opacity .05s; }
|
|
|
|
.fader-track { width: 4px; border-radius: 2px; background: ${T.border}; position: relative; cursor: pointer; touch-action: none; }
|
|
.fader-fill { width: 100%; border-radius: 2px; position: absolute; bottom: 0; transition: height .05s; }
|
|
.fader-thumb { width: 28px; height: 10px; border-radius: 3px; position: absolute; left: 50%; transform: translateX(-50%);
|
|
background: linear-gradient(180deg, #3A3A4A, #252530);
|
|
border: 1px solid #4A4A5A; box-shadow: 0 2px 4px rgba(0,0,0,.5);
|
|
cursor: grab; touch-action: none; }
|
|
.fader-thumb::after { content: ''; position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
|
|
width: 16px; height: 2px; border-radius: 1px; background: rgba(255,255,255,.15);
|
|
box-shadow: 0 3px 0 rgba(255,255,255,.15), 0 -3px 0 rgba(255,255,255,.15); }
|
|
|
|
.tabbar { display: flex; background: ${T.panel}; border-bottom: 1px solid ${T.border}; flex-shrink: 0; }
|
|
.tab { flex: 1; padding: 10px 4px; font-size: 10px; font-weight: 500; letter-spacing: .04em;
|
|
text-align: center; text-transform: uppercase; color: ${T.textDim};
|
|
cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
|
|
.tab.active { color: ${T.amber}; border-bottom-color: ${T.amber}; }
|
|
|
|
.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; }
|
|
|
|
.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}; }
|
|
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
|
|
|
.fx-block { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 10px;
|
|
overflow: hidden; transition: border-color .15s; }
|
|
.fx-block.active { border-color: ${T.amber}; }
|
|
.fx-block.bypassed { opacity: .45; }
|
|
.fx-header { display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
|
border-bottom: 1px solid ${T.border}; background: ${T.surface}; cursor: pointer; }
|
|
.fx-body { padding: 12px; display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
|
|
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.led-on { background: ${T.green}; box-shadow: 0 0 8px ${T.green}; }
|
|
.led-off { background: ${T.textDim}; }
|
|
|
|
.waveform-wrap { position: relative; overflow: hidden; border-radius: 6px; background: #0F0F14; }
|
|
.waveform-canvas { display: block; }
|
|
.playhead { position: absolute; top: 0; bottom: 0; width: 1.5px; background: ${T.amber};
|
|
box-shadow: 0 0 8px ${T.amber}; pointer-events: none; transition: left .08s linear; }
|
|
|
|
.nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
|
|
background: rgba(58,123,168,.15); border: 1px solid rgba(58,123,168,.35); border-radius: 5px;
|
|
font-family: 'JetBrains Mono', monospace; font-size: 11px; color: ${T.blue}; }
|
|
|
|
.section-label { font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
|
|
color: ${T.textDim}; font-weight: 600; margin-bottom: 10px; }
|
|
|
|
.preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px;
|
|
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); }
|
|
|
|
.transport-btn { width: 44px; height: 44px; border-radius: 50%; display: flex;
|
|
align-items: center; justify-content: center; font-size: 16px; cursor: pointer;
|
|
border: 2px solid ${T.border}; background: ${T.surface}; transition: all .12s; }
|
|
.transport-btn:active { transform: scale(.92); }
|
|
.transport-btn.rec { border-color: ${T.red}; background: rgba(200,64,64,.15); }
|
|
.transport-btn.play { border-color: ${T.green}; background: rgba(58,184,122,.15); }
|
|
|
|
.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 { padding: 12px 14px 8px; display: flex; align-items: center;
|
|
justify-content: space-between; border-bottom: 1px solid ${T.border}; flex-shrink: 0; }
|
|
.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: 12px; display: flex;
|
|
flex-direction: column; gap: 12px; }
|
|
|
|
/* Settings lists */
|
|
.setting-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid ${T.border}; }
|
|
.setting-label { font-size: 13px; color: ${T.textPrimary}; }
|
|
.setting-desc { font-size: 11px; color: ${T.textDim}; margin-top: 2px; }
|
|
.switch { width: 40px; height: 22px; border-radius: 11px; background: ${T.border}; cursor: pointer; position: relative; transition: background .2s; flex-shrink: 0; }
|
|
.switch.on { background: ${T.green}; }
|
|
.switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: white; transition: transform .15s; }
|
|
.switch.on::after { transform: translateX(18px); }
|
|
|
|
.search-bar { display: flex; background: ${T.surface}; border-radius: 7px; border: 1px solid ${T.border};
|
|
padding: 8px 12px; gap: 8px; align-items: center; }
|
|
.search-bar input { background: none; border: none; outline: none; color: ${T.textPrimary}; font-size: 13px; flex: 1; }
|
|
.search-bar input::placeholder { color: ${T.textDim}; }
|
|
|
|
.file-drop { border: 2px dashed ${T.border}; border-radius: 8px; padding: 20px; text-align: center;
|
|
cursor: pointer; transition: all .15s; font-size: 13px; color: ${T.textDim}; }
|
|
.file-drop:hover, .file-drop.dragover { border-color: ${T.amber}; color: ${T.amber}; }
|
|
|
|
.loader { width: 20px; height: 20px; border: 2px solid ${T.border}; border-top-color: ${T.amber};
|
|
border-radius: 50%; animation: spin .6s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.slide-panel { position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 100;
|
|
display: flex; align-items: flex-end; justify-content: center; }
|
|
.slide-panel-inner { background: ${T.bg}; max-width: 440px; width: 100%; max-height: 85vh;
|
|
border-radius: 16px 16px 0 0; overflow: hidden; display: flex; flex-direction: column; }
|
|
.slide-panel-handle { width: 36px; height: 4px; border-radius: 2px; background: ${T.border};
|
|
margin: 8px auto; flex-shrink: 0; }
|
|
|
|
/* Snapshot slots */
|
|
.snap-grid { display: flex; gap: 6px; overflow-x: auto; padding: 4px 0; }
|
|
.snap-slot { min-width: 60px; flex-shrink: 0; border-radius: 8px; border: 1.5px solid ${T.border};
|
|
background: ${T.surface}; cursor: pointer; text-align: center; padding: 8px 4px;
|
|
transition: all .12s; touch-action: manipulation; position: relative; }
|
|
.snap-slot:active { transform: scale(.95); }
|
|
.snap-slot.filled { border-color: ${T.amberDim}; }
|
|
.snap-slot.active { border-color: ${T.amber}; background: rgba(232,160,48,.08); }
|
|
.snap-slot .snap-num { font-family: 'JetBrains Mono', monospace; font-size: 16px;
|
|
font-weight: 700; color: ${T.textDim}; }
|
|
.snap-slot.filled .snap-num { color: ${T.amber}; }
|
|
.snap-slot.active .snap-num { color: ${T.amber}; text-shadow: 0 0 8px ${T.amber}; }
|
|
.snap-slot .snap-name { font-size: 9px; color: ${T.textDim}; margin-top: 3px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.snap-slot.filled .snap-name { color: ${T.textSec}; }
|
|
.snap-slot.active .snap-name { color: ${T.amber}; }
|
|
.snap-slot .snap-empty { font-size: 9px; color: ${T.textDim}; margin-top: 3px; font-style: italic; }
|
|
.snap-slot .snap-save-indicator { position: absolute; top: -3px; right: -3px;
|
|
width: 10px; height: 10px; border-radius: 50%; background: ${T.green};
|
|
box-shadow: 0 0 6px ${T.green}; animation: snap-pop .3s ease-out; }
|
|
@keyframes snap-pop { 0% { transform: scale(2); opacity: 0; } 100% { transform: scale(1); opacity: 1; } }
|
|
.snap-name-input { background: ${T.surface}; border: 1px solid ${T.amber}; border-radius: 4px;
|
|
padding: 2px 6px; color: ${T.amber}; font-size: 10px; text-align: center; outline: none;
|
|
font-family: 'Inter', sans-serif; width: 100%; }
|
|
.snap-header-btn { cursor: pointer; border: none; background: none; color: ${T.textSec};
|
|
font-size: 16px; padding: 4px 6px; border-radius: 6px; transition: all .12s;
|
|
display: flex; align-items: center; gap: 4px; }
|
|
.snap-header-btn:hover { color: ${T.amber}; background: rgba(232,160,48,.1); }
|
|
.snap-header-btn.active { color: ${T.amber}; background: rgba(232,160,48,.15); }
|
|
.snap-header-num { font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
|
font-weight: 700; color: ${T.amber}; min-width: 16px; text-align: center; }
|
|
`;
|
|
|
|
// ── Knob ──────────────────────────────────────────────────────
|
|
function Knob({ label, value = 50, onChange, size = 52, color = T.amber, min = 0, max = 100 }) {
|
|
const ref = useRef(null);
|
|
const startRef = useRef(null);
|
|
const norm = (value - min) / (max - min);
|
|
const angle = -140 + norm * 280;
|
|
|
|
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 endA = (angle - 90) * (Math.PI / 180);
|
|
const ex = cx + r * Math.cos(endA), ey = cy + r * Math.sin(endA);
|
|
|
|
return (
|
|
<div className="knob-wrap" style={{ width: size + 12 }}>
|
|
<div className="knob" style={{ width: size, height: size }} onMouseDown={handleStart} onTouchStart={handleStart} ref={ref}>
|
|
<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 ${cx} ${cy - r} A ${r} ${r} 0 ${norm > 0.5 ? 1 : 0} 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: size * 0.38, bottom: size / 2 - 2,
|
|
transform: `translateX(-50%) rotate(${angle}deg)`, transformOrigin: `50% ${size * 0.38}px` }} />
|
|
</div>
|
|
</div>
|
|
<div className="knob-value mono" style={{ color }}>{value}</div>
|
|
<div className="knob-label">{label}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── VU Meter ──────────────────────────────────────────────────
|
|
function VUMeter({ level = 0, height = 60, vertical = true }) {
|
|
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: 2, height, alignItems: "center" }}>
|
|
{Array.from({ length: segs }).map((_, i) => (
|
|
<div key={i} style={{ width: 6, height: (height - segs * 2) / 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 }} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Waveform ──────────────────────────────────────────────────
|
|
function Waveform({ width, height, color = T.amber, playPos = 0, seed = 1 }) {
|
|
const canvasRef = useRef(null);
|
|
useEffect(() => {
|
|
const c = canvasRef.current; if (!c) return;
|
|
const ctx = c.getContext("2d");
|
|
ctx.clearRect(0, 0, width, height);
|
|
const mid = height / 2;
|
|
ctx.strokeStyle = color; ctx.lineWidth = 1;
|
|
ctx.shadowColor = color; ctx.shadowBlur = 2;
|
|
ctx.beginPath();
|
|
for (let x = 0; x < width; x++) {
|
|
const t = x / width;
|
|
const amp = (Math.sin(t * 31 * seed + seed) * 0.4 + Math.sin(t * 97 * seed) * 0.3
|
|
+ Math.sin(t * 17 * seed + 2) * 0.2 + Math.random() * 0.1);
|
|
const h = Math.abs(amp) * (mid * 0.85);
|
|
ctx.moveTo(x, mid - h); ctx.lineTo(x, mid + h);
|
|
}
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 0.12;
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath(); ctx.moveTo(0, mid);
|
|
for (let x = 0; x < width; x++) {
|
|
const t = x / width;
|
|
const amp = (Math.sin(t * 31 * seed + seed) * 0.4 + Math.sin(t * 97 * seed) * 0.3
|
|
+ Math.sin(t * 17 * seed + 2) * 0.2);
|
|
ctx.lineTo(x, mid - Math.abs(amp) * mid * 0.85);
|
|
}
|
|
ctx.lineTo(width, mid); ctx.closePath(); ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
}, [width, height, color, seed]);
|
|
|
|
return (
|
|
<div className="waveform-wrap" style={{ width, height }}>
|
|
<canvas ref={canvasRef} width={width} height={height} className="waveform-canvas" />
|
|
<div className="playhead" style={{ left: `${playPos}%` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Fader ──────────────────────────────────────────────────────
|
|
function Fader({ value = 75, onChange, height = 120, label, color = T.amber }) {
|
|
const trackRef = useRef(null);
|
|
const handleStart = (e) => {
|
|
e.preventDefault();
|
|
const move = (ev) => {
|
|
const track = trackRef.current.getBoundingClientRect();
|
|
const cy = ev.touches ? ev.touches[0].clientY : ev.clientY;
|
|
const pct = 1 - Math.max(0, Math.min(1, (cy - track.top) / track.height));
|
|
onChange?.(Math.round(pct * 100));
|
|
};
|
|
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 thumbBottom = (value / 100) * (height - 10);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
|
<div style={{ position: "relative", height, display: "flex", justifyContent: "center" }}>
|
|
<div className="fader-track" style={{ height }} ref={trackRef} onMouseDown={handleStart} onTouchStart={handleStart}>
|
|
<div className="fader-fill" style={{ height: `${value}%`, background: color, opacity: 0.4 }} />
|
|
<div className="fader-thumb" style={{ bottom: thumbBottom }} onMouseDown={handleStart} onTouchStart={handleStart} />
|
|
</div>
|
|
</div>
|
|
<div className="knob-value mono" style={{ color }}>{value}</div>
|
|
<div className="knob-label">{label}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── FX Block ──────────────────────────────────────────────────
|
|
function FXBlock({ title, type, active = true, bypassed = false, params = [], onToggle }) {
|
|
const [open, setOpen] = useState(true);
|
|
const [vals, setVals] = useState(params.map(p => p.default ?? p.default ?? 50));
|
|
return (
|
|
<div className={`fx-block ${active ? "active" : ""} ${bypassed ? "bypassed" : ""}`}>
|
|
<div className="fx-header" onClick={() => setOpen(o => !o)}>
|
|
<div className={`bypass-led ${!bypassed ? "led-on" : "led-off"}`}
|
|
onClick={(e) => { e.stopPropagation(); onToggle?.(); }} />
|
|
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{title}</span>
|
|
<span className="badge badge-blue" style={{ fontSize: 10 }}>{type}</span>
|
|
<span style={{ color: T.textDim, fontSize: 18, marginLeft: 6 }}>{open ? "▾" : "▸"}</span>
|
|
</div>
|
|
{open && (
|
|
<div className="fx-body">
|
|
{params.map((p, i) => (
|
|
<Knob key={p.key || p.name} label={p.name} value={vals[i]} size={44}
|
|
onChange={v => setVals(prev => prev.map((x, j) => j === i ? v : x))}
|
|
min={p.min ?? 0} max={p.max ?? 100} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Signal Arrow Connector ──────────────────────────────────
|
|
function SignalArrow({ active = true, variant = 'right', animated = true }) {
|
|
const color = active ? T.green : T.textDim;
|
|
|
|
if (variant === 'right') {
|
|
return (
|
|
<svg width="22" height="14" viewBox="0 0 22 14" style={{ flexShrink: 0, display: 'block' }}>
|
|
<line x1="2" y1="7" x2="14" y2="7" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
|
<polygon points="12,3 20,7 12,11" fill={color} />
|
|
{active && animated && (
|
|
<circle r="1.5" fill={color} opacity="0.6">
|
|
<animate attributeName="cx" values="4;18" dur="1.2s" repeatCount="indefinite" />
|
|
</circle>
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
if (variant === 'split') {
|
|
return (
|
|
<svg width="26" height="24" viewBox="0 0 26 24" style={{ flexShrink: 0, display: 'block' }}>
|
|
<line x1="2" y1="10" x2="14" y2="10" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="8" y1="10" x2="18" y2="18" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
|
<polygon points="12,6 18,10 12,14" fill={color} />
|
|
<polygon points="16,14 21,20 19,13" fill={color} />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
if (variant === 'merge') {
|
|
return (
|
|
<svg width="26" height="24" viewBox="0 0 26 24" style={{ flexShrink: 0, display: 'block' }}>
|
|
<line x1="2" y1="4" x2="12" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="2" y1="20" x2="12" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
|
<line x1="12" y1="12" x2="14" y2="12" stroke={color} strokeWidth="2" strokeLinecap="round" />
|
|
<polygon points="12,8 18,12 12,16" fill={color} />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── Slide Panel ──────────────────────────────────────────────
|
|
function SlidePanel({ children, onClose }) {
|
|
return (
|
|
<div className="slide-panel" onClick={onClose}>
|
|
<div className="slide-panel-inner" onClick={e => e.stopPropagation()}>
|
|
<div className="slide-panel-handle" />
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SCREENS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// 1. RIG SCREEN ────────────────────────────────────────────────
|
|
function RigScreen({ state, connected, refresh }) {
|
|
const [volume, setVolume] = useState(state?.master_volume != null ? Math.round(state.master_volume * 100) : 80);
|
|
// Optimistic local states so toggles respond immediately
|
|
const [localBypass, setLocalBypass] = useState(null);
|
|
const [localTuner, setLocalTuner] = useState(null);
|
|
|
|
const bypass = localBypass ?? state?.bypass ?? false;
|
|
const tuner = localTuner ?? state?.tuner_enabled ?? false;
|
|
|
|
useEffect(() => {
|
|
if (state?.master_volume != null) setVolume(Math.round(state.master_volume * 100));
|
|
}, [state?.master_volume]);
|
|
|
|
// Sync optimistic states from server when they arrive
|
|
useEffect(() => {
|
|
if (state?.bypass !== undefined) setLocalBypass(null);
|
|
if (state?.tuner_enabled !== undefined) setLocalTuner(null);
|
|
}, [state?.bypass, state?.tuner_enabled]);
|
|
|
|
const handleVolume = async (val) => {
|
|
setVolume(val);
|
|
try {
|
|
await apiPut('/api/volume', { volume: val / 100 });
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const handleBypass = async () => {
|
|
const next = !bypass;
|
|
setLocalBypass(next);
|
|
try {
|
|
await apiPost('/api/bypass', { bypass: next });
|
|
} catch (e) { setLocalBypass(null); }
|
|
};
|
|
|
|
const handleTuner = async () => {
|
|
const next = !tuner;
|
|
setLocalTuner(next);
|
|
try {
|
|
await apiPost('/api/tuner', { enabled: next });
|
|
} catch (e) { setLocalTuner(null); }
|
|
};
|
|
|
|
// Real audio levels from the pipeline (0-100 scale, updated every poll cycle)
|
|
const inputLevel = state?.input_level ?? 0;
|
|
const outputLevel = state?.output_level ?? 0;
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div className="screen-header">
|
|
<div>
|
|
<div className="screen-title">Active Rig</div>
|
|
<div style={{ display: "flex", gap: 6, marginTop: 4, flexWrap: "wrap", alignItems: "center" }}>
|
|
{state?.current_preset ? (
|
|
<span className="nam-chip" style={{ color: T.amber, borderColor: `${T.amber}55`, background: `${T.amber}15` }}>
|
|
{state.current_preset.name}
|
|
</span>
|
|
) : (
|
|
<span style={{ fontSize: 11, color: T.textDim }}>No preset loaded</span>
|
|
)}
|
|
{connected ? (
|
|
<span className="badge badge-green">Connected</span>
|
|
) : (
|
|
<span className="badge badge-red">Offline</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
|
<VUMeter level={inputLevel} height={44} />
|
|
<VUMeter level={outputLevel} height={44} />
|
|
</div>
|
|
</div>
|
|
<div className="screen-body">
|
|
{/* Status row */}
|
|
<div className="card-sm" style={{ display: "flex", gap: 10, justifyContent: "space-around" }}>
|
|
<div style={{ textAlign: "center" }}>
|
|
<div className="section-label" style={{ marginBottom: 4 }}>Bypass</div>
|
|
<div className={`switch ${bypass ? 'on' : ''}`} onClick={handleBypass} style={{ margin: "0 auto" }} />
|
|
</div>
|
|
<div style={{ textAlign: "center" }}>
|
|
<div className="section-label" style={{ marginBottom: 4 }}>Tuner</div>
|
|
<div className={`switch ${tuner ? 'on' : ''}`} onClick={handleTuner} style={{ margin: "0 auto" }} />
|
|
</div>
|
|
<div style={{ textAlign: "center" }}>
|
|
<div className="section-label" style={{ marginBottom: 4 }}>Routing</div>
|
|
<span className="mono" style={{ fontSize: 12, color: T.amber }}>{state?.routing_mode || "mono"}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tuner active indicator */}
|
|
{tuner && (
|
|
<div className="card-sm" style={{ background: `${T.amber}15`, borderColor: T.amber, textAlign: "center", padding: "20px 16px" }}>
|
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🎵</div>
|
|
<div className="section-label" style={{ color: T.amber, marginBottom: 4, fontSize: 14 }}>TUNER ACTIVE</div>
|
|
<div className="mono" style={{ fontSize: 24, color: T.amber, letterSpacing: 4 }}>—————●—————</div>
|
|
<div style={{ fontSize: 11, color: T.textDim, marginTop: 8 }}>Tap switch again to exit tuner mode</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Volume */}
|
|
<div className="card" style={{ display: "flex", gap: 20, alignItems: "center" }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div className="section-label">Master Volume</div>
|
|
<input type="range" min={0} max={100} value={volume}
|
|
onChange={e => handleVolume(+e.target.value)}
|
|
style={{ width: "100%", accentColor: T.amber }} />
|
|
<div className="mono" style={{ fontSize: 11, color: T.amber, marginTop: 4 }}>
|
|
{volume}%
|
|
</div>
|
|
</div>
|
|
<div className="mono" style={{ fontSize: 11, color: T.textSec, textAlign: "right" }}>
|
|
{state?.nam_loaded ? "NAM: Active" : "NAM: —"}
|
|
<br />
|
|
{state?.ir_loaded ? "IR: Active" : "IR: —"}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Signal chain */}
|
|
<div className="card-sm">
|
|
<div className="section-label">Signal Chain</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4, overflowX: "auto", padding: "4px 0" }}>
|
|
{(() => {
|
|
const chain = ["IN", state?.bypass ? "BYP" : (state?.nam_loaded ? "NAM" : "AMP"), state?.ir_loaded ? "IR" : "CAB", "OUT"];
|
|
const is4CM = state?.routing_mode === '4cm';
|
|
const bp = state?.routing_breakpoint || 7;
|
|
const active = !state?.bypass;
|
|
return chain.map((s, i, arr) => (
|
|
<div key={i} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
{is4CM && s === "AMP" && (
|
|
<SignalArrow variant="split" active={active} />
|
|
)}
|
|
{is4CM && s === "CAB" && (
|
|
<SignalArrow variant="merge" active={active} />
|
|
)}
|
|
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
|
background: i === 0 || i === arr.length - 1 ? T.blueDim : (is4CM && s === "AMP" ? "rgba(58,184,122,.15)" : T.amberDim),
|
|
color: i === 0 || i === arr.length - 1 ? T.blue : (is4CM && s === "AMP" ? T.green : T.amber),
|
|
border: `1px solid ${(i === 0 || i === arr.length - 1 ? T.blue : (is4CM && s === "AMP" ? T.green : T.amber))}60` }}>
|
|
{s}
|
|
{is4CM && s === "AMP" && <span style={{ fontSize: 8, marginLeft: 4, opacity: 0.7 }}>4CM</span>}
|
|
{is4CM && s === "BYP" && <span style={{ fontSize: 8, marginLeft: 4, opacity: 0.7 }}>4CM</span>}
|
|
</div>
|
|
{is4CM && s === "AMP" && (
|
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0, gap: 1 }}>
|
|
<SignalArrow variant="split" active={active} />
|
|
<span style={{ fontSize: 7, color: T.textDim, letterSpacing: '.05em', lineHeight: '1' }}>LOOP</span>
|
|
<SignalArrow variant="merge" active={active} />
|
|
</div>
|
|
)}
|
|
{i < arr.length - 1 && !(is4CM && s === "AMP") && <SignalArrow active={active} />}
|
|
</div>
|
|
));
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* NAM + IR info */}
|
|
<div className="card-sm" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
|
<div>
|
|
<div className="section-label" style={{ marginBottom: 6 }}>NAM Model</div>
|
|
{state?.nam_model ? (
|
|
<div className="nam-chip">{state.nam_model}</div>
|
|
) : (
|
|
<div style={{ fontSize: 11, color: T.textDim }}>Not loaded</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="section-label" style={{ marginBottom: 6 }}>IR</div>
|
|
{state?.ir_name ? (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
<div className="nam-chip" style={{ color: T.green, borderColor: `${T.green}55`, background: `${T.green}15`, fontSize: 10 }}>{state.ir_name}</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ fontSize: 11, color: T.textDim }}>Not loaded</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Quick links */}
|
|
<div className="card-sm" style={{ display: "flex", justifyContent: "space-around", gap: 6 }}>
|
|
<button className="btn btn-ghost btn-sm" style={{ flex: 1, fontSize: 10 }} onClick={() => {
|
|
const tabbar = document.querySelector('.tabbar');
|
|
const tabs = tabbar?.children;
|
|
if (tabs) tabs[3]?.click(); // IRs tab index
|
|
}}>Browse IRs</button>
|
|
<button className="btn btn-ghost btn-sm" style={{ flex: 1, fontSize: 10 }} onClick={() => {
|
|
const tabbar = document.querySelector('.tabbar');
|
|
const tabs = tabbar?.children;
|
|
if (tabs) tabs[2]?.click(); // Models tab index
|
|
}}>Browse Models</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 2. FX CHAIN SCREEN ───────────────────────────────────────────
|
|
const FX_TYPE_LABELS = {
|
|
noise_gate: "Noise Gate", compressor: "Compressor", boost: "Boost",
|
|
overdrive: "Overdrive", distortion: "Distortion", fuzz: "Fuzz",
|
|
eq: "EQ", chorus: "Chorus", flanger: "Flanger", phaser: "Phaser",
|
|
tremolo: "Tremolo", vibrato: "Vibrato", delay: "Delay", reverb: "Reverb",
|
|
volume: "Volume", nam_amp: "NAM Amp", ir_cab: "IR Cab",
|
|
octaver: "Octaver", pitch_shifter: "Pitch Shifter", harmonizer: "Harmonizer",
|
|
whammy: "Whammy", detune: "Detune", ring_modulator: "Ring Mod",
|
|
auto_wah: "Auto Wah", envelope_filter: "Env Filter", rotary_speaker: "Rotary",
|
|
uni_vibe: "Uni-Vibe", auto_pan: "Auto Pan", stereo_widener: "Stereo Widener",
|
|
bitcrusher: "Bitcrusher", wavefolder: "Wavefolder", rectifier: "Rectifier",
|
|
expander: "Expander", de_esser: "De-esser", transient_shaper: "Transient Shaper",
|
|
parametric_eq: "Parametric EQ", high_pass_filter: "High Pass",
|
|
low_pass_filter: "Low Pass", band_pass_filter: "Band Pass",
|
|
notch_filter: "Notch Filter", formant_filter: "Formant Filter",
|
|
ping_pong_delay: "Ping Pong Delay", multi_tap_delay: "Multi Tap",
|
|
reverse_delay: "Reverse Delay", tape_echo: "Tape Echo",
|
|
shimmer_reverb: "Shimmer Reverb", looper: "Looper",
|
|
early_reflections: "Early Reflections", sidechain_compressor: "Sidechain Comp",
|
|
};
|
|
|
|
function FXScreen({ state, connected }) {
|
|
const [fxList, setFxList] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showAdd, setShowAdd] = useState(false);
|
|
const [paramSchemas, setParamSchemas] = useState({});
|
|
|
|
// Load block param schemas
|
|
const loadParams = useCallback(async (fxType) => {
|
|
if (paramSchemas[fxType]) return paramSchemas[fxType];
|
|
try {
|
|
const data = await apiGet(`/api/block-params/${fxType}`);
|
|
setParamSchemas(prev => ({ ...prev, [fxType]: data.params || [] }));
|
|
return data.params || [];
|
|
} catch (e) { return []; }
|
|
}, [paramSchemas]);
|
|
|
|
useEffect(() => {
|
|
if (!connected || !state?.current_preset) {
|
|
// Offline: show a simple empty state
|
|
setFxList([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Load active preset's chain from presets API
|
|
apiGet('/api/presets').then(async data => {
|
|
const banks = data.banks || [];
|
|
const cp = state.current_preset;
|
|
const preset = banks[cp.bank]?.presets?.[cp.program];
|
|
if (preset?.chain) {
|
|
const blocks = await Promise.all(preset.chain.map(async (b, i) => {
|
|
const params = await loadParams(b.fx_type);
|
|
return {
|
|
id: i + 1,
|
|
title: FX_TYPE_LABELS[b.fx_type] || b.fx_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
|
type: b.fx_type,
|
|
bypassed: b.bypass || !b.enabled,
|
|
params: params,
|
|
};
|
|
}));
|
|
setFxList(blocks);
|
|
}
|
|
setLoading(false);
|
|
}).catch(() => {
|
|
setFxList([]);
|
|
setLoading(false);
|
|
});
|
|
}, [connected, state, loadParams]);
|
|
|
|
const toggle = (id) => setFxList(prev => prev ? prev.map(f => f.id === id ? { ...f, bypassed: !f.bypassed } : f) : prev);
|
|
|
|
const addBlock = async (fxType) => {
|
|
setShowAdd(false);
|
|
const params = await loadParams(fxType);
|
|
setFxList(prev => [...(prev || []), {
|
|
id: Date.now(),
|
|
title: FX_TYPE_LABELS[fxType] || fxType,
|
|
type: fxType,
|
|
bypassed: false,
|
|
params: params,
|
|
}]);
|
|
};
|
|
|
|
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div className="screen-header">
|
|
<div className="screen-title">FX Chain {(fxList?.length || 0) > 0 ? `(${fxList.length})` : ''}</div>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowAdd(true)}>+ Add Block</button>
|
|
</div>
|
|
<div className="screen-body">
|
|
{/* Signal path */}
|
|
{fxList && fxList.length > 0 && (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 2px", overflowX: "auto" }}>
|
|
{(() => {
|
|
const is4CM = state?.routing_mode === '4cm';
|
|
const bp = is4CM ? (state?.routing_breakpoint || 7) : -1;
|
|
const items = [];
|
|
// Build chain items array with connector metadata
|
|
items.push({ label: "IN", type: "io", idx: -1 });
|
|
fxList.forEach((f, idx) => {
|
|
if (is4CM && idx === bp) {
|
|
items.push({ label: null, type: "merge-bp", idx });
|
|
}
|
|
items.push({ label: f.type.toUpperCase(), type: "fx", idx, bypassed: f.bypassed });
|
|
if (is4CM && idx === bp - 1) {
|
|
items.push({ label: null, type: "split-bp", idx });
|
|
}
|
|
});
|
|
items.push({ label: "OUT", type: "io", idx: -1 });
|
|
|
|
return items.map((item, i) => {
|
|
if (item.type === 'split-bp') {
|
|
return (
|
|
<div key={`bp-${i}`} style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0, gap: 1 }}>
|
|
<SignalArrow variant="split" active={true} />
|
|
<span style={{ fontSize: 7, color: T.textDim, letterSpacing: '.05em', lineHeight: '1' }}>4CM</span>
|
|
<SignalArrow variant="merge" active={true} />
|
|
</div>
|
|
);
|
|
}
|
|
if (item.type === 'merge-bp') {
|
|
// Already rendered as part of the split-bp composite — skip
|
|
return null;
|
|
}
|
|
return (
|
|
<div key={`${item.type}-${i}`} style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
|
{item.type === 'io' && (
|
|
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
|
background: T.blueDim, color: T.blue, border: `1px solid ${T.blue}60` }}>{item.label}</div>
|
|
)}
|
|
{item.type === 'fx' && (
|
|
<div style={{ padding: "3px 8px", borderRadius: 4, fontSize: 10, fontWeight: 700,
|
|
background: T.amberDim, color: item.bypassed ? T.textDim : T.amber,
|
|
border: `1px solid ${(item.bypassed ? T.textDim : T.amber)}60`, opacity: item.bypassed ? 0.5 : 1 }}>{item.label}</div>
|
|
)}
|
|
{/* Connector after this block */}
|
|
{i < items.length - 1 && items[i+1]?.type !== 'split-bp' && items[i]?.type !== 'merge-bp' && (
|
|
<SignalArrow active={item.type === 'io' || !item.bypassed} />
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
</div>
|
|
)}
|
|
{(!fxList || fxList.length === 0) ? (
|
|
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
|
|
No FX blocks in the chain.<br />
|
|
<button className="btn btn-ghost btn-sm" style={{ marginTop: 10 }} onClick={() => setShowAdd(true)}>+ Add your first block</button>
|
|
</div>
|
|
) : (
|
|
fxList.map(f => (
|
|
<FXBlock key={f.id} {...f} active={!f.bypassed} onToggle={() => toggle(f.id)} />
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Block slide panel */}
|
|
{showAdd && (
|
|
<SlidePanel onClose={() => setShowAdd(false)}>
|
|
<div className="screen-header">
|
|
<div className="screen-title">Add FX Block</div>
|
|
</div>
|
|
<div className="screen-body" style={{ gap: 4 }}>
|
|
{Object.entries(FX_TYPE_LABELS).map(([key, label]) => (
|
|
<div key={key} className="preset-row" onClick={() => addBlock(key)} style={{ borderRadius: 6 }}>
|
|
<span className="badge badge-blue" style={{ width: 60, flexShrink: 0 }}>{key.split('_')[0]}</span>
|
|
<span style={{ flex: 1, fontSize: 13 }}>{label}</span>
|
|
<span style={{ color: T.textDim }}>+</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SlidePanel>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 3. MODELS SCREEN ─────────────────────────────────────────────
|
|
function ModelsScreen({ state, connected, refresh }) {
|
|
const [models, setModels] = useState([]);
|
|
const [current, setCurrent] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searching, setSearching] = useState(false);
|
|
const [searchQ, setSearchQ] = useState('');
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const fileRef = useRef(null);
|
|
|
|
const loadModels = useCallback(async () => {
|
|
if (!connected) { setLoading(false); return; }
|
|
try {
|
|
const data = await apiGet('/api/models');
|
|
setModels(data.models || []);
|
|
setCurrent(data.current);
|
|
} catch (e) { /* ignore */ }
|
|
setLoading(false);
|
|
}, [connected]);
|
|
|
|
useEffect(() => { loadModels(); }, [loadModels]);
|
|
|
|
const handleLoad = async (path) => {
|
|
try { await apiPost('/api/models/load', { path }); await loadModels(); refresh(); } catch (e) { alert('Failed to load model'); }
|
|
};
|
|
|
|
const handleUnload = async () => {
|
|
try { await apiPost('/api/models/unload'); setCurrent(null); await loadModels(); refresh(); } catch (e) { alert('Failed to unload'); }
|
|
};
|
|
|
|
const handleUpload = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setUploading(true);
|
|
try {
|
|
await apiUpload('/api/models/upload', file);
|
|
await loadModels();
|
|
} catch (err) { alert('Upload failed'); }
|
|
setUploading(false);
|
|
};
|
|
|
|
const handleSearch = useCallback(async () => {
|
|
if (!searchQ.trim()) return;
|
|
setSearching(true);
|
|
try {
|
|
const data = await apiGet(`/api/models/tonedownload/search?q=${encodeURIComponent(searchQ)}`);
|
|
setSearchResults(data.results || []);
|
|
} catch (e) { alert('Search failed'); }
|
|
setSearching(false);
|
|
}, [searchQ]);
|
|
|
|
const handleInstall = async (result) => {
|
|
try {
|
|
await apiPost('/api/models/tonedownload/install', {
|
|
download_url: result.download_url,
|
|
name: result.name || result.filename,
|
|
id: result.id,
|
|
size: result.size,
|
|
pack_name: result.pack_name,
|
|
display_name: result.name,
|
|
});
|
|
await loadModels();
|
|
refresh();
|
|
} catch (e) { alert('Install failed'); }
|
|
};
|
|
|
|
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div className="screen-header">
|
|
<div className="screen-title">NAM Models ({models.length})</div>
|
|
<div style={{ display: "flex", gap: 6 }}>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowSearch(!showSearch)}>
|
|
{showSearch ? "Local" : "Search"}
|
|
</button>
|
|
<button className="btn btn-primary btn-sm" onClick={() => fileRef.current?.click()}>
|
|
{uploading ? "..." : "+ Upload"}
|
|
</button>
|
|
<input ref={fileRef} type="file" accept=".nam" style={{ display: "none" }} onChange={handleUpload} />
|
|
</div>
|
|
</div>
|
|
|
|
{showSearch && (
|
|
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
|
|
<div className="search-bar">
|
|
<span style={{ color: T.textDim }}>🔍</span>
|
|
<input type="text" placeholder="Search Tone3000…" value={searchQ}
|
|
onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
|
|
<button className="btn btn-primary btn-sm" onClick={handleSearch} disabled={searching}>
|
|
{searching ? "..." : "Go"}
|
|
</button>
|
|
</div>
|
|
{searchResults.length > 0 && (
|
|
<div style={{ marginTop: 8, maxHeight: 240, overflow: "auto" }}>
|
|
{searchResults.map(r => (
|
|
<div key={r.id} className="preset-row" onClick={() => handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13 }}>{r.name}</div>
|
|
<div style={{ fontSize: 10, color: T.textDim }}>{r.author} · {r.size_display} · {r.architecture}</div>
|
|
</div>
|
|
<button className="btn btn-primary btn-sm">Get</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="screen-body">
|
|
{current && (
|
|
<div className="card-sm">
|
|
<div className="section-label" style={{ marginBottom: 6 }}>Loaded Model</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<div className="nam-chip">{current}</div>
|
|
<button className="btn btn-danger btn-sm" onClick={handleUnload}>Unload</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{models.length === 0 ? (
|
|
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
|
|
No NAM models found.<br />Upload a .nam file or search Tone3000.
|
|
</div>
|
|
) : (
|
|
models.map(m => (
|
|
<div key={m.name} className={`preset-row ${m.loaded ? "active" : ""}`}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{m.name}</div>
|
|
<div style={{ fontSize: 10, color: T.textDim }}>{m.architecture} · {m.size_mb} MB</div>
|
|
</div>
|
|
{m.loaded ? (
|
|
<span className="badge badge-green">Loaded</span>
|
|
) : (
|
|
<button className="btn btn-ghost btn-sm" onClick={() => handleLoad(m.path)}>Load</button>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 4. IRS SCREEN ────────────────────────────────────────────────
|
|
function IRsScreen({ state, connected, refresh }) {
|
|
const [irs, setIrs] = useState([]);
|
|
const [current, setCurrent] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searching, setSearching] = useState(false);
|
|
const [searchQ, setSearchQ] = useState('');
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const fileRef = useRef(null);
|
|
|
|
const loadIrs = useCallback(async () => {
|
|
if (!connected) { setLoading(false); return; }
|
|
try {
|
|
const data = await apiGet('/api/irs');
|
|
setIrs(data.irs || []);
|
|
setCurrent(data.current);
|
|
} catch (e) { /* ignore */ }
|
|
setLoading(false);
|
|
}, [connected]);
|
|
|
|
useEffect(() => { loadIrs(); }, [loadIrs]);
|
|
|
|
const handleLoad = async (path) => {
|
|
try { await apiPost('/api/irs/load', { path }); await loadIrs(); refresh(); } catch (e) { alert('Failed to load IR'); }
|
|
};
|
|
|
|
const handleUnload = async () => {
|
|
try { await apiPost('/api/irs/unload'); setCurrent(null); await loadIrs(); refresh(); } catch (e) { alert('Failed to unload'); }
|
|
};
|
|
|
|
const handleUpload = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setUploading(true);
|
|
try {
|
|
await apiUpload('/api/irs/upload', file);
|
|
await loadIrs();
|
|
} catch (err) { alert('Upload failed'); }
|
|
setUploading(false);
|
|
};
|
|
|
|
const handleSearch = useCallback(async () => {
|
|
if (!searchQ.trim()) return;
|
|
setSearching(true);
|
|
try {
|
|
const data = await apiGet(`/api/irs/tonedownload/search?q=${encodeURIComponent(searchQ)}`);
|
|
setSearchResults(data.results || []);
|
|
} catch (e) { alert('Search failed'); }
|
|
setSearching(false);
|
|
}, [searchQ]);
|
|
|
|
const handleInstall = async (result) => {
|
|
try {
|
|
await apiPost('/api/irs/tonedownload/install', {
|
|
download_url: result.download_url,
|
|
name: result.name || result.filename,
|
|
id: result.id,
|
|
size: result.size,
|
|
display_name: result.name,
|
|
});
|
|
await loadIrs();
|
|
refresh();
|
|
} catch (e) { alert('Install failed'); }
|
|
};
|
|
|
|
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div className="screen-header">
|
|
<div className="screen-title">IR Files ({irs.length})</div>
|
|
<div style={{ display: "flex", gap: 6 }}>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowSearch(!showSearch)}>
|
|
{showSearch ? "Local" : "Search"}
|
|
</button>
|
|
<button className="btn btn-primary btn-sm" onClick={() => fileRef.current?.click()}>
|
|
{uploading ? "..." : "+ Upload"}
|
|
</button>
|
|
<input ref={fileRef} type="file" accept=".wav" style={{ display: "none" }} onChange={handleUpload} />
|
|
</div>
|
|
</div>
|
|
|
|
{showSearch && (
|
|
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.border}` }}>
|
|
<div className="search-bar">
|
|
<span style={{ color: T.textDim }}>🔍</span>
|
|
<input type="text" placeholder="Search Tone3000 IRs…" value={searchQ}
|
|
onChange={e => setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} />
|
|
<button className="btn btn-primary btn-sm" onClick={handleSearch} disabled={searching}>
|
|
{searching ? "..." : "Go"}
|
|
</button>
|
|
</div>
|
|
{searchResults.length > 0 && (
|
|
<div style={{ marginTop: 8, maxHeight: 240, overflow: "auto" }}>
|
|
{searchResults.map(r => (
|
|
<div key={r.id} className="preset-row" onClick={() => handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13 }}>{r.name}</div>
|
|
<div style={{ fontSize: 10, color: T.textDim }}>{r.author} · {r.size_display}</div>
|
|
</div>
|
|
<button className="btn btn-primary btn-sm">Get</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="screen-body">
|
|
{current && (
|
|
<div className="card-sm">
|
|
<div className="section-label" style={{ marginBottom: 6 }}>Loaded IR</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<div className="nam-chip" style={{ color: T.green, borderColor: `${T.green}55`, background: `${T.green}15` }}>{current}</div>
|
|
<button className="btn btn-danger btn-sm" onClick={handleUnload}>Unload</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{irs.length === 0 ? (
|
|
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>
|
|
No IR files found.<br />Upload a .wav file or search Tone3000.
|
|
</div>
|
|
) : (
|
|
irs.map(ir => (
|
|
<div key={ir.name} className={`preset-row ${ir.loaded ? "active" : ""}`}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{ir.name}</div>
|
|
<div style={{ fontSize: 10, color: T.textDim }}>{ir.sample_rate} Hz · {ir.length_ms} ms · {ir.num_taps} taps</div>
|
|
</div>
|
|
{ir.loaded ? (
|
|
<span className="badge badge-green">Loaded</span>
|
|
) : (
|
|
<button className="btn btn-ghost btn-sm" onClick={() => handleLoad(ir.path)}>Load</button>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 5. PRESETS SCREEN ────────────────────────────────────────────
|
|
function PresetsScreen({ state, connected, refresh }) {
|
|
const [banks, setBanks] = useState([]);
|
|
const [selectedBank, setSelectedBank] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saveName, setSaveName] = useState('');
|
|
const [showSave, setShowSave] = useState(false);
|
|
const [deleting, setDeleting] = useState(null);
|
|
|
|
const loadPresets = useCallback(async () => {
|
|
if (!connected) {
|
|
setBanks([{ name: "Default", number: 0, presets: [
|
|
{ name: "Edge of Breakup", bank: 0, program: 0 },
|
|
{ name: "Modern High Gain", bank: 0, program: 1 },
|
|
null, null,
|
|
]}]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
const data = await apiGet('/api/presets');
|
|
setBanks(data.banks || []);
|
|
} catch (e) { /* ignore */ }
|
|
setLoading(false);
|
|
}, [connected]);
|
|
|
|
useEffect(() => { loadPresets(); }, [loadPresets]);
|
|
|
|
const handleActivate = async (bank, program) => {
|
|
try {
|
|
await apiPost(`/api/presets/${bank}/${program}/activate`);
|
|
await loadPresets();
|
|
refresh();
|
|
} catch (e) { alert('Failed to load preset'); }
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!saveName.trim()) return;
|
|
const currentBank = state?.current_preset?.bank ?? 0;
|
|
const currentProgram = state?.current_preset?.program ?? 0;
|
|
try {
|
|
await apiPut(`/api/presets/${currentBank}/${currentProgram}`, { name: saveName });
|
|
setShowSave(false);
|
|
await loadPresets();
|
|
} catch (e) { alert('Failed to save'); }
|
|
};
|
|
|
|
const handleNewPreset = async () => {
|
|
// Find the first empty slot, or create at next program number
|
|
const bank = selectedBank;
|
|
const bankData = banks[bank];
|
|
const presets = bankData?.presets || [];
|
|
let nextProg = presets.findIndex(p => p == null);
|
|
if (nextProg === -1) nextProg = presets.length;
|
|
const name = `New Preset ${nextProg + 1}`;
|
|
try {
|
|
await apiPut(`/api/presets/${bank}/${nextProg}`, { name });
|
|
await loadPresets();
|
|
await apiPost(`/api/presets/${bank}/${nextProg}/activate`);
|
|
refresh();
|
|
} catch (e) { alert('Failed to create preset'); }
|
|
};
|
|
|
|
const handleDelete = async (bank, program) => {
|
|
try {
|
|
await apiDelete(`/api/presets/${bank}/${program}`);
|
|
setDeleting(null);
|
|
await loadPresets();
|
|
} catch (e) { alert('Failed to delete'); }
|
|
};
|
|
|
|
if (loading) return <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}><div className="loader" /></div>;
|
|
|
|
const currentPreset = state?.current_preset;
|
|
const bankData = banks[selectedBank];
|
|
const presets = bankData?.presets || [];
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div className="screen-header">
|
|
<div className="screen-title">Presets</div>
|
|
<div style={{ display: "flex", gap: 6 }}>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowSave(true)}>Save</button>
|
|
<button className="btn btn-primary btn-sm" onClick={handleNewPreset}>+ New</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="screen-body">
|
|
{/* Bank selector */}
|
|
{banks.length > 1 && (
|
|
<div className="toggle">
|
|
{banks.map((b, i) => (
|
|
<button key={b.number} className={`toggle-btn ${selectedBank === i ? "active" : ""}`}
|
|
onClick={() => setSelectedBank(i)}>{b.name}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Current preset indicator */}
|
|
{currentPreset && (
|
|
<div className="card-sm" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 11, color: T.textDim }}>Current</div>
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: T.amber }}>
|
|
{currentPreset.name}
|
|
<span className="mono" style={{ fontSize: 10, color: T.textDim, marginLeft: 8 }}>
|
|
Bank {currentPreset.bank} · Prog {currentPreset.program}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preset list */}
|
|
{presets.length === 0 ? (
|
|
<div style={{ textAlign: "center", padding: 30, color: T.textDim, fontSize: 14 }}>No presets in this bank.</div>
|
|
) : (
|
|
presets.map((p, i) => {
|
|
if (!p) return null;
|
|
const isActive = currentPreset?.bank === p.bank && currentPreset?.program === p.program;
|
|
const isDeleting = deleting === `${p.bank}-${p.program}`;
|
|
return (
|
|
<div key={i} className={`preset-row ${isActive ? "active" : ""}`}
|
|
onClick={() => !isDeleting && handleActivate(p.bank, p.program)}>
|
|
<div className="mono" style={{ width: 28, height: 28, borderRadius: 6, display: "flex",
|
|
alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700,
|
|
background: isActive ? `${T.amber}22` : T.surface,
|
|
color: isActive ? T.amber : T.textDim,
|
|
border: `1px solid ${isActive ? T.amber+"60" : T.border}` }}>{p.program + 1}</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{p.name}</div>
|
|
<div style={{ fontSize: 10, color: T.textDim }}>Bank {p.bank} · Program {p.program}</div>
|
|
</div>
|
|
{isActive && <span style={{ color: T.amber, fontSize: 16 }}>●</span>}
|
|
{isDeleting ? (
|
|
<div style={{ display: "flex", gap: 4 }}>
|
|
<button className="btn btn-danger btn-sm" onClick={e => { e.stopPropagation(); handleDelete(p.bank, p.program); }}>Confirm</button>
|
|
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setDeleting(null); }}>Cancel</button>
|
|
</div>
|
|
) : (
|
|
<button className="btn btn-ghost btn-sm" style={{ color: T.red }}
|
|
onClick={e => { e.stopPropagation(); setDeleting(`${p.bank}-${p.program}`); }}>Delete</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Save dialog */}
|
|
{showSave && (
|
|
<SlidePanel onClose={() => setShowSave(false)}>
|
|
<div className="screen-header">
|
|
<div className="screen-title">Save Preset</div>
|
|
</div>
|
|
<div className="screen-body">
|
|
<div className="search-bar">
|
|
<input type="text" placeholder="Preset name…" value={saveName}
|
|
onChange={e => setSaveName(e.target.value)} autoFocus
|
|
onKeyDown={e => e.key === 'Enter' && handleSave()} />
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<button className="btn btn-primary" style={{ flex: 1 }} onClick={handleSave}>Save</button>
|
|
<button className="btn btn-ghost" onClick={() => setShowSave(false)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
</SlidePanel>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 6. SETTINGS SCREEN ───────────────────────────────────────────
|
|
function SettingsScreen({ state, connected, refresh }) {
|
|
const [tab, setTab] = useState('wifi');
|
|
const [wifiNetworks, setWifiNetworks] = useState([]);
|
|
const [btDevices, setBtDevices] = useState([]);
|
|
const [btPaired, setBtPaired] = useState([]);
|
|
const [wifiScanning, setWifiScanning] = useState(false);
|
|
const [btScanning, setBtScanning] = useState(false);
|
|
const [connectSsid, setConnectSsid] = useState('');
|
|
const [connectPw, setConnectPw] = useState('');
|
|
const [showConnect, setShowConnect] = useState(null);
|
|
const [routingMode, setRoutingMode] = useState(state?.routing_mode || 'mono');
|
|
const [routingBp, setRoutingBp] = useState(state?.routing_breakpoint || 7);
|
|
const [channelMode, setChannelMode] = useState(state?.channel_mode || 'dual-mono');
|
|
|
|
useEffect(() => {
|
|
if (state?.routing_mode) setRoutingMode(state.routing_mode);
|
|
if (state?.routing_breakpoint != null) setRoutingBp(state.routing_breakpoint);
|
|
if (state?.channel_mode) setChannelMode(state.channel_mode);
|
|
}, [state?.routing_mode, state?.routing_breakpoint, state?.channel_mode]);
|
|
|
|
const scanWifi = async () => {
|
|
setWifiScanning(true);
|
|
try {
|
|
const data = await apiGet('/api/wifi/scan');
|
|
setWifiNetworks(data.networks || []);
|
|
} catch (e) { /* ignore */ }
|
|
setWifiScanning(false);
|
|
};
|
|
|
|
const connectWifi = async (ssid, password) => {
|
|
try {
|
|
await apiPost('/api/wifi/connect', { ssid, password });
|
|
setShowConnect(null);
|
|
setConnectSsid('');
|
|
setConnectPw('');
|
|
setTimeout(refresh, 2000);
|
|
} catch (e) { alert('Failed to connect'); }
|
|
};
|
|
|
|
const disconnectWifi = async () => {
|
|
try { await apiPost('/api/wifi/disconnect'); setTimeout(refresh, 2000); } catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const toggleHotspot = async () => {
|
|
const active = state?.wifi?.hotspot_active;
|
|
try {
|
|
if (active) await apiPost('/api/wifi/hotspot/disable');
|
|
else await apiPost('/api/wifi/hotspot/enable', { ssid: 'Pi-Pedal', password: 'pedal1234' });
|
|
setTimeout(refresh, 2000);
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const handleBtPower = async (on) => {
|
|
try { await apiPost('/api/bluetooth/power', { on }); setTimeout(refresh, 1000); } catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const scanBt = async () => {
|
|
setBtScanning(true);
|
|
try {
|
|
const data = await apiPost('/api/bluetooth/scan');
|
|
setBtDevices(data.devices || []);
|
|
} catch (e) { /* ignore */ }
|
|
setBtScanning(false);
|
|
};
|
|
|
|
const loadBtPaired = async () => {
|
|
try {
|
|
const data = await apiGet('/api/bluetooth/paired');
|
|
setBtPaired(data.devices || []);
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const pairBt = async (addr) => {
|
|
try { await apiPost('/api/bluetooth/pair', { address: addr }); await loadBtPaired(); } catch (e) { alert('Pairing failed'); }
|
|
};
|
|
|
|
const handleRouting = async (mode, bp) => {
|
|
try { await apiPost('/api/routing', { routing_mode: mode, routing_breakpoint: bp }); } catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const handleChannelMode = async (mode) => {
|
|
try {
|
|
await apiPost('/api/channel-mode', { channel_mode: mode });
|
|
setChannelMode(mode);
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
useEffect(() => { if (connected) { scanWifi(); loadBtPaired(); } }, [connected]);
|
|
|
|
const wifiState = state?.wifi;
|
|
const btState = state?.bluetooth;
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div className="screen-header">
|
|
<div className="screen-title">Settings</div>
|
|
</div>
|
|
|
|
{/* Settings tabs */}
|
|
<div className="toggle" style={{ margin: "6px 10px" }}>
|
|
{[{ id: 'wifi', label: 'WiFi' }, { id: 'bt', label: 'Bluetooth' }, { id: 'routing', label: 'Routing' }].map(t => (
|
|
<button key={t.id} className={`toggle-btn ${tab === t.id ? "active" : ""}`} onClick={() => setTab(t.id)}>{t.label}</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="screen-body" style={{ gap: 4 }}>
|
|
{/* ── WiFi Tab ─────────────────────────────────────────────*/}
|
|
{tab === 'wifi' && (
|
|
<>
|
|
{/* WiFi Status */}
|
|
<div className="card-sm">
|
|
<div className="setting-row" style={{ border: "none", paddingTop: 0 }}>
|
|
<div>
|
|
<div className="setting-label">WiFi</div>
|
|
<div className="setting-desc">{wifiState?.connected ? `Connected to ${wifiState.ssid}` : 'Disconnected'}</div>
|
|
</div>
|
|
<div className={`switch ${wifiState?.connected ? 'on' : ''}`} onClick={disconnectWifi} />
|
|
</div>
|
|
{wifiState?.connected && (
|
|
<div style={{ fontSize: 11, color: T.textDim }}>
|
|
IP: {wifiState.ip} · Signal: {wifiState.signal}%
|
|
</div>
|
|
)}
|
|
|
|
{/* Hotspot */}
|
|
<div className="setting-row">
|
|
<div>
|
|
<div className="setting-label">Hotspot</div>
|
|
<div className="setting-desc">{wifiState?.hotspot_active ? `Active: ${wifiState.hotspot_ssid}` : 'Off'}</div>
|
|
</div>
|
|
<div className={`switch ${wifiState?.hotspot_active ? 'on' : ''}`} onClick={toggleHotspot} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scan networks */}
|
|
<div className="card-sm">
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
<div className="section-label" style={{ marginBottom: 0 }}>Networks</div>
|
|
<button className="btn btn-ghost btn-sm" onClick={scanWifi} disabled={wifiScanning}>
|
|
{wifiScanning ? "..." : "Scan"}
|
|
</button>
|
|
</div>
|
|
{wifiNetworks.length === 0 ? (
|
|
<div style={{ fontSize: 12, color: T.textDim, padding: "8px 0" }}>Scan to see available networks.</div>
|
|
) : (
|
|
wifiNetworks.map(n => (
|
|
<div key={n.ssid} className="preset-row" style={{ padding: "8px 0", borderRadius: 0, borderBottom: `1px solid ${T.border}` }}
|
|
onClick={() => wpaState?.ssid !== n.ssid && setShowConnect(n.ssid)}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13 }}>{n.ssid}</div>
|
|
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>Signal: {n.signal}% · {n.encryption || 'Open'}</div>
|
|
</div>
|
|
{wifiState?.ssid === n.ssid && <span className="badge badge-green">Connected</span>}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Connect dialog */}
|
|
{showConnect && (
|
|
<div className="card-sm">
|
|
<div className="section-label">Connect to {showConnect}</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
<input type="password" placeholder="Password (leave blank for open network)"
|
|
value={connectPw} onChange={e => setConnectPw(e.target.value)}
|
|
style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "8px 12px", color: T.textPrimary, fontSize: 13, outline: "none" }}
|
|
onKeyDown={e => e.key === 'Enter' && connectWifi(showConnect, connectPw)} />
|
|
<div style={{ display: "flex", gap: 6 }}>
|
|
<button className="btn btn-primary btn-sm" style={{ flex: 1 }}
|
|
onClick={() => connectWifi(showConnect, connectPw)}>Connect</button>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowConnect(null)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── Bluetooth Tab ─────────────────────────────────────────*/}
|
|
{tab === 'bt' && (
|
|
<>
|
|
<div className="card-sm">
|
|
<div className="setting-row" style={{ border: "none", paddingTop: 0 }}>
|
|
<div>
|
|
<div className="setting-label">Bluetooth</div>
|
|
<div className="setting-desc">{btState?.powered ? 'Powered on' : 'Off'}</div>
|
|
</div>
|
|
<div className={`switch ${btState?.powered ? 'on' : ''}`}
|
|
onClick={() => handleBtPower(!btState?.powered)} />
|
|
</div>
|
|
{btState?.powered && (
|
|
<>
|
|
<div className="setting-row">
|
|
<div>
|
|
<div className="setting-label">Discoverable</div>
|
|
<div className="setting-desc">{btState?.name} ({btState?.address})</div>
|
|
</div>
|
|
<div className={`switch ${btState?.discoverable ? 'on' : ''}`}
|
|
onClick={async () => { try { await apiPost('/api/bluetooth/discoverable', { on: !btState?.discoverable }); setTimeout(refresh, 500); } catch(e) {} }} />
|
|
</div>
|
|
<div className="setting-row">
|
|
<div>
|
|
<div className="setting-label">Bluetooth MIDI</div>
|
|
<div className="setting-desc">{btState?.midi_enabled ? 'Enabled' : 'Disabled'}</div>
|
|
</div>
|
|
<div className={`switch ${btState?.midi_enabled ? 'on' : ''}`}
|
|
onClick={async () => { try { await (btState?.midi_enabled ? apiPost('/api/bluetooth/midi-disable') : apiPost('/api/bluetooth/midi-enable')); setTimeout(refresh, 500); } catch(e) {} }} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{btState?.powered && (
|
|
<div className="card-sm">
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
|
<div className="section-label" style={{ marginBottom: 0 }}>Paired Devices ({btPaired.length})</div>
|
|
<button className="btn btn-ghost btn-sm" onClick={scanBt} disabled={btScanning}>
|
|
{btScanning ? "..." : "Scan"}
|
|
</button>
|
|
</div>
|
|
{btPaired.length === 0 ? (
|
|
<div style={{ fontSize: 12, color: T.textDim, padding: "8px 0" }}>No paired devices.</div>
|
|
) : (
|
|
btPaired.map(d => (
|
|
<div key={d.address} className="preset-row" style={{ padding: "8px 0", borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13 }}>{d.name || d.address}</div>
|
|
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>{d.address} · {d.connected ? 'Connected' : 'Paired'}</div>
|
|
</div>
|
|
{!d.connected && (
|
|
<button className="btn btn-ghost btn-sm" onClick={async () => { try { await apiPost('/api/bluetooth/connect', { address: d.address }); } catch(e) { alert('Failed'); } }}>Connect</button>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{btScanning && btDevices.length > 0 && (
|
|
<div className="card-sm">
|
|
<div className="section-label" style={{ marginBottom: 6 }}>Discovered</div>
|
|
{btDevices.map(d => (
|
|
<div key={d.address} className="preset-row" style={{ padding: "8px 0", borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13 }}>{d.name || d.address}</div>
|
|
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>{d.address}</div>
|
|
</div>
|
|
<button className="btn btn-ghost btn-sm" onClick={() => pairBt(d.address)}>Pair</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── Routing Tab ───────────────────────────────────────────*/}
|
|
{tab === 'routing' && (
|
|
<>
|
|
<div className="card-sm">
|
|
<div className="section-label">4CM Routing</div>
|
|
<div className="setting-row">
|
|
<div>
|
|
<div className="setting-label">Mode</div>
|
|
<div className="setting-desc">Mono (normal) or 4CM (4-cable method)</div>
|
|
</div>
|
|
<select value={routingMode} onChange={e => { setRoutingMode(e.target.value); handleRouting(e.target.value, routingBp); }}
|
|
style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "6px 10px", color: T.textPrimary, fontSize: 13, outline: "none" }}>
|
|
<option value="mono">Mono</option>
|
|
<option value="4cm">4CM</option>
|
|
</select>
|
|
</div>
|
|
{routingMode === '4cm' && (
|
|
<div className="setting-row">
|
|
<div>
|
|
<div className="setting-label">Breakpoint</div>
|
|
<div className="setting-desc">Chain position for amp send/return</div>
|
|
</div>
|
|
<input type="number" min={1} max={15} value={routingBp}
|
|
onChange={e => { const v = +e.target.value; setRoutingBp(v); handleRouting(routingMode, v); }}
|
|
style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "6px 10px", color: T.textPrimary, fontSize: 13, width: 60, outline: "none", textAlign: "center" }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Channel Mode */}
|
|
<div className="card-sm">
|
|
<div className="section-label">Channel Mode</div>
|
|
<div className="setting-row">
|
|
<div>
|
|
<div className="setting-label">Routing</div>
|
|
<div className="setting-desc">Dual-mono: two independent channels · Stereo: linked as L/R pair</div>
|
|
</div>
|
|
<select value={channelMode} onChange={e => handleChannelMode(e.target.value)}
|
|
style={{ background: T.surface, border: `1px solid ${T.border}`, borderRadius: 6, padding: "6px 10px", color: T.textPrimary, fontSize: 13, outline: "none" }}>
|
|
<option value="dual-mono">Dual-Mono</option>
|
|
<option value="stereo">Stereo</option>
|
|
</select>
|
|
</div>
|
|
<div style={{ fontSize: 10, color: T.textDim, marginTop: 4, padding: '4px 0' }}>
|
|
{channelMode === 'dual-mono'
|
|
? 'Each channel controlled independently from its own phone.'
|
|
: 'Channels A (Left) and B (Right) are controlled together.'}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 7. LOOPER / RECORDER SCREEN ──────────────────────────────────
|
|
function RecordScreen({ state }) {
|
|
const [recording, setRecording] = useState(false);
|
|
const [captures, setCaptures] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const inputLevel = state?.input_level ?? 0;
|
|
const outputLevel = state?.output_level ?? 0;
|
|
|
|
const loadCaptures = useCallback(async () => {
|
|
try {
|
|
const data = await apiGet('/api/captures');
|
|
setCaptures(data.captures || []);
|
|
} catch (e) { /* ignore */ }
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { loadCaptures(); }, [loadCaptures]);
|
|
|
|
const handleRecord = async () => {
|
|
try {
|
|
await apiPost('/api/capture/start');
|
|
setRecording(true);
|
|
} catch (e) { alert('Failed to start recording'); }
|
|
};
|
|
|
|
const handleStop = async () => {
|
|
try {
|
|
await apiPost('/api/capture/stop');
|
|
setRecording(false);
|
|
loadCaptures();
|
|
} catch (e) { alert('Failed to stop recording'); }
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
|
<div className="screen-header">
|
|
<div>
|
|
<div className="screen-title">Looper / Recorder</div>
|
|
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>
|
|
{state?.sample_rate ? `${state.sample_rate/1000}kHz` : '—'}
|
|
{recording && <span style={{ color: T.red, marginLeft: 8 }}>● RECORDING</span>}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
{recording ? (
|
|
<button className="btn btn-danger btn-sm" onClick={handleStop} style={{ padding: "8px 16px" }}>
|
|
⏹ Stop
|
|
</button>
|
|
) : (
|
|
<button className="btn btn-primary btn-sm" onClick={handleRecord} style={{ padding: "8px 16px", background: T.red, color: '#fff' }}>
|
|
● Record
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="screen-body">
|
|
{/* Live levels during recording */}
|
|
<div className="card" style={{ padding: "16px", display: "flex", gap: 16, alignItems: "center" }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div className="section-label">Input Level</div>
|
|
<div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
|
|
{Array.from({ length: 12 }).map((_, i) => {
|
|
const active = Math.round((inputLevel / 100) * 12) > i;
|
|
const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green;
|
|
return <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
|
|
transition: 'all .05s' }} />;
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div className="section-label">Output Level</div>
|
|
<div style={{ display: "flex", gap: 2, height: 20, marginTop: 4 }}>
|
|
{Array.from({ length: 12 }).map((_, i) => {
|
|
const active = Math.round((outputLevel / 100) * 12) > i;
|
|
const c = i >= 10 ? T.red : i >= 8 ? '#E8C030' : T.green;
|
|
return <div key={i} style={{ flex: 1, borderRadius: 2, background: active ? c : T.border,
|
|
transition: 'all .05s' }} />;
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recording status */}
|
|
{recording && (
|
|
<div className="card-sm" style={{ background: `${T.red}15`, borderColor: T.red, textAlign: 'center', padding: 20 }}>
|
|
<div style={{ fontSize: 40, marginBottom: 8, animation: 'pulse 1s ease-in-out infinite' }}>🔴</div>
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: T.red }}>Recording...</div>
|
|
<div className="mono" style={{ fontSize: 11, color: T.textDim, marginTop: 4 }}>
|
|
Saving to captures folder — tap Stop to finish
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Captured files */}
|
|
{!recording && (
|
|
<>
|
|
<div className="card-sm">
|
|
<div className="section-label" style={{ marginBottom: 6 }}>Recordings ({captures.length})</div>
|
|
{loading ? (
|
|
<div className="loader" style={{ margin: '10px auto' }} />
|
|
) : captures.length === 0 ? (
|
|
<div style={{ fontSize: 12, color: T.textDim, padding: '10px 0' }}>
|
|
No recordings yet. Tap ● Record to capture audio.
|
|
</div>
|
|
) : (
|
|
captures.map(c => (
|
|
<div key={c.name} className="preset-row" style={{ padding: '8px 0', borderRadius: 0, borderBottom: `1px solid ${T.border}` }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13 }}>{c.name}</div>
|
|
<div className="mono" style={{ fontSize: 10, color: T.textDim }}>{c.size_display}</div>
|
|
</div>
|
|
<a href={`/api/captures/${c.name}`} target="_blank" rel="noopener"
|
|
className="btn btn-ghost btn-sm" style={{ textDecoration: 'none' }}>▶ Play</a>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<style>{`@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SNAPSHOT PANEL
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
function SnapshotPanel({ onClose, state, connected }) {
|
|
const [snapshots, setSnapshots] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [editingSlot, setEditingSlot] = useState(null);
|
|
const [editName, setEditName] = useState('');
|
|
const [saveFlash, setSaveFlash] = useState(null);
|
|
const [discardEdits, setDiscardEdits] = useState(false);
|
|
const holdTimer = useRef(null);
|
|
const currentSlot = state?.current_snapshot ?? 0;
|
|
|
|
const loadSnapshots = useCallback(async () => {
|
|
if (!connected) { setLoading(false); return; }
|
|
try {
|
|
const data = await apiGet('/api/snapshots');
|
|
setSnapshots(data.snapshots || {});
|
|
} catch (e) { /* ignore */ }
|
|
setLoading(false);
|
|
}, [connected]);
|
|
|
|
useEffect(() => { loadSnapshots(); }, [loadSnapshots]);
|
|
|
|
// Handle press & hold vs tap
|
|
const handlePointerDown = (slot) => {
|
|
holdTimer.current = setTimeout(() => {
|
|
// Long press → save
|
|
holdTimer.current = null;
|
|
handleSave(slot);
|
|
}, 600);
|
|
};
|
|
|
|
const handlePointerUp = (slot) => {
|
|
if (holdTimer.current) {
|
|
clearTimeout(holdTimer.current);
|
|
holdTimer.current = null;
|
|
// Short tap → recall (or save if empty)
|
|
if (snapshots[String(slot)]) {
|
|
handleRecall(slot);
|
|
} else {
|
|
handleSave(slot);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePointerLeave = () => {
|
|
if (holdTimer.current) {
|
|
clearTimeout(holdTimer.current);
|
|
holdTimer.current = null;
|
|
}
|
|
};
|
|
|
|
const handleSave = async (slot) => {
|
|
try {
|
|
await apiPost(`/api/snapshots/${slot}/save`);
|
|
setSaveFlash(slot);
|
|
setTimeout(() => setSaveFlash(null), 800);
|
|
await loadSnapshots();
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const handleRecall = async (slot) => {
|
|
try {
|
|
await apiPost(`/api/snapshots/${slot}/recall`);
|
|
await loadSnapshots();
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const handleRename = async (slot) => {
|
|
if (!editName.trim()) return;
|
|
try {
|
|
await apiPut(`/api/snapshots/${slot}`, { name: editName.trim() });
|
|
setEditingSlot(null);
|
|
await loadSnapshots();
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
const handleDelete = async (slot) => {
|
|
try {
|
|
await apiDelete(`/api/snapshots/${slot}`);
|
|
await loadSnapshots();
|
|
} catch (e) { /* ignore */ }
|
|
};
|
|
|
|
return (
|
|
<SlidePanel onClose={onClose}>
|
|
<div className="screen-header" style={{ justifyContent: "space-between" }}>
|
|
<div className="screen-title">Snapshots</div>
|
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 10, color: T.textDim, cursor: "pointer" }}>
|
|
<div className={`switch ${discardEdits ? 'on' : ''}`} style={{ width: 28, height: 16 }}
|
|
onClick={() => setDiscardEdits(d => !d)}>
|
|
<div style={{ position: "absolute", top: 1, left: discardEdits ? 11 : 1, width: 13, height: 13,
|
|
borderRadius: "50%", background: "#fff", transition: "left .15s" }} />
|
|
</div>
|
|
Discard Edits
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div className="screen-body" style={{ gap: 8 }}>
|
|
<div style={{ fontSize: 11, color: T.textDim, marginBottom: 4 }}>
|
|
Tap to recall · Hold to save
|
|
{discardEdits && <span style={{ color: T.amber, marginLeft: 8 }}>Edits will be discarded on recall</span>}
|
|
</div>
|
|
<div className="snap-grid">
|
|
{[1,2,3,4,5,6,7,8].map(slot => {
|
|
const snap = snapshots[String(slot)];
|
|
const isActive = currentSlot === slot;
|
|
const isFilled = !!snap;
|
|
const isEditing = editingSlot === slot;
|
|
const isFlashing = saveFlash === slot;
|
|
return (
|
|
<div key={slot} className={`snap-slot ${isFilled ? "filled" : ""} ${isActive ? "active" : ""}`}
|
|
onMouseDown={() => handlePointerDown(slot)}
|
|
onTouchStart={() => handlePointerDown(slot)}
|
|
onMouseUp={() => handlePointerUp(slot)}
|
|
onTouchEnd={() => handlePointerUp(slot)}
|
|
onMouseLeave={handlePointerLeave}
|
|
onTouchCancel={handlePointerLeave}
|
|
style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}>
|
|
<div className="snap-num">{slot}</div>
|
|
{isEditing ? (
|
|
<input className="snap-name-input" value={editName}
|
|
onChange={e => setEditName(e.target.value)}
|
|
onBlur={() => handleRename(slot)}
|
|
onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }}
|
|
autoFocus onClick={e => e.stopPropagation()} />
|
|
) : snap ? (
|
|
<div className="snap-name"
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
setEditingSlot(slot);
|
|
setEditName(snap.name);
|
|
}}>{snap.name}</div>
|
|
) : (
|
|
<div className="snap-empty">Empty</div>
|
|
)}
|
|
{isFlashing && <div className="snap-save-indicator" />}
|
|
{isActive && <div style={{ position: "absolute", bottom: -2, left: "50%", transform: "translateX(-50%)",
|
|
width: 20, height: 2, borderRadius: 1, background: T.amber }} />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Delete button for active snapshot */}
|
|
{currentSlot > 0 && snapshots[String(currentSlot)] && (
|
|
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
|
|
<button className="btn btn-danger btn-sm" style={{ flex: 1 }}
|
|
onClick={() => handleDelete(currentSlot)}>
|
|
Delete Snapshot {currentSlot}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SlidePanel>
|
|
);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// APP SHELL
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
const TABS = [
|
|
{ id: "rig", label: "Rig", icon: "🎛" },
|
|
{ id: "fx", label: "FX Chain", icon: "⛓" },
|
|
{ id: "models", label: "Models", icon: "📁" },
|
|
{ id: "irs", label: "IRs", icon: "🎚" },
|
|
{ id: "presets", label: "Presets", icon: "⭐" },
|
|
{ id: "settings", label: "Settings", icon: "⚙" },
|
|
{ id: "record", label: "Looper", icon: "🎙" },
|
|
];
|
|
|
|
export default function App() {
|
|
const [tab, setTab] = useState("rig");
|
|
const [showSnapshots, setShowSnapshots] = useState(false);
|
|
const [channel, setChannelState] = useState(() => {
|
|
try { return localStorage.getItem('pedal_channel') || 'guitar'; } catch { return 'guitar'; }
|
|
});
|
|
const { state, connected, refresh } = usePedalState();
|
|
|
|
useEffect(() => { document.title = "Pi Multi-FX Pedal"; }, []);
|
|
|
|
// Sync channel to localStorage + module-level API var
|
|
useEffect(() => {
|
|
try { localStorage.setItem('pedal_channel', channel); } catch (e) {}
|
|
setChannel(channel);
|
|
}, [channel]);
|
|
|
|
const handleChannel = (ch) => {
|
|
setChannelState(ch);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<style>{css}</style>
|
|
<div style={{ width: "100%", maxWidth: 440, margin: "0 auto", height: "100dvh",
|
|
display: "flex", flexDirection: "column", background: T.bg, overflow: "hidden",
|
|
fontFamily: "'Inter', sans-serif" }}>
|
|
|
|
{/* Status bar */}
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center",
|
|
padding: "4px 10px", background: T.panel, borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
|
|
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
|
<div style={{ width: 6, height: 6, borderRadius: "50%",
|
|
background: connected ? T.green : T.red,
|
|
boxShadow: connected ? `0 0 6px ${T.green}` : "none" }} />
|
|
{/* Channel toggle — hidden in stereo mode */}
|
|
{state?.channel_mode === 'stereo' ? (
|
|
<span className="badge badge-amber" style={{ fontSize: 9, padding: "2px 7px", letterSpacing: ".08em" }}>
|
|
STEREO
|
|
</span>
|
|
) : (
|
|
<div className="toggle" style={{ margin: 0, height: 22 }}>
|
|
<button className={`toggle-btn ${channel === 'guitar' ? 'active' : ''}`}
|
|
onClick={() => handleChannel('guitar')}
|
|
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
|
|
GUITAR
|
|
</button>
|
|
<button className={`toggle-btn ${channel === 'bass' ? 'active' : ''}`}
|
|
onClick={() => handleChannel('bass')}
|
|
style={{ padding: '2px 8px', fontSize: 10, fontWeight: 700, letterSpacing: '.1em' }}>
|
|
BASS
|
|
</button>
|
|
</div>
|
|
)}
|
|
{state?.tuner_enabled && <span className="badge badge-amber" style={{ fontSize: 8, padding: "1px 5px" }}>TUNER</span>}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
{/* Snapshot camera icon */}
|
|
<button className={`snap-header-btn ${showSnapshots ? "active" : ""}`}
|
|
onClick={() => setShowSnapshots(true)}
|
|
title="Snapshots">
|
|
📷
|
|
{state?.current_snapshot > 0 && (
|
|
<span className="snap-header-num">{state.current_snapshot}</span>
|
|
)}
|
|
</button>
|
|
{state?.cpu_percent != null && (
|
|
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>CPU {state.cpu_percent}%</span>
|
|
)}
|
|
{state?.sample_rate && (
|
|
<span className="mono" style={{ fontSize: 9, color: T.textDim }}>{state.sample_rate/1000}kHz</span>
|
|
)}
|
|
{state?.bypass && <span className="badge badge-red" style={{ fontSize: 8, padding: "1px 5px" }}>BYP</span>}
|
|
{state?.nam_loaded && <span className="badge badge-blue" style={{ fontSize: 8, padding: "1px 5px" }}>NAM</span>}
|
|
{state?.ir_loaded && <span className="badge badge-green" style={{ fontSize: 8, padding: "1px 5px" }}>IR</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Screen */}
|
|
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
|
{tab === "rig" && <RigScreen state={state} connected={connected} refresh={refresh} />}
|
|
{tab === "fx" && <FXScreen state={state} connected={connected} />}
|
|
{tab === "models" && <ModelsScreen state={state} connected={connected} refresh={refresh} />}
|
|
{tab === "irs" && <IRsScreen state={state} connected={connected} refresh={refresh} />}
|
|
{tab === "presets" && <PresetsScreen state={state} connected={connected} refresh={refresh} />}
|
|
{tab === "settings" && <SettingsScreen state={state} connected={connected} refresh={refresh} />}
|
|
{tab === "record" && <RecordScreen state={state} />}
|
|
</div>
|
|
|
|
{/* Tab bar */}
|
|
<div className="tabbar">
|
|
{TABS.map(t => (
|
|
<div key={t.id} className={`tab ${tab === t.id ? "active" : ""}`} onClick={() => setTab(t.id)}>
|
|
<span style={{ fontSize: 14, display: "block" }}>{t.icon}</span>
|
|
{t.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Snapshot panel overlay */}
|
|
{showSnapshots && (
|
|
<SnapshotPanel onClose={() => setShowSnapshots(false)} state={state} connected={connected} />
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|