feat: Undo/Redo + Main Menu + Global Settings + Global EQ

- Undo/Redo in header bar: snapshot-based tracking for block adds, removes,
  reorders, parameter changes, and model swaps. 50-level undo stack with
  debounced param snapshots (400ms cooldown).
- Main Menu: slide-out sidebar from left with ☰ button in status bar.
  Items: Save Preset, Bypass/Control, Global Settings, Global EQ, Tuner,
  Wi-Fi / Bluetooth.
- Global Settings: full-screen overlay with grouped settings — Audio (Master
  Volume, Input Pad, Impedance), MIDI (Channel, Clock, Thru), Display
  (Brightness, Auto Dim, Screen Saver), Tempo (Tap Tempo, Division, Mute).
- Global EQ: 3-band shelving equalizer (Low 80Hz, Mid 800Hz, High 6.4kHz)
  with large knobs, canvas-based frequency response graph, and reset button.
- New files: src/MainMenu.jsx, src/GlobalSettings.jsx, src/GlobalEQ.jsx
This commit is contained in:
2026-06-12 19:54:53 -04:00
parent a4f7d791bc
commit c7f148341d
4 changed files with 1052 additions and 6 deletions
+405
View File
@@ -0,0 +1,405 @@
import { useState, useCallback, useRef, useEffect } from "react";
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",
};
// ── Settings definitions ──
const SETTINGS = {
"Audio": [
{ key: "master_volume", label: "Master Volume", type: "slider", min: 0, max: 100, def: 72, unit: "%", icon: "🔊" },
{ key: "input_pad", label: "Input Pad", type: "select", def: "off",
options: [
{ value: "off", label: "Off (0 dB)" },
{ value: "-6", label: "-6 dB" },
{ value: "-12", label: "-12 dB" },
{ value: "-18", label: "-18 dB" },
], icon: "📥" },
{ key: "input_impedance", label: "Input Impedance", type: "select", def: "1m",
options: [
{ value: "1m", label: "1 MΩ" },
{ value: "500k", label: "500 kΩ" },
{ value: "250k", label: "250 kΩ" },
{ value: "150k", label: "150 kΩ" },
{ value: "68k", label: "68 kΩ" },
], icon: "⚡" },
],
"MIDI": [
{ key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni",
options: [
{ value: "omni", label: "Omni" },
...Array.from({ length: 16 }, (_, i) => ({
value: String(i + 1), label: `Channel ${i + 1}`
})),
], icon: "🎹" },
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, icon: "⏱" },
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, icon: "↔" },
],
"Display": [
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", icon: "☀️" },
{ key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, icon: "🌙" },
{ key: "screen_saver", label: "Screen Saver", type: "select", def: "5m",
options: [
{ value: "off", label: "Off" },
{ value: "1m", label: "1 min" },
{ value: "5m", label: "5 min" },
{ value: "15m", label: "15 min" },
{ value: "30m", label: "30 min" },
], icon: "💤" },
],
"Tempo": [
{ key: "tap_tempo", label: "Tap Tempo", type: "bpm", def: 120, icon: "🔄" },
{ key: "tempo_division", label: "Tempo Division", type: "select", def: "quarter",
options: [
{ value: "half", label: "½ Note" },
{ value: "quarter", label: "¼ Note" },
{ value: "eighth", label: "⅛ Note" },
{ value: "triplet", label: "⅛ Triplet" },
{ value: "sixteenth", label: "¹⁄₁₆ Note" },
{ value: "dotted_eighth", label: "Dotted ⅛" },
], icon: "🎶" },
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, icon: "🔇" },
],
};
// ── Slider Widget ──
function SettingsSlider({ label, value, min, max, unit, onChange }) {
const trackRef = useRef(null);
const dragRef = useRef(false);
const pct = ((value - min) / (max - min)) * 100;
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(); onChange?.(valFromEvent(e)); };
const up = () => { 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, onChange]);
const handleStart = (e) => { e.preventDefault(); dragRef.current = true; onChange?.(valFromEvent(e)); };
const quick = (delta) => (e) => { e.preventDefault(); e.stopPropagation(); onChange?.(Math.max(min, Math.min(max, value + delta))); };
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button style={{
width: 26, height: 26, borderRadius: "50%", border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
}} onMouseDown={quick(-1)} onTouchStart={quick(-1)}></button>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: 14, fontWeight: 700, color: T.amber,
minWidth: 36, textAlign: "center",
}}>{value}{unit}</span>
<button style={{
width: 26, height: 26, borderRadius: "50%", border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 14, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", lineHeight: 1,
}} onMouseDown={quick(1)} onTouchStart={quick(1)}>+</button>
</div>
</div>
<div ref={trackRef}
onMouseDown={handleStart} onTouchStart={handleStart}
style={{
position: "relative", height: 32, display: "flex", alignItems: "center",
cursor: "pointer", touchAction: "none",
}}>
<div style={{
width: "100%", height: 6, background: T.border, borderRadius: 3,
position: "relative", overflow: "hidden",
}}>
<div style={{
height: "100%", borderRadius: 3, position: "absolute", top: 0, left: 0,
width: `${pct}%`, background: T.amber,
boxShadow: `0 0 6px ${T.amber}44`,
transition: "width .03s",
}} />
</div>
<div style={{
width: 24, height: 24, borderRadius: "50%", position: "absolute",
top: "50%", left: `${pct}%`,
transform: "translate(-50%,-50%)",
background: T.amber, border: `3px solid ${T.bg}`,
boxShadow: `0 1px 6px rgba(0,0,0,.5)`,
pointerEvents: "none",
transition: "left .03s",
}} />
</div>
</div>
);
}
// ── Select Widget ──
function SettingsSelect({ label, value, options, onChange, icon }) {
return (
<div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 6,
}}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
</div>
<div style={{
display: "flex", gap: 4, flexWrap: "wrap",
background: T.panel, borderRadius: 6, padding: 4,
}}>
{options.map(opt => (
<button key={opt.value}
onClick={() => onChange?.(opt.value)}
style={{
flex: 1, padding: "6px 8px", borderRadius: 4, border: "none",
fontSize: 10, fontWeight: 600, cursor: "pointer",
background: value === opt.value ? T.amber : "transparent",
color: value === opt.value ? "#000" : T.textSec,
transition: "all .1s", letterSpacing: ".03em",
whiteSpace: "nowrap",
minWidth: 0,
}}
>
{opt.label}
</button>
))}
</div>
</div>
);
}
// ── Toggle Widget ──
function SettingsToggle({ label, value, onChange }) {
return (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
padding: "4px 0",
}}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
<button
onClick={() => onChange?.(!value)}
style={{
width: 44, height: 24, borderRadius: 12,
background: value ? T.amber : T.border,
border: "none", cursor: "pointer", position: "relative",
transition: "background .15s",
}}
>
<div style={{
width: 18, height: 18, borderRadius: "50%",
background: "#fff",
position: "absolute", top: 3, left: value ? 23 : 3,
transition: "left .15s",
boxShadow: "0 1px 3px rgba(0,0,0,.3)",
}} />
</button>
</div>
);
}
// ── BPM/Tap Tempo Widget ──
function BpmWidget({ label, value, onChange }) {
const tapTimes = useRef([]);
const handleTap = useCallback(() => {
const now = Date.now();
tapTimes.current.push(now);
if (tapTimes.current.length > 4) tapTimes.current.shift();
if (tapTimes.current.length < 2) return;
const intervals = [];
for (let i = 1; i < tapTimes.current.length; i++) {
intervals.push(tapTimes.current[i] - tapTimes.current[i - 1]);
}
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const bpm = Math.max(20, Math.min(300, Math.round(60000 / avg)));
onChange?.(bpm);
}, [onChange]);
return (
<div>
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
marginBottom: 6,
}}>
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
</div>
<div style={{
display: "flex", gap: 8, alignItems: "center",
}}>
<button onClick={handleTap}
style={{
padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`,
background: `${T.amber}15`, color: T.amber,
fontSize: 12, fontWeight: 700, cursor: "pointer",
letterSpacing: ".05em",
}}>
TAP
</button>
<div style={{
flex: 1, display: "flex", alignItems: "center", gap: 4,
background: T.surface, borderRadius: 6, border: `1px solid ${T.border}`,
padding: "6px 10px",
}}>
<button onClick={() => onChange?.(Math.max(20, value - 1))}
style={{
background: "none", border: "none", color: T.textDim,
fontSize: 14, cursor: "pointer", padding: "0 4px",
}}></button>
<span style={{
flex: 1, textAlign: "center",
fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber,
}}>{value} BPM</span>
<button onClick={() => onChange?.(Math.min(300, value + 1))}
style={{
background: "none", border: "none", color: T.textDim,
fontSize: 14, cursor: "pointer", padding: "0 4px",
}}>+</button>
</div>
</div>
</div>
);
}
// ── Global Settings ──
export default function GlobalSettings({ onClose, onMasterVolume }) {
const [values, setValues] = useState(() => {
const init = {};
for (const group of Object.values(SETTINGS)) {
for (const s of group) {
init[s.key] = s.def;
}
}
return init;
});
const groups = Object.entries(SETTINGS);
const update = useCallback((key, val) => {
setValues(prev => ({ ...prev, [key]: val }));
// Fire external callback for master volume
if (key === "master_volume") {
onMasterVolume?.(val);
}
}, [onMasterVolume]);
return (
<div style={{
position: "fixed", inset: 0, zIndex: 998,
display: "flex", flexDirection: "column",
background: T.bg, color: T.textPrimary,
fontFamily: "'Inter', sans-serif",
}}>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "10px 14px",
borderBottom: `1px solid ${T.border}`,
background: T.panel, flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button onClick={onClose}
style={{
width: 34, height: 34, borderRadius: 8,
display: "flex", alignItems: "center", justifyContent: "center",
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, fontSize: 16, cursor: "pointer",
}}>
</button>
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Global Settings</div>
</div>
</div>
</div>
{/* Body */}
<div style={{
flex: 1, overflowY: "auto", overflowX: "hidden",
padding: "6px 14px 20px",
}}>
{groups.map(([groupName, settings]) => (
<div key={groupName}>
{/* Group header */}
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: ".1em",
textTransform: "uppercase", color: T.textDim,
padding: "14px 0 8px",
}}>
{groupName}
</div>
{/* Settings */}
<div style={{
display: "flex", flexDirection: "column", gap: 12,
background: T.surface, borderRadius: 8,
border: `1px solid ${T.border}`,
padding: "12px 14px",
}}>
{settings.map(s => (
<div key={s.key} style={{
display: "flex", alignItems: "flex-start", gap: 10,
}}>
{/* Icon */}
<div style={{
width: 28, height: 28, borderRadius: 6, flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
background: T.panel, fontSize: 13,
}}>
{s.icon}
</div>
{/* Control */}
<div style={{ flex: 1, minWidth: 0 }}>
{s.type === "slider" && (
<SettingsSlider
label={s.label}
value={values[s.key] ?? s.def}
min={s.min} max={s.max} unit={s.unit}
onChange={v => update(s.key, v)}
/>
)}
{s.type === "select" && (
<SettingsSelect
label={s.label}
value={values[s.key] ?? s.def}
options={s.options}
onChange={v => update(s.key, v)}
/>
)}
{s.type === "toggle" && (
<SettingsToggle
label={s.label}
value={values[s.key] ?? s.def}
onChange={v => update(s.key, v)}
/>
)}
{s.type === "bpm" && (
<BpmWidget
label={s.label}
value={values[s.key] ?? s.def}
onChange={v => update(s.key, v)}
/>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}