feat: Model Browser — block library with category filter tabs, search, favorites, NAM/IR models
This commit is contained in:
+57
-7
@@ -3,6 +3,7 @@ import BlockChain from "./BlockChain.jsx";
|
||||
import FootswitchBar from "./FootswitchBar.jsx";
|
||||
import TunerScreen from "./Tuner.jsx";
|
||||
import FocusView from "./FocusView.jsx";
|
||||
import ModelBrowser from "./ModelBrowser.jsx";
|
||||
|
||||
// ── API Layer ──────────────────────────────────────────────────────
|
||||
const API_BASE = "";
|
||||
@@ -343,13 +344,17 @@ function SelectParam({ def, value, color, onChange }) {
|
||||
}
|
||||
|
||||
// ── Parameter Panel ──────────────────────────────────────────
|
||||
function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView,onFocus }) {
|
||||
function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,onToggleView,onFocus,onModelBrowser }) {
|
||||
const defs=getBlockParams(block.type);const color=getBlockColor(block.type);
|
||||
const visibleDefs = defs.filter(d => !d.conditional || d.conditional(params));
|
||||
return (
|
||||
<div className="param-panel">
|
||||
<div className="param-panel-header">
|
||||
<span className="param-panel-title"><span style={{color}}>●</span> {block.name||block.type||''}</span>
|
||||
<span className="param-panel-title" style={{cursor:onModelBrowser?'pointer':'default'}}
|
||||
onClick={()=>onModelBrowser?.(block.id)}>
|
||||
<span style={{color}}>●</span> {block.name||block.type||''}
|
||||
{onModelBrowser&&<span style={{marginLeft:4,fontSize:8,color:T.textDim}}>▼</span>}
|
||||
</span>
|
||||
<div style={{display:'flex',gap:4,alignItems:'center'}}>
|
||||
{onFocus&&<button className="btn" style={{padding:'4px 8px',fontSize:9,fontWeight:600,background:`${T.amber}22`,border:`1px solid ${T.amber}44`,color:T.amber,borderRadius:5,letterSpacing:'.04em',cursor:'pointer'}}
|
||||
onClick={onFocus}>🔍 Focus</button>}
|
||||
@@ -450,6 +455,7 @@ export default function App(){
|
||||
const[globalBypass,setGlobalBypass]=useState(false);
|
||||
const[loadingPreset,setLoadingPreset]=useState(false);
|
||||
const[focusBlockId,setFocusBlockId]=useState(null);
|
||||
const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
|
||||
const[footswitchMode,setFootswitchMode]=useState("stomp");
|
||||
|
||||
// ── Poll pedal state from API ──
|
||||
@@ -604,10 +610,37 @@ export default function App(){
|
||||
setTimeout(()=>{userChangedBlock.current=false;},3000);
|
||||
},[focusBlockId]);
|
||||
|
||||
const handleOpenFocusModelBrowser=useCallback(()=>{
|
||||
setView("captures");
|
||||
setFocusBlockId(null);
|
||||
},[]);
|
||||
const handleOpenModelBrowser=useCallback((blockId)=>{
|
||||
setModelBrowserBlockId(blockId);
|
||||
if(focusBlockId) setFocusBlockId(null);
|
||||
},[focusBlockId]);
|
||||
|
||||
const handleSelectModel=useCallback(({type, name, modelPath, isBuiltin})=>{
|
||||
const id=modelBrowserBlockId;
|
||||
if(!id)return;
|
||||
userChangedBlock.current=true;
|
||||
setBlocks(prev=>prev.map(b=>{
|
||||
if(b.id!==id)return b;
|
||||
const defs=getBlockParams(type);
|
||||
const init={};
|
||||
defs.forEach(p=>{init[p.key]=p.def;});
|
||||
return {
|
||||
...b,
|
||||
type,
|
||||
name: name || getBlockDisplayName({type}),
|
||||
params: init,
|
||||
nam_model_path: (!isBuiltin && modelPath && type==='nam') ? modelPath : undefined,
|
||||
ir_file_path: (!isBuiltin && modelPath && type==='ir') ? modelPath : undefined,
|
||||
};
|
||||
}));
|
||||
setModelBrowserBlockId(null);
|
||||
setBlockParams(prev=>{
|
||||
const next={...prev};
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setTimeout(()=>{userChangedBlock.current=false;},3000);
|
||||
},[modelBrowserBlockId]);
|
||||
|
||||
// ── Render ──
|
||||
return(
|
||||
@@ -720,6 +753,7 @@ export default function App(){
|
||||
onReorder={handleReorder}
|
||||
onAddBlock={handleAddBlock}
|
||||
onRemoveBlock={handleRemoveBlock}
|
||||
onModelBrowser={handleOpenModelBrowser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -733,6 +767,7 @@ export default function App(){
|
||||
onParamChange={(key,value)=>handleParamChange(selectedBlockId,key,value)}
|
||||
onParamChangeEnd={(key,value)=>handleParamChangeEnd(selectedBlockId,key,value)}
|
||||
onFocus={()=>setFocusBlockId(selectedBlockId)}
|
||||
onModelBrowser={handleOpenModelBrowser}
|
||||
/>
|
||||
):(
|
||||
<div style={{flex:1,display:"flex",alignItems:"center",justifyContent:"center",background:T.bg,color:T.textDim,fontSize:11}}>
|
||||
@@ -774,7 +809,7 @@ export default function App(){
|
||||
onParamChangeEnd={(key,value)=>handleParamChangeEnd(focusBlockId,key,value)}
|
||||
onToggleView={()=>setParamView(p=>p==='slider'?'knob':'slider')}
|
||||
onToggleBypass={handleFocusViewBypass}
|
||||
onOpenModelBrowser={handleOpenFocusModelBrowser}
|
||||
onOpenModelBrowser={()=>handleOpenModelBrowser(focusBlockId)}
|
||||
/>
|
||||
)}
|
||||
{/* ── Overlays ── */}
|
||||
@@ -809,6 +844,21 @@ export default function App(){
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Model Browser ── */}
|
||||
{modelBrowserBlockId&&(()=>{
|
||||
const block=blocks.find(b=>b.id===modelBrowserBlockId);
|
||||
if(!block)return null;
|
||||
return (
|
||||
<ModelBrowser
|
||||
currentBlockType={block.type}
|
||||
currentBlockName={block.name}
|
||||
currentModelPath={block.nam_model_path||block.ir_file_path}
|
||||
onSelectModel={handleSelectModel}
|
||||
onClose={()=>setModelBrowserBlockId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
</div></>
|
||||
);
|
||||
}
|
||||
|
||||
+70
-4
@@ -163,17 +163,37 @@ function VertBracket({ height, direction }) {
|
||||
}
|
||||
|
||||
// ── Split Block Tile ─────────────────────────────
|
||||
function SplitBlockTile({ block, selected, onSelect, onToggle }) {
|
||||
function SplitBlockTile({ block, selected, onSelect, onToggle, onModelBrowser }) {
|
||||
const splitParams = block.params || {};
|
||||
const splitType = splitParams.splitType || "y";
|
||||
const st = SPLIT_TYPES[splitType] || SPLIT_TYPES.y;
|
||||
const enabled = !block.bypassed;
|
||||
const color = getBlockColor("split");
|
||||
const longPressRef = useRef(null);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
longPressRef.current = setTimeout(() => {
|
||||
longPressRef.current = null;
|
||||
onModelBrowser?.(block.id);
|
||||
}, 500);
|
||||
}, [block.id, onModelBrowser]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (longPressRef.current) {
|
||||
clearTimeout(longPressRef.current);
|
||||
longPressRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onClick={() => onSelect(block.id)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchEnd}
|
||||
onMouseDown={handleTouchEnd}
|
||||
onMouseUp={handleTouchEnd}
|
||||
style={{
|
||||
minWidth: 130, width: 130, height: 76, borderRadius: 8,
|
||||
background: `linear-gradient(135deg, ${T.panel}, #1A1520)`,
|
||||
@@ -237,14 +257,34 @@ function SplitBlockTile({ block, selected, onSelect, onToggle }) {
|
||||
}
|
||||
|
||||
// ── Merge Block Tile ──────────────────────────────
|
||||
function MergeBlockTile({ block, selected, onSelect, onToggle }) {
|
||||
function MergeBlockTile({ block, selected, onSelect, onToggle, onModelBrowser }) {
|
||||
const mergeParams = block.params || {};
|
||||
const enabled = !block.bypassed;
|
||||
const longPressRef = useRef(null);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
longPressRef.current = setTimeout(() => {
|
||||
longPressRef.current = null;
|
||||
onModelBrowser?.(block.id);
|
||||
}, 500);
|
||||
}, [block.id, onModelBrowser]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (longPressRef.current) {
|
||||
clearTimeout(longPressRef.current);
|
||||
longPressRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onClick={() => onSelect(block.id)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchEnd}
|
||||
onMouseDown={handleTouchEnd}
|
||||
onMouseUp={handleTouchEnd}
|
||||
style={{
|
||||
minWidth: 130, width: 130, height: 76, borderRadius: 8,
|
||||
background: `linear-gradient(135deg, #15201A, ${T.panel})`,
|
||||
@@ -304,14 +344,34 @@ function MergeBlockTile({ block, selected, onSelect, onToggle }) {
|
||||
}
|
||||
|
||||
// ── Regular Block Tile ────────────────────────────
|
||||
function BlockTile({ block, selected, onSelect, onToggle, pathLabel }) {
|
||||
function BlockTile({ block, selected, onSelect, onToggle, pathLabel, onModelBrowser }) {
|
||||
const color = getBlockColor(block.type);
|
||||
const enabled = !block.bypassed;
|
||||
const longPressRef = useRef(null);
|
||||
|
||||
const handleTouchStart = useCallback(() => {
|
||||
longPressRef.current = setTimeout(() => {
|
||||
longPressRef.current = null;
|
||||
onModelBrowser?.(block.id);
|
||||
}, 500);
|
||||
}, [block.id, onModelBrowser]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
if (longPressRef.current) {
|
||||
clearTimeout(longPressRef.current);
|
||||
longPressRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onClick={() => onSelect(block.id)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchMove={handleTouchEnd}
|
||||
onMouseDown={handleTouchEnd}
|
||||
onMouseUp={handleTouchEnd}
|
||||
style={{
|
||||
minWidth: 120, width: 120, height: 76, borderRadius: 8,
|
||||
background: pathLabel === "B" ? "#1A1820" : T.panel,
|
||||
@@ -435,7 +495,7 @@ function buildChainModel(blocks) {
|
||||
// ── Block Chain (exported) ─────────────────────────
|
||||
export default function BlockChain({
|
||||
blocks, selectedBlockId, onSelectBlock, onToggleBlock,
|
||||
onReorder, onAddBlock, onRemoveBlock,
|
||||
onReorder, onAddBlock, onRemoveBlock, onModelBrowser,
|
||||
}) {
|
||||
const scrollRef = useRef(null);
|
||||
const scrollBRef = useRef(null);
|
||||
@@ -528,6 +588,7 @@ export default function BlockChain({
|
||||
index={idx}
|
||||
onSelect={onSelectBlock}
|
||||
onToggle={onToggleBlock}
|
||||
onModelBrowser={onModelBrowser}
|
||||
/>
|
||||
<ChainArrow />
|
||||
</div>
|
||||
@@ -585,6 +646,7 @@ export default function BlockChain({
|
||||
index={idx}
|
||||
onSelect={onSelectBlock}
|
||||
onToggle={onToggleBlock}
|
||||
onModelBrowser={onModelBrowser}
|
||||
/>
|
||||
<ChainArrow />
|
||||
</div>
|
||||
@@ -597,6 +659,7 @@ export default function BlockChain({
|
||||
selected={splitBlock.id === selectedBlockId}
|
||||
onSelect={onSelectBlock}
|
||||
onToggle={onToggleBlock}
|
||||
onModelBrowser={onModelBrowser}
|
||||
/>
|
||||
<ChainArrow />
|
||||
</div>
|
||||
@@ -611,6 +674,7 @@ export default function BlockChain({
|
||||
onSelect={onSelectBlock}
|
||||
onToggle={onToggleBlock}
|
||||
pathLabel="A"
|
||||
onModelBrowser={onModelBrowser}
|
||||
/>
|
||||
<ChainArrow />
|
||||
</div>
|
||||
@@ -626,6 +690,7 @@ export default function BlockChain({
|
||||
selected={mergeBlock.id === selectedBlockId}
|
||||
onSelect={onSelectBlock}
|
||||
onToggle={onToggleBlock}
|
||||
onModelBrowser={onModelBrowser}
|
||||
/>
|
||||
<ChainArrow />
|
||||
</div>
|
||||
@@ -639,6 +704,7 @@ export default function BlockChain({
|
||||
index={idx}
|
||||
onSelect={onSelectBlock}
|
||||
onToggle={onToggleBlock}
|
||||
onModelBrowser={onModelBrowser}
|
||||
/>
|
||||
<ChainArrow />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } 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",
|
||||
};
|
||||
|
||||
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
|
||||
|
||||
// ── Category definitions ──
|
||||
const CATEGORY_DEFS = {
|
||||
"Distortions": { icon: "fx_distortion", color: T.red },
|
||||
"Dynamics": { icon: "fx_compressor", color: T.green },
|
||||
"Modulations": { icon: "fx_modulator", color: T.blue },
|
||||
"Delays": { icon: "fx_delay", color: "#60A0E0" },
|
||||
"Reverbs": { icon: "fx_reverb", color: "#60C0D0" },
|
||||
"Filters": { icon: "fx_filter", color: "#D0A060" },
|
||||
"Amps": { icon: "fx_amplifier", color: "#E0A040" },
|
||||
"Preamps": { icon: "fx_simulator", color: "#D08040" },
|
||||
"Cabs": { icon: "fx_spatial", color: "#D07090" },
|
||||
"IRs": { icon: "fx_spatial", color: "#70B090" },
|
||||
"Pitch/Synth": { icon: "fx_pitch", color: "#A060E0" },
|
||||
"Volume/Pan": { icon: "fx_constant", color: T.textSec },
|
||||
"Looper": { icon: "fx_oscillator", color: "#80D0A0" },
|
||||
"Send/Return": { icon: "fx_lr", color: "#9090C0" },
|
||||
"Special": { icon: "fx_utility", color: "#B080C0" },
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"Distortions", "Dynamics", "Modulations", "Delays", "Reverbs",
|
||||
"Filters", "Amps", "Preamps", "Cabs", "IRs",
|
||||
"Pitch/Synth", "Volume/Pan", "Looper", "Send/Return", "Special",
|
||||
];
|
||||
|
||||
const CATEGORY_HIDE = ["Preamps", "Looper", "Pitch/Synth"]; // hide empty categories
|
||||
|
||||
// ── Built-in block types mapped to categories ──
|
||||
const TYPE_TO_CATEGORY = {
|
||||
overdrive: "Distortions", disturbance: "Distortions", distortion: "Distortions",
|
||||
fuzz: "Distortions", boost: "Distortions", wah: "Filters",
|
||||
delay: "Delays", echo: "Delays",
|
||||
reverb: "Reverbs",
|
||||
chorus: "Modulations", flanger: "Modulations", phaser: "Modulations",
|
||||
tremolo: "Modulations", vibrato: "Modulations", rotary: "Modulations",
|
||||
compressor: "Dynamics", gate: "Dynamics", limiter: "Dynamics",
|
||||
eq: "Filters", filter: "Filters",
|
||||
pitch: "Pitch/Synth", harmonizer: "Pitch/Synth", shifter: "Pitch/Synth",
|
||||
nam: "Amps", amplifier: "Amps",
|
||||
cabinet: "Cabs", cab: "Cabs",
|
||||
ir: "IRs",
|
||||
volume: "Volume/Pan", level: "Volume/Pan",
|
||||
looper: "Looper",
|
||||
loop: "Send/Return",
|
||||
split: "Special", merge: "Special", tuner: "Special",
|
||||
instrument: "Special", plugin: "Special", utility: "Special",
|
||||
mixer: "Special", converter: "Special", reamp: "Special",
|
||||
};
|
||||
|
||||
// ── Type-to-color (same palette as BlockChain) ──
|
||||
const TYPE_COLORS = {
|
||||
od: T.amber, overdrive: T.amber, drive: T.amber,
|
||||
dist: T.red, distortion: T.red,
|
||||
fuzz: "#D060E0",
|
||||
mod: T.blue, chorus: T.blue, flange: T.blue, phaser: T.blue,
|
||||
tremolo: T.blue, vibrato: T.blue, rotary: T.blue,
|
||||
delay: "#60A0E0", echo: "#60A0E0", reverb: "#60C0D0",
|
||||
pitch: "#A060E0", comp: T.green, compressor: T.green,
|
||||
gate: T.green, filter: "#D0A060", wah: "#D0A060",
|
||||
eq: "#80A0B0", ir: T.green, cab: T.green, nam: "#E0A040",
|
||||
volume: T.textSec, boost: T.amber,
|
||||
split: "#B080C0", merge: "#80B0C0",
|
||||
};
|
||||
|
||||
// ── Type-to-icon map ──
|
||||
const TYPE_ICON_MAP = {
|
||||
od: "fx_distortion", overdrive: "fx_distortion", drive: "fx_distortion",
|
||||
dist: "fx_distortion", distortion: "fx_distortion", gain: "fx_distortion",
|
||||
fuzz: "fx_distortion",
|
||||
delay: "fx_delay", echo: "fx_delay",
|
||||
reverb: "fx_reverb",
|
||||
chorus: "fx_chorus", flange: "fx_flanger", flanger: "fx_flanger",
|
||||
phaser: "fx_phaser", tremolo: "fx_modulator", vibrato: "fx_modulator",
|
||||
rotary: "fx_modulator", modulator: "fx_modulator",
|
||||
pitch: "fx_pitch", harmonizer: "fx_pitch", shifter: "fx_pitch",
|
||||
comp: "fx_compressor", compressor: "fx_compressor",
|
||||
limiter: "fx_limiter",
|
||||
gate: "fx_gate", noise_gate: "fx_gate",
|
||||
filter: "fx_filter", wah: "fx_filter",
|
||||
eq: "fx_eq", graphic_eq: "fx_eq",
|
||||
parametric_eq: "fx_parametric_eq", multiband_eq: "fx_multiband_eq",
|
||||
ir: "fx_spatial", cab: "fx_spatial", cabinet: "fx_spatial",
|
||||
nam: "fx_simulator", capture: "fx_simulator", amp: "fx_amplifier",
|
||||
amplifier: "fx_amplifier",
|
||||
volume: "fx_constant", level: "fx_constant", boost: "fx_constant",
|
||||
split: "fx_split_a", merge: "fx_mixer",
|
||||
tuner: "fx_analyzer",
|
||||
mixer: "fx_mixer", utility: "fx_utility",
|
||||
plugin: "fx_plugin",
|
||||
instrument: "fx_instrument",
|
||||
looper: "fx_oscillator",
|
||||
converter: "fx_converter",
|
||||
reamp: "fx_converter",
|
||||
lr: "fx_lr",
|
||||
};
|
||||
|
||||
// ── Built-in model library ──
|
||||
const BUILTIN_MODELS = [
|
||||
// Distortions
|
||||
{ type: "overdrive", name: "OD-808", category: "Distortions", desc: "Classic overdrive" },
|
||||
{ type: "distortion", name: "RAT", category: "Distortions", desc: "Aggressive distortion" },
|
||||
{ type: "fuzz", name: "Big Muff", category: "Distortions", desc: "Thick fuzz sustain" },
|
||||
{ type: "boost", name: "Clean Boost", category: "Distortions", desc: "Clean level boost" },
|
||||
// Dynamics
|
||||
{ type: "compressor", name: "Compressor", category: "Dynamics", desc: "Dynamic range control" },
|
||||
{ type: "gate", name: "Noise Gate", category: "Dynamics", desc: "Noise suppression" },
|
||||
// Modulations
|
||||
{ type: "chorus", name: "Chorus", category: "Modulations", desc: "Rich chorus effect" },
|
||||
{ type: "flanger", name: "Flanger", category: "Modulations", desc: "Jet plane sweep" },
|
||||
{ type: "phaser", name: "Phase 90", category: "Modulations", desc: "Iconic phase shift" },
|
||||
{ type: "tremolo", name: "Tremolo", category: "Modulations", desc: "Volume modulation" },
|
||||
// Delays
|
||||
{ type: "delay", name: "Digital Delay", category: "Delays", desc: "Clean digital repeats" },
|
||||
{ type: "echo", name: "Tape Echo", category: "Delays", desc: "Warm tape repeats" },
|
||||
// Reverbs
|
||||
{ type: "reverb", name: "Hall Reverb", category: "Reverbs", desc: "Lush hall reverb" },
|
||||
// Filters
|
||||
{ type: "eq", name: "Equalizer", category: "Filters", desc: "3-band tone control" },
|
||||
{ type: "wah", name: "Wah", category: "Filters", desc: "Crybaby wah" },
|
||||
// Volume/Pan
|
||||
{ type: "volume", name: "Volume Pedal", category: "Volume/Pan", desc: "Volume control" },
|
||||
// Special
|
||||
{ type: "split", name: "Split", category: "Special", desc: "Parallel routing" },
|
||||
{ type: "merge", name: "Merge", category: "Special", desc: "Sum parallel paths" },
|
||||
{ type: "tuner", name: "Tuner", category: "Special", desc: "Instrument tuner" },
|
||||
{ type: "loop", name: "FX Loop", category: "Send/Return", desc: "External FX send/return" },
|
||||
// Cabs & IRs
|
||||
{ type: "cabinet", name: "Cabinet", category: "Cabs", desc: "Speaker cabinet sim" },
|
||||
{ type: "ir", name: "IR Loader", category: "IRs", desc: "Impulse response loader" },
|
||||
// Amps
|
||||
{ type: "nam", name: "NAM Model", category: "Amps", desc: "Neural amp model" },
|
||||
];
|
||||
|
||||
// ── Helper: resolve icon for a type ──
|
||||
function getIconFile(type) {
|
||||
const key = (type || "").toLowerCase().trim();
|
||||
for (const [k, v] of Object.entries(TYPE_ICON_MAP)) {
|
||||
if (key === k || key.startsWith(k) || key.includes(k)) return v;
|
||||
}
|
||||
return "fx_plugin";
|
||||
}
|
||||
|
||||
function getTypeColor(type) {
|
||||
const key = (type || "").toLowerCase().trim();
|
||||
for (const [k, v] of Object.entries(TYPE_COLORS)) {
|
||||
if (key === k || key.startsWith(k) || key.includes(k)) return v;
|
||||
}
|
||||
return T.amber;
|
||||
}
|
||||
|
||||
// ── Block Icon (inline from BlockChain) ──
|
||||
function BlockIcon({ type, size = 16 }) {
|
||||
const icon = getIconFile(type);
|
||||
return (
|
||||
<img
|
||||
src={`${IMG_PATH}${icon}.svg`}
|
||||
alt={type || "fx"}
|
||||
style={{
|
||||
width: size, height: size, flexShrink: 0,
|
||||
filter: "invert(0.95) brightness(2)",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Category Icon ──
|
||||
function CategoryIcon({ category, size = 14 }) {
|
||||
const def = CATEGORY_DEFS[category];
|
||||
if (!def) return null;
|
||||
return (
|
||||
<img
|
||||
src={`${IMG_PATH}${def.icon}.svg`}
|
||||
alt={category}
|
||||
style={{
|
||||
width: size, height: size, flexShrink: 0,
|
||||
filter: "invert(0.95) brightness(2)",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Model Browser ──
|
||||
export default function ModelBrowser({
|
||||
currentBlockType,
|
||||
currentBlockName,
|
||||
currentModelPath,
|
||||
onSelectModel,
|
||||
onClose,
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState("All");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loadedModels, setLoadedModels] = useState([]);
|
||||
const [favorites, setFavorites] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("helix_model_favorites");
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
const searchRef = useRef(null);
|
||||
|
||||
// Load installed NAM / IR models from API
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await fetch("/api/models").then(r => r.json());
|
||||
if (!cancelled) setLoadedModels(data.models || []);
|
||||
} catch {}
|
||||
};
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Save favorites
|
||||
const toggleFav = useCallback((id) => {
|
||||
setFavorites(prev => {
|
||||
const next = prev.includes(id) ? prev.filter(f => f !== id) : [...prev, id];
|
||||
localStorage.setItem("helix_model_favorites", JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Determine current model ID for highlight ──
|
||||
const currentModelId = currentModelPath
|
||||
? currentModelPath
|
||||
: currentBlockType
|
||||
? `builtin:${currentBlockType}`
|
||||
: null;
|
||||
|
||||
// ── Build model list ──
|
||||
const allModels = useMemo(() => {
|
||||
const list = [];
|
||||
|
||||
// Built-in block types
|
||||
for (const m of BUILTIN_MODELS) {
|
||||
list.push({
|
||||
id: `builtin:${m.type}`,
|
||||
type: m.type,
|
||||
name: m.name,
|
||||
category: m.category,
|
||||
desc: m.desc,
|
||||
isBuiltin: true,
|
||||
iconType: m.type,
|
||||
});
|
||||
}
|
||||
|
||||
// Installed NAM models — map to Amps category
|
||||
for (const m of loadedModels) {
|
||||
const label = m.name || m.filename || "Unnamed Model";
|
||||
list.push({
|
||||
id: `nam:${label}`,
|
||||
type: "nam",
|
||||
name: label,
|
||||
category: "Amps",
|
||||
desc: m.architecture ? `${m.architecture} · ${m.size_mb}MB` : "Installed NAM model",
|
||||
isBuiltin: false,
|
||||
loaded: m.loaded,
|
||||
modelPath: m.path || m.filename,
|
||||
iconType: "nam",
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [loadedModels]);
|
||||
|
||||
// ── Filter by tab & search ──
|
||||
const visibleCategories = useMemo(() => {
|
||||
if (activeTab === "All") {
|
||||
const cats = [...CATEGORY_ORDER];
|
||||
// Only show non-empty + visible categories
|
||||
return cats.filter(c => {
|
||||
if (CATEGORY_HIDE.includes(c)) {
|
||||
// Only show hidden categories if they have installed models
|
||||
return allModels.some(m => m.category === c && !m.isBuiltin);
|
||||
}
|
||||
return allModels.some(m => m.category === c);
|
||||
});
|
||||
}
|
||||
if (activeTab === "★Favorites") return [];
|
||||
return [activeTab];
|
||||
}, [activeTab, allModels]);
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let list = allModels;
|
||||
|
||||
// Tab filter
|
||||
if (activeTab === "★Favorites") {
|
||||
list = list.filter(m => favorites.includes(m.id));
|
||||
} else if (activeTab !== "All") {
|
||||
list = list.filter(m => m.category === activeTab);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
list = list.filter(m =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.type.toLowerCase().includes(q) ||
|
||||
(m.desc || "").toLowerCase().includes(q) ||
|
||||
(m.category || "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [allModels, activeTab, searchQuery, favorites]);
|
||||
|
||||
const groupedByCategory = useMemo(() => {
|
||||
if (activeTab !== "All" && activeTab !== "★Favorites") return null;
|
||||
const groups = {};
|
||||
for (const m of filteredModels) {
|
||||
if (!groups[m.category]) groups[m.category] = [];
|
||||
groups[m.category].push(m);
|
||||
}
|
||||
return groups;
|
||||
}, [filteredModels, activeTab]);
|
||||
|
||||
// ── Handle model selection ──
|
||||
const handleSelect = useCallback((model) => {
|
||||
onSelectModel?.({
|
||||
type: model.type,
|
||||
name: model.name,
|
||||
modelPath: model.isBuiltin ? null : model.modelPath || model.id,
|
||||
isBuiltin: model.isBuiltin,
|
||||
});
|
||||
onClose?.();
|
||||
}, [onSelectModel, onClose]);
|
||||
|
||||
// ── Handle tab selection ──
|
||||
const tabs = ["All", "★Favorites", ...CATEGORY_ORDER.filter(c => {
|
||||
// Only show categories that have models
|
||||
if (CATEGORY_HIDE.includes(c)) {
|
||||
return allModels.some(m => m.category === c && !m.isBuiltin);
|
||||
}
|
||||
return allModels.some(m => m.category === c);
|
||||
})];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 1000,
|
||||
display: "flex", flexDirection: "column",
|
||||
background: T.bg,
|
||||
color: T.textPrimary,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
}}>
|
||||
{/* ── 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 }}>
|
||||
<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 }}>Model Browser</div>
|
||||
{currentBlockName && (
|
||||
<div style={{ fontSize: 10, color: T.textSec, marginTop: 1 }}>
|
||||
Current: {currentBlockName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search bar ── */}
|
||||
<div style={{
|
||||
padding: "8px 14px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.surface,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
background: T.panel, borderRadius: 7,
|
||||
border: `1px solid ${T.border}`,
|
||||
padding: "7px 10px",
|
||||
}}>
|
||||
<span style={{ color: T.textDim, fontSize: 13 }}>🔍</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
flex: 1, background: "none", border: "none",
|
||||
color: T.textPrimary, fontSize: 13, outline: "none",
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery("")}
|
||||
style={{
|
||||
background: "none", border: "none", color: T.textDim,
|
||||
fontSize: 14, cursor: "pointer", padding: 2,
|
||||
}}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Category tabs ── */}
|
||||
<div style={{
|
||||
display: "flex", gap: 0,
|
||||
overflowX: "auto", overflowY: "hidden",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.panel,
|
||||
flexShrink: 0,
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const def = CATEGORY_DEFS[tab];
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
fontSize: 10, fontWeight: 600,
|
||||
letterSpacing: ".04em",
|
||||
whiteSpace: "nowrap",
|
||||
background: "none",
|
||||
border: "none",
|
||||
borderBottom: `2px solid ${isActive ? T.amber : "transparent"}`,
|
||||
color: isActive ? T.textPrimary : T.textSec,
|
||||
cursor: "pointer",
|
||||
display: "flex", alignItems: "center", gap: 5,
|
||||
flexShrink: 0,
|
||||
transition: "all .12s",
|
||||
}}>
|
||||
{def && <CategoryIcon category={tab} size={12} />}
|
||||
{tab === "★Favorites" && <span>★</span>}
|
||||
{tab === "★Favorites" ? " Favorites" : tab}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Model grid area ── */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: "auto", overflowX: "hidden",
|
||||
padding: "10px 14px",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}>
|
||||
{/* Favorites empty state */}
|
||||
{activeTab === "★Favorites" && filteredModels.length === 0 && (
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
justifyContent: "center", padding: "40px 20px", gap: 8,
|
||||
color: T.textDim,
|
||||
}}>
|
||||
<span style={{ fontSize: 28 }}>☆</span>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>No favorites yet</div>
|
||||
<div style={{ fontSize: 11, textAlign: "center" }}>
|
||||
Star a model to add it to your favorites
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search empty state */}
|
||||
{searchQuery && filteredModels.length === 0 && (
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
justifyContent: "center", padding: "40px 20px", gap: 8,
|
||||
color: T.textDim,
|
||||
}}>
|
||||
<div style={{ fontSize: 13 }}>No models matching "{searchQuery}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories with models (All tab) or just the models */}
|
||||
{groupedByCategory ? (
|
||||
// All tab — grouped by category
|
||||
visibleCategories.map(cat => {
|
||||
const models = groupedByCategory[cat] || [];
|
||||
if (models.length === 0) return null;
|
||||
const def = CATEGORY_DEFS[cat];
|
||||
return (
|
||||
<div key={cat} style={{ marginBottom: 20 }}>
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 8,
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{def && <CategoryIcon category={cat} size={16} />}
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: ".08em",
|
||||
textTransform: "uppercase", color: T.textSec,
|
||||
}}>{cat}</span>
|
||||
<div style={{
|
||||
flex: 1, height: 1, background: T.border, opacity: 0.4,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(130px, 1fr))",
|
||||
gap: 6,
|
||||
}}>
|
||||
{models.map(model => (
|
||||
<ModelTile
|
||||
key={model.id}
|
||||
model={model}
|
||||
isCurrent={model.id === currentModelId}
|
||||
isFavorite={favorites.includes(model.id)}
|
||||
onSelect={() => handleSelect(model)}
|
||||
onToggleFav={() => toggleFav(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Specific tab — flat grid
|
||||
<>
|
||||
{filteredModels.length > 0 && (
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(130px, 1fr))",
|
||||
gap: 6,
|
||||
}}>
|
||||
{filteredModels.map(model => (
|
||||
<ModelTile
|
||||
key={model.id}
|
||||
model={model}
|
||||
isCurrent={model.id === currentModelId}
|
||||
isFavorite={favorites.includes(model.id)}
|
||||
onSelect={() => handleSelect(model)}
|
||||
onToggleFav={() => toggleFav(model.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Individual Model Tile ──
|
||||
function ModelTile({ model, isCurrent, isFavorite, onSelect, onToggleFav }) {
|
||||
const color = getTypeColor(model.type);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
return (
|
||||
<div onClick={onSelect}
|
||||
style={{
|
||||
display: "flex", flexDirection: "column", gap: 0,
|
||||
padding: "10px 8px 8px",
|
||||
borderRadius: 8,
|
||||
background: isCurrent
|
||||
? `linear-gradient(135deg, ${color}18, ${T.panel})`
|
||||
: T.surface,
|
||||
border: `1.5px solid ${isCurrent ? color + "66" : T.border}`,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
transition: "all .12s",
|
||||
boxShadow: isCurrent ? `0 0 12px ${color}33` : "none",
|
||||
minHeight: 72,
|
||||
}}
|
||||
>
|
||||
{/* Favorite star */}
|
||||
<div onClick={(e) => { e.stopPropagation(); onToggleFav(); }}
|
||||
style={{
|
||||
position: "absolute", top: 4, right: 4,
|
||||
fontSize: 13, cursor: "pointer", zIndex: 2,
|
||||
color: isFavorite ? T.amber : T.textDim,
|
||||
textShadow: isFavorite ? `0 0 4px ${T.amber}` : "none",
|
||||
transition: "all .15s",
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{isFavorite ? "★" : "☆"}
|
||||
</div>
|
||||
|
||||
{/* Model icon */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
height: 34, marginBottom: 6,
|
||||
}}>
|
||||
{!imgError ? (
|
||||
<img
|
||||
src={`${IMG_PATH}${getIconFile(model.iconType || model.type)}.svg`}
|
||||
alt={model.type}
|
||||
style={{
|
||||
width: 28, height: 28,
|
||||
filter: `invert(0.95) brightness(2) drop-shadow(0 0 4px ${color}44)`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: 18, color, opacity: 0.7,
|
||||
fontFamily: "'JetBrains Mono', monospace", fontWeight: 700,
|
||||
}}>
|
||||
{model.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model name */}
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 600,
|
||||
color: isCurrent ? color : T.textPrimary,
|
||||
textAlign: "center",
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
lineHeight: 1.3,
|
||||
}}>
|
||||
{model.name}
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "center", marginTop: 3,
|
||||
}}>
|
||||
<span style={{
|
||||
display: "inline-flex", padding: "1px 5px", borderRadius: 3,
|
||||
fontSize: 8, fontWeight: 700, letterSpacing: ".05em",
|
||||
textTransform: "uppercase",
|
||||
background: `${color}18`, color,
|
||||
}}>
|
||||
{model.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{model.desc && (
|
||||
<div style={{
|
||||
fontSize: 8, color: T.textDim,
|
||||
textAlign: "center", marginTop: 3,
|
||||
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
|
||||
}}>
|
||||
{model.desc}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current indicator */}
|
||||
{isCurrent && (
|
||||
<div style={{
|
||||
position: "absolute", top: 4, left: 4,
|
||||
width: 6, height: 6, borderRadius: "50%",
|
||||
background: T.green,
|
||||
boxShadow: `0 0 6px ${T.green}`,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user