PEDAL-UI: Footswitch modes (stomp/preset) + mode selector in status bar + SVG fx icons from pipedal project

This commit is contained in:
2026-06-12 19:20:39 -04:00
parent 64f1086343
commit 91228cb284
84 changed files with 19852 additions and 783 deletions
+524
View File
@@ -0,0 +1,524 @@
import { useState, useRef, useCallback, useMemo } 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",
};
// ── Block param definitions (mirrored from App.jsx) ──
const BLOCK_PARAMS = {
overdrive: [{ key:'drive', label:'Drive', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }, { key:'level', label:'Level', min:0, max:100, def:70 }],
distortion: [{ key:'drive', label:'Drive', min:0, max:100, def:50 }, { key:'tone', label:'Tone', min:0, max:100, def:40 }, { key:'level', label:'Level', min:0, max:100, def:60 }],
fuzz: [{ key:'fuzz', label:'Fuzz', min:0, max:100, def:60 }, { key:'tone', label:'Tone', min:0, max:100, def:30 }, { key:'level', label:'Level', min:0, max:100, def:60 }],
delay: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }],
echo: [{ key:'time', label:'Time', min:0, max:100, def:40 }, { key:'feedback', label:'Feedback', min:0, max:100, def:30 }, { key:'mix', label:'Mix', min:0, max:100, def:35 }],
reverb: [{ key:'decay', label:'Decay', min:0, max:100, def:50 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }, { key:'tone', label:'Tone', min:0, max:100, def:50 }],
chorus: [{ key:'rate', label:'Rate', min:0, max:100, def:40 }, { key:'depth', label:'Depth', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
flanger: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
phaser: [{ key:'rate', label:'Rate', min:0, max:100, def:30 }, { key:'depth', label:'Depth', min:0, max:100, def:50 }, { key:'feedback', label:'Feedback', min:0, max:100, def:40 }, { key:'mix', label:'Mix', min:0, max:100, def:30 }],
compressor: [{ key:'threshold', label:'Thresh', min:0, max:100, def:50 }, { key:'ratio', label:'Ratio', min:1, max:20, def:4 }, { key:'gain', label:'Gain', min:0, max:100, def:50 }],
gate: [{ key:'threshold', label:'Thresh', min:0, max:100, def:30 }, { key:'release', label:'Release', min:0, max:100, def:50 }],
eq: [{ key:'low', label:'Low', min:-12, max:12, def:0 }, { key:'mid', label:'Mid', min:-12, max:12, def:0 }, { key:'high', label:'High', min:-12, max:12, def:0 }, { key:'level', label:'Level', min:0, max:100, def:70 }],
cabinet: [{ key:'level', label:'Level', min:0, max:100, def:75 }],
ir: [{ key:'level', label:'Level', min:0, max:100, def:75 }],
};
const BLOCK_COLORS = {
overdrive: T.amber, distortion: T.red, fuzz: '#C07030',
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', boost: T.amber,
wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0',
};
const BLOCK_DISPLAY_NAMES = {
overdrive: "OD-808", distortion: "RAT", fuzz: "Big Muff",
delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb",
chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90",
tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate",
eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model",
boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner",
loop: "FX Loop",
};
function getBlockParams(type) {
return BLOCK_PARAMS[(type || '').toLowerCase()] || [{ key:'level', label:'Level', min:0, max:100, def:50 }];
}
function getBlockColor(type) {
return BLOCK_COLORS[(type || '').toLowerCase()] || T.amber;
}
function getBlockDisplayName(block) {
const key = (block.type || '').toLowerCase();
if (block.nam_model_path) {
const parts = block.nam_model_path.split('/').pop()?.replace(/\.(nam|wav|aiff?)$/i, '') || '';
return parts || 'NAM Model';
}
if (block.ir_file_path) {
const parts = block.ir_file_path.split('/').pop()?.replace(/\.(wav|aiff?|ir)$/i, '') || '';
return parts || 'IR Loader';
}
return BLOCK_DISPLAY_NAMES[key] || key.charAt(0).toUpperCase() + key.slice(1);
}
// ── Large touch knob (bigger version for Focus View) ──
function FocusKnob({ label, value = 50, onChange, size = 72, color = T.amber, min = 0, max = 100, compact = false }) {
const startRef = useRef(null);
const norm = (value - min) / (max - min);
const angle = -140 + norm * 280;
const dotH = size * 0.38;
const dotTop = size * 0.5 - dotH;
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 + 16, cx = svgSize / 2, cy = svgSize / 2, r = size / 2 + 4;
const lx = cx + r * Math.cos((-140 - 90) * (Math.PI / 180)), ly = cy + r * Math.sin((-140 - 90) * (Math.PI / 180));
const ex = cx + r * Math.cos((angle - 90) * (Math.PI / 180)), ey = cy + r * Math.sin((angle - 90) * (Math.PI / 180));
const large = norm * 280 > 180 ? 1 : 0;
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: compact ? 2 : 6,
cursor: 'pointer',
}}>
<div style={{ width: size, height: size, position: 'relative', touchAction: 'none' }}
onMouseDown={handleStart} onTouchStart={handleStart}>
<svg style={{ position: 'absolute', inset: -8, width: svgSize, height: svgSize, overflow: 'visible' }}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={T.border} strokeWidth="3" strokeLinecap="round" opacity=".5" />
{norm > 0 && <path d={`M ${lx} ${ly} A ${r} ${r} 0 ${large} 1 ${ex} ${ey}`} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" style={{ filter: `drop-shadow(0 0 4px ${color})` }} />}
</svg>
<div style={{
borderRadius: '50%', width: '100%', height: '100%', position: 'relative',
background: 'radial-gradient(circle at 35% 30%, #3A3A48, #1A1A22)',
boxShadow: '0 3px 12px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.07)',
}}>
<div style={{
position: 'absolute', width: 4, borderRadius: 2, background: color,
left: '50%', transformOrigin: 'bottom center',
height: dotH, top: dotTop,
transform: `translateX(-50%) rotate(${angle}deg)`,
transformOrigin: `50% ${dotH}px`,
boxShadow: `0 0 6px ${color}`,
}} />
</div>
</div>
<div style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: compact ? 11 : 14,
fontWeight: 700, color,
}}>{value}</div>
<div style={{
fontSize: compact ? 7 : 9, color: T.textSec,
letterSpacing: '.06em', textTransform: 'uppercase', textAlign: 'center',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: size + 16,
}}>{label}</div>
</div>
);
}
// ── Large Param Slider (bigger for Focus View) ──
function FocusSlider({ label, value = 50, onChange, onChangeEnd, min = 0, max = 100, color = T.amber }) {
const trackRef = useRef(null);
const dragRef = useRef(false);
const lastValRef = useRef(value);
const onChangeRef = useRef(onChange);
const onChangeEndRef = useRef(onChangeEnd);
const pct = ((value - min) / (max - min)) * 100;
const [touchVal, setTouchVal] = useState(null);
useMemo(() => { onChangeRef.current = onChange; }, [onChange]);
useMemo(() => { onChangeEndRef.current = onChangeEnd; }, [onChangeEnd]);
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]);
useMemo(() => {
const move = (e) => { if (!dragRef.current) return; e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; setTouchVal(v); onChangeRef.current?.(v); };
const up = () => { if (dragRef.current) { onChangeEndRef.current?.(lastValRef.current); dragRef.current = false; setTouchVal(null); } };
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]);
const handleStart = (e) => { e.preventDefault(); const v = valFromEvent(e); lastValRef.current = v; dragRef.current = true; setTouchVal(v); onChangeRef.current?.(v); };
const quick = (delta) => (e) => { e.preventDefault(); e.stopPropagation(); const next = Math.max(min, Math.min(max, value + delta)); onChangeRef.current?.(next); onChangeEndRef.current?.(next); };
return (
<div style={{ marginBottom: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<span style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '.06em', color }}>{label}</span>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button style={{
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1,
}} onMouseDown={quick(-5)} onTouchStart={quick(-5)}></button>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color,
minWidth: 40, textAlign: 'center',
}}>{touchVal ?? value}</span>
<button style={{
width: 34, height: 34, borderRadius: '50%', border: `1px solid ${T.border}`,
background: T.panel, color: T.textPrimary, fontSize: 16, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
lineHeight: 1,
}} onMouseDown={quick(5)} onTouchStart={quick(5)}>+</button>
</div>
</div>
<div ref={trackRef}
onMouseDown={handleStart} onTouchStart={handleStart}
style={{
position: 'relative', height: 44, display: 'flex', alignItems: 'center',
cursor: 'pointer', touchAction: 'none',
}}>
<div style={{
width: '100%', height: 10, background: T.border, borderRadius: 5,
position: 'relative', overflow: 'hidden',
}}>
<div style={{
height: '100%', borderRadius: 5, position: 'absolute', top: 0, left: 0,
width: `${pct}%`, background: color,
boxShadow: `0 0 8px ${color}44`,
transition: 'width .03s',
}} />
</div>
<div style={{
width: 30, height: 30, borderRadius: '50%', position: 'absolute',
top: '50%', left: `${pct}%`,
transform: 'translate(-50%,-50%)',
background: color,
boxShadow: `0 2px 10px rgba(0,0,0,.6), 0 0 0 3px ${T.bg}`,
pointerEvents: 'none',
transition: 'left .03s',
}} />
</div>
</div>
);
}
// ── Parameter page (single page of up to 8 params) ──
function ParamPage({ defs, params, viewMode, color, onParamChange, onParamChangeEnd, compact }) {
return (
<div style={{
display: 'flex', flexDirection: 'column', gap: viewMode === 'knob' ? 8 : 0,
width: '100%', minWidth: 0, flexShrink: 0,
}}>
{viewMode === 'slider' ? (
defs.map(p => (
<FocusSlider key={p.key} label={p.label}
value={params[p.key] != null ? params[p.key] : p.def}
min={p.min} max={p.max} color={color}
onChange={v => onParamChange(p.key, v)}
onChangeEnd={v => onParamChangeEnd(p.key, v)} />
))
) : (
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${compact ? 4 : Math.min(4, defs.length)}, 1fr)`,
gap: 12,
padding: '8px 0',
justifyItems: 'center',
width: '100%',
maxWidth: 480,
margin: '0 auto',
}}>
{defs.map(p => (
<FocusKnob key={p.key} label={p.label} size={compact ? 60 : 72}
value={params[p.key] != null ? params[p.key] : p.def}
min={p.min} max={p.max} color={color}
onChange={v => { onParamChange(p.key, v); onParamChangeEnd(p.key, v); }}
compact={compact} />
))}
</div>
)}
</div>
);
}
// ── Focus View (full-screen overlay) ──
export default function FocusView({
block,
params,
viewMode,
onClose,
onParamChange,
onParamChangeEnd,
onToggleView,
onToggleBypass,
onOpenModelBrowser,
}) {
const defs = getBlockParams(block.type);
const color = getBlockColor(block.type);
const displayName = getBlockDisplayName(block);
// Pagination — max 8 params per page
const PER_PAGE = 8;
const pages = useMemo(() => {
const p = [];
for (let i = 0; i < defs.length; i += PER_PAGE) {
p.push(defs.slice(i, i + PER_PAGE));
}
return p;
}, [defs]);
const [currentPage, setCurrentPage] = useState(0);
const scrollRef = useRef(null);
const touchStartX = useRef(null);
const [isCompact, setIsCompact] = useState(window.innerWidth < 500);
// Track resize for compact layout
useMemo(() => {
const onResize = () => setIsCompact(window.innerWidth < 500);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// ── Swipe handling ──
const handleTouchStart = useCallback((e) => {
touchStartX.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback((e) => {
if (touchStartX.current == null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(dx) < 50) return; // threshold
if (dx < 0 && currentPage < pages.length - 1) {
setCurrentPage(p => Math.min(p + 1, pages.length - 1));
} else if (dx > 0 && currentPage > 0) {
setCurrentPage(p => Math.max(p - 1, 0));
}
}, [currentPage, pages.length]);
// ── Keyboard nav ──
useMemo(() => {
const handler = (e) => {
if (e.key === 'ArrowLeft' && currentPage > 0) setCurrentPage(p => p - 1);
if (e.key === 'ArrowRight' && currentPage < pages.length - 1) setCurrentPage(p => p + 1);
if (e.key === 'Escape') onClose?.();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [currentPage, pages.length, onClose]);
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', flexDirection: 'column',
background: T.bg,
fontFamily: "'Inter', sans-serif",
color: T.textPrimary,
}}>
{/* ── 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, minWidth: 0 }}>
<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',
flexShrink: 0,
}}>
</button>
<div style={{ minWidth: 0 }}>
<div style={{
fontSize: 15, fontWeight: 600, whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{displayName}
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 2,
}}>
<span style={{
display: 'inline-flex', padding: '1px 6px', borderRadius: 3,
fontSize: 9, fontWeight: 700, letterSpacing: '.06em',
textTransform: 'uppercase',
background: `${color}22`, color,
}}>
{(block.type || 'fx').replace(/_/g, ' ').toUpperCase()}
</span>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
{/* Slider/Knob toggle */}
<button onClick={onToggleView}
style={{
padding: '6px 10px', borderRadius: 6, fontSize: 10, fontWeight: 600,
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, cursor: 'pointer', letterSpacing: '.04em',
}}>
{viewMode === 'slider' ? '◉ Knobs' : '▦ Sliders'}
</button>
</div>
</div>
{/* ── Bypass toggle (prominent) ── */}
<div style={{
padding: '6px 14px',
borderBottom: `1px solid ${T.border}`,
background: T.surface,
flexShrink: 0,
}}>
<button onClick={onToggleBypass}
style={{
width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '10px 14px',
borderRadius: 8,
border: `2px solid ${block.bypassed ? T.red + '66' : T.green + '66'}`,
background: block.bypassed
? `linear-gradient(180deg, ${T.red + '18'}, ${T.panel})`
: `linear-gradient(180deg, ${T.green + '18'}, ${T.panel})`,
cursor: 'pointer',
transition: 'all .12s',
}}>
<div style={{
width: 12, height: 12, borderRadius: '50%',
background: block.bypassed ? T.textDim : T.green,
boxShadow: block.bypassed ? 'none' : `0 0 8px ${T.green}`,
}} />
<span style={{
fontSize: 12, fontWeight: 700, letterSpacing: '.08em', textTransform: 'uppercase',
color: block.bypassed ? T.red : T.green,
}}>
{block.bypassed ? 'Bypassed — Tap to Enable' : 'Active — Tap to Bypass'}
</span>
</button>
</div>
{/* ── Parameter area (scrollable with pages) ── */}
<div
ref={scrollRef}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{
flex: 1, overflowY: 'auto', overflowX: 'hidden',
padding: '12px 16px',
display: 'flex', flexDirection: 'column',
gap: 6,
}}>
{pages.length === 0 ? (
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: T.textDim, fontSize: 12,
}}>
No parameters for this block type
</div>
) : (
<>
{/* Page content */}
<ParamPage
defs={pages[currentPage]}
params={params}
viewMode={viewMode}
color={color}
onParamChange={onParamChange}
onParamChangeEnd={onParamChangeEnd}
compact={isCompact}
/>
{/* Spacer so page dots don't crowd content */}
<div style={{ flex: 1, minHeight: 8 }} />
</>
)}
</div>
{/* ── Bottom bar: Model browser + page indicators ── */}
<div style={{
padding: '8px 14px 12px',
borderTop: `1px solid ${T.border}`,
background: T.panel,
flexShrink: 0,
display: 'flex', flexDirection: 'column', gap: 6,
}}>
{/* Page dots */}
{pages.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 5 }}>
{pages.map((_, i) => (
<button key={i} onClick={() => setCurrentPage(i)}
style={{
width: 8, height: 8, borderRadius: '50%', border: 'none',
padding: 0, cursor: 'pointer',
background: i === currentPage ? color : T.border,
opacity: i === currentPage ? 1 : 0.4,
transition: 'all .12s',
}} />
))}
</div>
)}
{/* Page indicator text */}
{pages.length > 1 && (
<div style={{
textAlign: 'center', fontSize: 9, color: T.textDim,
fontFamily: "'JetBrains Mono', monospace", letterSpacing: '.04em',
}}>
Page {currentPage + 1} / {pages.length}
<span style={{ marginLeft: 8, opacity: 0.6 }}>
swipe to navigate
</span>
</div>
)}
{/* Model browser button / swap block */}
{onOpenModelBrowser && (
<button onClick={onOpenModelBrowser}
style={{
width: '100%',
padding: '8px 14px', borderRadius: 7,
background: T.surface, border: `1px solid ${T.border}`,
color: T.textPrimary, fontSize: 11, fontWeight: 600,
cursor: 'pointer', letterSpacing: '.04em',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<span>🎛</span> Swap Model / Block Type
</button>
)}
</div>
</div>
);
}