Files
pi-multifx-pedal-ui/src/GlobalSettings.jsx
T
shawn 87ee14e4e5 v2 audit: contrast, dark-glow, layout, palette, sizing fixes
All fixes from impeccable browser audit:
- Removed/reduced 18 dark-glow box-shadows (AI tells)
- Fixed contrast: muted text, badge-blue, badge-amber, kebab opacity
- Removed cyan neon palette (#60C0D0 → #5090A0, #60A0E0 → #6080B0)
- Changed layout transitions from width to transform (jank-free)
- Bumped tiny text sizes (9px→10px, 10px→11px)
- Fixed cramped padding on preset chips
2026-06-14 18:50:23 -04:00

452 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { T } from "./theme.js";
async function api(method, path, body) {
const opts = { method, headers: { "Content-Type": "application/json" } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${path}`, opts);
if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`);
return res.json();
}
const SETTINGS = {
"Audio": [
{ key: "master_volume", label: "Master Volume", type: "slider", min: 0, max: 100, def: 72, unit: "%", icon: "🔊",
via: "volume" },
{ key: "sample_rate", label: "Sample Rate", type: "select", def: "48000",
options: [
{ value: "22050", label: "22.05 kHz" },
{ value: "44100", label: "44.1 kHz" },
{ value: "48000", label: "48 kHz" },
{ value: "88200", label: "88.2 kHz" },
{ value: "96000", label: "96 kHz" },
], icon: "📊", via: "audio_profile" },
{ key: "buffer_size", label: "Buffer Size", type: "select", def: "512",
options: [
{ value: "64", label: "64 (1.3ms)" },
{ value: "128", label: "128 (2.7ms)" },
{ value: "256", label: "256 (5.3ms)" },
{ value: "512", label: "512 (10.6ms)" },
{ value: "1024",label: "1024 (21.3ms)" },
], icon: "📦", via: "audio_profile" },
{ 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: "📥", via: "settings" },
{ 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: "⚡", via: "settings" },
],
"Routing": [
{ key: "routing_mode", label: "Routing Mode", type: "select", def: "mono",
options: [
{ value: "mono", label: "Mono" },
{ value: "4cm", label: "4CM (Stereo FX Loop)" },
], icon: "🔀", via: "routing" },
{ key: "routing_breakpoint", label: "FX Loop Breakpoint", type: "select", def: "7",
options: [
{ value: "1", label: "After block 1" },
{ value: "2", label: "After block 2" },
{ value: "3", label: "After block 3" },
{ value: "4", label: "After block 4" },
{ value: "5", label: "After block 5" },
{ value: "6", label: "After block 6" },
{ value: "7", label: "After block 7" },
{ value: "8", label: "After block 8" },
], icon: "↔", via: "routing" },
],
"Audio Interface": [
{ key: "input_device", label: "Input Device", type: "select", def: "hw:0,0",
options: [
{ value: "hw:0,0", label: "Default (hw:0,0)" },
{ value: "hw:1,0", label: "USB Audio (hw:1,0)" },
{ value: "hw:2,0", label: "HDMI Audio (hw:2,0)" },
], icon: "🎤", via: "settings" },
{ key: "output_device", label: "Output Device", type: "select", def: "hw:0,0",
options: [
{ value: "hw:0,0", label: "Default (hw:0,0)" },
{ value: "hw:1,0", label: "USB Audio (hw:1,0)" },
{ value: "hw:2,0", label: "HDMI Audio (hw:2,0)" },
], icon: "🔈", via: "settings" },
{ key: "channel_mode", label: "Channel Mode", type: "select", def: "mono",
options: [
{ value: "mono", label: "Mono" },
{ value: "dual-mono", label: "Dual Mono" },
{ value: "stereo_4cm", label: "Stereo (4CM)" },
], icon: "🔊", via: "settings" },
],
"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: "🎹", via: "settings" },
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, via: "settings", icon: "⏱" },
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, via: "settings", icon: "↔" },
],
"Display": [
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", icon: "☀️", via: "settings" },
{ key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, via: "settings", 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: "💤", via: "settings" },
],
"Tempo": [
{ key: "tap_tempo", label: "Tap Tempo", type: "bpm", def: 120, via: "settings", 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: "🎶", via: "settings" },
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, via: "settings", icon: "🔇" },
],
};
function buildDefaults(overrides = {}) {
const init = {};
for (const group of Object.values(SETTINGS)) {
for (const s of group) {
init[s.key] = s.def;
}
}
return { ...init, ...overrides };
}
// ── Widgets ────────────────────────────────────────────────────────────────
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: 44, height: 44, 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: 44, height: 44, 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: "100%", background: T.amber, transform: `scaleX(${pct/100})`, transformOrigin: "left center", transition: "transform .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>
);
}
function SettingsSelect({ label, value, options, 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: 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>
);
}
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>
);
}
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, onAudioProfile, onRoutingChange, onSaveSettings }) {
const [values, setValues] = useState(buildDefaults());
const [savedValues, setSavedValues] = useState(buildDefaults());
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState(null); // "saved" | "error" | null
useEffect(() => {
let cancelled = false;
Promise.all([
api("GET", "/api/state").catch(() => null),
api("GET", "/api/audio/profile").catch(() => null),
api("GET", "/api/routing").catch(() => null),
]).then(([state, audioProfile, routing]) => {
if (cancelled) return;
const overrides = {};
if (state) {
if (state.master_volume != null) overrides.master_volume = Math.round(state.master_volume * 100);
if (state.sample_rate != null) overrides.sample_rate = String(state.sample_rate);
if (state.channel_mode) overrides.channel_mode = state.channel_mode;
}
if (audioProfile) {
if (audioProfile.rate != null) overrides.sample_rate = String(audioProfile.rate);
if (audioProfile.period != null) overrides.buffer_size = String(audioProfile.period);
}
if (routing) {
if (routing.routing_mode) overrides.routing_mode = routing.routing_mode;
if (routing.routing_breakpoint != null) overrides.routing_breakpoint = String(routing.routing_breakpoint);
}
const merged = { ...buildDefaults(), ...overrides };
setValues(merged);
setSavedValues(merged);
setLoaded(true);
});
return () => { cancelled = true; };
}, []);
const hasChanges = useMemo(() => {
return Object.keys(values).some(k => values[k] !== savedValues[k]);
}, [values, savedValues]);
const groups = Object.entries(SETTINGS);
const update = useCallback((key, val) => {
setValues(prev => ({ ...prev, [key]: val }));
setSaveMsg(null);
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveMsg(null);
const changed = {};
for (const k of Object.keys(values)) {
if (values[k] !== savedValues[k]) {
changed[k] = values[k];
}
}
// Apply instant-effect settings via dedicated endpoints first
const promises = [];
if (changed.master_volume != null) {
onMasterVolume?.(changed.master_volume);
}
if (changed.sample_rate != null || changed.buffer_size != null) {
const rate = changed.sample_rate || values.sample_rate || "48000";
const period = changed.buffer_size || values.buffer_size || "512";
onAudioProfile?.(parseInt(rate), parseInt(period));
}
if (changed.routing_mode != null || changed.routing_breakpoint != null) {
const mode = changed.routing_mode || values.routing_mode || "mono";
const bp = changed.routing_breakpoint || values.routing_breakpoint || "7";
onRoutingChange?.(mode, parseInt(bp));
}
// Everything else → bulk POST /api/settings
const settingsChanged = Object.fromEntries(
Object.entries(changed).filter(([k]) =>
!["master_volume", "sample_rate", "buffer_size", "routing_mode", "routing_breakpoint"].includes(k)
)
);
if (Object.keys(settingsChanged).length > 0) {
promises.push(
api("POST", "/api/settings", settingsChanged)
.then(() => {})
.catch(() => { throw new Error("Save failed"); })
);
}
try {
await Promise.all(promises);
setSavedValues({ ...values });
setSaveMsg("saved");
setTimeout(() => { setSaveMsg(null); }, 2000);
} catch {
setSaveMsg("error");
}
setSaving(false);
}, [values, savedValues, onMasterVolume, onAudioProfile, onRoutingChange]);
const handleDiscard = useCallback(() => {
setValues({ ...savedValues });
setSaveMsg(null);
}, [savedValues]);
return (
<div style={{ position: "fixed", inset: 0, zIndex: 998, display: "flex", flexDirection: "column", background: T.bg, color: T.textPrimary, fontFamily: "'Space Grotesk', sans-serif" }}>
<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: 44, height: 44, 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 }}>Settings</div>
{!loaded && <span style={{ fontSize: 10, color: T.textDim }}>Loading</span>}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{saveMsg === "saved" && (
<span style={{ fontSize: 10, fontWeight: 600, color: T.green }}> Saved</span>
)}
{saveMsg === "error" && (
<span style={{ fontSize: 10, fontWeight: 600, color: T.red }}> Save failed</span>
)}
{hasChanges && (
<span style={{ fontSize: 10, color: T.amber, fontWeight: 600 }}>Unsaved</span>
)}
{hasChanges && (
<button onClick={handleDiscard}
style={{ padding: "6px 12px", borderRadius: 6, border: `1px solid ${T.border}`, background: T.surface, color: T.textSec, fontSize: 11, fontWeight: 600, cursor: "pointer" }}>
Discard
</button>
)}
<button onClick={handleSave} disabled={!hasChanges || saving}
style={{
padding: "6px 14px", borderRadius: 6, border: "none",
background: hasChanges ? T.amber : T.border, color: hasChanges ? "#000" : T.textDim,
fontSize: 11, fontWeight: 700, cursor: hasChanges ? "pointer" : "default",
opacity: saving ? 0.6 : 1,
}}>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: "6px 14px 20px" }}>
{groups.map(([groupName, settings]) => (
<div key={groupName}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase", color: T.textDim, padding: "14px 0 8px" }}>{groupName}</div>
<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 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", background: T.panel, fontSize: 13 }}>{s.icon}</div>
<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>
);
}