87ee14e4e5
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
452 lines
21 KiB
React
452 lines
21 KiB
React
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>
|
||
);
|
||
}
|