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

390 lines
19 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, 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",
};
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: 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>
);
}
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 [loaded, setLoaded] = useState(false);
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);
}
setValues(prev => ({ ...prev, ...overrides }));
setLoaded(true);
});
return () => { cancelled = true; };
}, []);
const groups = Object.entries(SETTINGS);
const update = useCallback((key, val) => {
setValues(prev => {
const next = { ...prev, [key]: val };
// Route via dedicated endpoints
if (key === "master_volume") onMasterVolume?.(val);
else if (key === "sample_rate" || key === "buffer_size") {
const rate = key === "sample_rate" ? val : (next.sample_rate || "48000");
const period = key === "buffer_size" ? val : (next.buffer_size || "512");
onAudioProfile?.(parseInt(rate), parseInt(period));
}
else if (key === "routing_mode" || key === "routing_breakpoint") {
const mode = key === "routing_mode" ? val : (next.routing_mode || "mono");
const bp = key === "routing_breakpoint" ? val : (next.routing_breakpoint || "7");
onRoutingChange?.(mode, parseInt(bp));
}
else {
// All other settings → POST /api/settings (generic persistence)
onSaveSettings?.({ [key]: val });
}
return next;
});
}, [onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings]);
return (
<div style={{ position: "fixed", inset: 0, zIndex: 998, display: "flex", flexDirection: "column", background: T.bg, color: T.textPrimary, fontFamily: "'Inter', 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: 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 }}>Settings</div>
{!loaded && <span style={{ fontSize: 10, color: T.textDim }}>Loading</span>}
</div>
</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>
);
}