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

525 lines
22 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, 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>
);
}