diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index d0cee79..a10b4a6 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -11908,6 +11908,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
const imgRef = React.useRef(null);
const boxRef = React.useRef(null);
const deadRef = React.useRef(false);
+ const screenRectRef = React.useRef(null);
const [dead, setDead] = React.useState(false);
const post = (payload) => { try { fetch(url + '/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (e) {} };
React.useEffect(() => {
@@ -11922,6 +11923,34 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
return () => { clearInterval(tick); clearInterval(ping); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
+ // Embed panel screen rect (physical px) for the bridge's anchored real-input
+ // dance: Tauri innerPosition + scaleFactor; window.screenX/Y fallback when
+ // not running under Tauri. Refreshed ~500ms so scroll/resize stays current.
+ React.useEffect(() => {
+ let alive = true;
+ const refresh = () => {
+ const box = boxRef.current;
+ if (!box) return;
+ const r = box.getBoundingClientRect();
+ if (r.width < 4 || r.height < 4) return;
+ try {
+ const tw = window.__TAURI__ && window.__TAURI__.window;
+ if (tw && tw.getCurrentWindow) {
+ Promise.all([tw.getCurrentWindow().innerPosition(), tw.getCurrentWindow().scaleFactor()]).then(([pos, sf]) => {
+ if (!alive || !boxRef.current) return;
+ const rb = boxRef.current.getBoundingClientRect();
+ screenRectRef.current = { x: Math.round(pos.x + rb.left * sf), y: Math.round(pos.y + rb.top * sf), w: Math.round(rb.width * sf), h: Math.round(rb.height * sf) };
+ }).catch(() => {});
+ } else {
+ screenRectRef.current = { x: Math.round((window.screenX || 0) + r.left), y: Math.round((window.screenY || 0) + r.top), w: Math.round(r.width), h: Math.round(r.height) };
+ }
+ } catch (e) {}
+ };
+ refresh();
+ const iv = setInterval(refresh, 500);
+ return () => { alive = false; clearInterval(iv); };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [url]);
const pt = (e) => {
const box = boxRef.current, img = imgRef.current;
if (!box || !img || !img.naturalWidth || !img.naturalHeight) return null;
@@ -11934,7 +11963,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
};
const handle = (e) => {
const p = pt(e); if (!p) return;
- post({ type: e.type === 'mousedown' ? 'mousedown' : e.type === 'mouseup' ? 'mouseup' : 'mousemove', x: p.x, y: p.y, button: e.button, buttons: e.buttons });
+ post({ type: e.type === 'mousedown' ? 'mousedown' : e.type === 'mouseup' ? 'mouseup' : 'mousemove', x: p.x, y: p.y, button: e.button, buttons: e.buttons, rect: screenRectRef.current || undefined });
};
const close = () => { try { fetch(url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} onClose && onClose(); };
return (
@@ -11949,7 +11978,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
post({ type: 'mouseup', x: -1, y: -1, button: 0 })}
- onWheel={e => { const p = pt(e); if (p) post({ type: 'wheel', x: p.x, y: p.y, deltaY: e.deltaY }); }}
+ onWheel={e => { const p = pt(e); if (p) post({ type: 'wheel', x: p.x, y: p.y, deltaY: e.deltaY, rect: screenRectRef.current || undefined }); }}
onKeyDown={e => { e.preventDefault(); post({ type: 'keydown', keyCode: e.keyCode || e.which }); }}
onKeyUp={e => { e.preventDefault(); post({ type: 'keyup', keyCode: e.keyCode || e.which }); }}>
![VST GUI]()
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index 57176b8..64812da 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -742,8 +742,12 @@ return{hx:Math.max(0,Math.min((canvasRef.current?canvasRef.current.clientWidth:4
// gửi pointer/key về bridge qua /input. Dùng chung MasteringModal + FXRackModal.
// url = embed_url từ API fx-gui open (embed:true); onClose khi user đóng;
// onDead khi bridge chết (ping fail) → parent trả graph + toast.
-const VstGuiEmbed=({url,onClose,onDead})=>{const imgRef=React.useRef(null);const boxRef=React.useRef(null);const deadRef=React.useRef(false);const[dead,setDead]=React.useState(false);const post=payload=>{try{fetch(url+'/input',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).catch(()=>{});}catch(e){}};React.useEffect(()=>{deadRef.current=false;setDead(false);const tick=setInterval(()=>{const img=imgRef.current;if(img)img.src=url+'/frame?t='+Date.now();},100);const ping=setInterval(()=>{fetch(url+'/ping').then(r=>{if(!r.ok)throw new Error('bad');}).catch(()=>{if(!deadRef.current){deadRef.current=true;setDead(true);onDead&&onDead();}});},2000);return()=>{clearInterval(tick);clearInterval(ping);};// eslint-disable-next-line react-hooks/exhaustive-deps
-},[url]);const pt=e=>{const box=boxRef.current,img=imgRef.current;if(!box||!img||!img.naturalWidth||!img.naturalHeight)return null;const r=box.getBoundingClientRect();if(r.width===0||r.height===0)return null;return{x:Math.round((e.clientX-r.left)/r.width*img.naturalWidth),y:Math.round((e.clientY-r.top)/r.height*img.naturalHeight)};};const handle=e=>{const p=pt(e);if(!p)return;post({type:e.type==='mousedown'?'mousedown':e.type==='mouseup'?'mouseup':'mousemove',x:p.x,y:p.y,button:e.button,buttons:e.buttons});};const close=()=>{try{fetch(url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}onClose&&onClose();};return/*#__PURE__*/React.createElement("div",{className:"relative w-full h-full flex flex-col"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-3 py-1.5 bg-slate-900/80 border border-slate-800 rounded-t-lg shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-violet-300 font-mono"},"VST GUI \u2014 nh\xFAng (frame-capture \xB7 ~10fps)"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},dead&&/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-red-400 font-mono"},"bridge m\u1EA5t k\u1EBFt n\u1ED1i"),/*#__PURE__*/React.createElement("button",{onClick:close,className:"px-2 py-0.5 bg-red-900/60 hover:bg-red-800 text-red-100 text-[10px] font-semibold rounded transition",title:"\u0110\xF3ng GUI v\xE0 tr\u1EA3 v\u1EC1 graph"},"\u2715 \u0110\xF3ng GUI"))),/*#__PURE__*/React.createElement("div",{ref:boxRef,tabIndex:0,className:"flex-1 relative bg-slate-950 border border-slate-800 rounded-b-lg overflow-hidden outline-none",onMouseDown:handle,onMouseUp:handle,onMouseMove:handle,onMouseLeave:()=>post({type:'mouseup',x:-1,y:-1,button:0}),onWheel:e=>{const p=pt(e);if(p)post({type:'wheel',x:p.x,y:p.y,deltaY:e.deltaY});},onKeyDown:e=>{e.preventDefault();post({type:'keydown',keyCode:e.keyCode||e.which});},onKeyUp:e=>{e.preventDefault();post({type:'keyup',keyCode:e.keyCode||e.which});}},/*#__PURE__*/React.createElement("img",{ref:imgRef,alt:"VST GUI",className:"w-full h-full object-contain select-none pointer-events-none",draggable:false})));};const vstRow=(s,idx,toggleV,changeV,uploadV,moveV,removeV,fxl,chainLen,openGui,showCarla=true,activeIdx=-1,onSelect=null)=>/*#__PURE__*/React.createElement("div",{key:idx,onClick:()=>onSelect&&onSelect(s,idx),className:`flex items-center gap-2 bg-slate-900/60 border rounded-lg px-2 py-1.5 flex-wrap cursor-pointer transition-all ${activeIdx===idx?'border-teal-400 bg-slate-800/80':'border-slate-800 hover:border-slate-600'}`},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleV(idx);},className:`w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 ${!s.bypass?'bg-teal-500 text-slate-950':'bg-slate-700 text-slate-300'}`,title:s.bypass?'Bypass':'Active'},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-300 font-mono w-6 shrink-0"},idx+1),/*#__PURE__*/React.createElement("select",{value:s.path||'',onClick:e=>e.stopPropagation(),onChange:e=>changeV(idx,e.target.value),className:"flex-1 min-w-[200px] bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1 text-[11px] outline-none focus:border-teal-500"},/*#__PURE__*/React.createElement("option",{value:""},"\u2014 Ch\u1ECDn VST FX \u2014"),(fxl||[]).map(f=>/*#__PURE__*/React.createElement("option",{key:f.path,value:f.path},f.name||f.id))),/*#__PURE__*/React.createElement("span",{className:`text-[9px] font-mono ${s.preset_b64?'text-emerald-400':'text-slate-600'}`},s.preset_b64?'✓ preset':'no preset'),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();uploadV(idx);},className:"px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[10px] font-semibold rounded transition",title:"Upload .vstpreset (Save preset)"},"Preset"),openGui&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();openGui(s,idx);},disabled:!s.path,className:"px-2 py-1 bg-violet-800 hover:bg-violet-700 disabled:opacity-30 text-violet-100 text-[10px] font-semibold rounded transition",title:"M\u1EDF GUI c\u1EE7a VST (ch\u1EC9nh th\xF4ng s\u1ED1 tr\u1EF1c ti\u1EBFp)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"monitor",className:"w-3 h-3"})," GUI"),showCarla&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}window.SonicAPI.openInCarla(s.name||s.path,s.path).then(r=>{if(r&&r.success)window.showToast&&window.showToast('Đã mở Carla với '+(s.name||s.path)+' — chỉnh âm trong Carla rồi Save preset','success');else window.showToast&&window.showToast('Không mở được Carla','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở Carla: '+(err.message||err),'error'));},className:"px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition",title:"M\u1EDF native GUI trong Carla"},"Carla"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,-1);},disabled:idx===0,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"L\xEAn"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-up",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,1);},disabled:idx===chainLen-1,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"Xu\u1ED1ng"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-down",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeV(idx);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5",title:"X\xF3a slot"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));// ── VST FX Plugin Picker (spec vst_fx_panel_integration.md §II) ──────────
+const VstGuiEmbed=({url,onClose,onDead})=>{const imgRef=React.useRef(null);const boxRef=React.useRef(null);const deadRef=React.useRef(false);const screenRectRef=React.useRef(null);const[dead,setDead]=React.useState(false);const post=payload=>{try{fetch(url+'/input',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).catch(()=>{});}catch(e){}};React.useEffect(()=>{deadRef.current=false;setDead(false);const tick=setInterval(()=>{const img=imgRef.current;if(img)img.src=url+'/frame?t='+Date.now();},100);const ping=setInterval(()=>{fetch(url+'/ping').then(r=>{if(!r.ok)throw new Error('bad');}).catch(()=>{if(!deadRef.current){deadRef.current=true;setDead(true);onDead&&onDead();}});},2000);return()=>{clearInterval(tick);clearInterval(ping);};// eslint-disable-next-line react-hooks/exhaustive-deps
+},[url]);// Embed panel screen rect (physical px) for the bridge's anchored real-input
+// dance: Tauri innerPosition + scaleFactor; window.screenX/Y fallback when
+// not running under Tauri. Refreshed ~500ms so scroll/resize stays current.
+React.useEffect(()=>{let alive=true;const refresh=()=>{const box=boxRef.current;if(!box)return;const r=box.getBoundingClientRect();if(r.width<4||r.height<4)return;try{const tw=window.__TAURI__&&window.__TAURI__.window;if(tw&&tw.getCurrentWindow){Promise.all([tw.getCurrentWindow().innerPosition(),tw.getCurrentWindow().scaleFactor()]).then(([pos,sf])=>{if(!alive||!boxRef.current)return;const rb=boxRef.current.getBoundingClientRect();screenRectRef.current={x:Math.round(pos.x+rb.left*sf),y:Math.round(pos.y+rb.top*sf),w:Math.round(rb.width*sf),h:Math.round(rb.height*sf)};}).catch(()=>{});}else{screenRectRef.current={x:Math.round((window.screenX||0)+r.left),y:Math.round((window.screenY||0)+r.top),w:Math.round(r.width),h:Math.round(r.height)};}}catch(e){}};refresh();const iv=setInterval(refresh,500);return()=>{alive=false;clearInterval(iv);};// eslint-disable-next-line react-hooks/exhaustive-deps
+},[url]);const pt=e=>{const box=boxRef.current,img=imgRef.current;if(!box||!img||!img.naturalWidth||!img.naturalHeight)return null;const r=box.getBoundingClientRect();if(r.width===0||r.height===0)return null;return{x:Math.round((e.clientX-r.left)/r.width*img.naturalWidth),y:Math.round((e.clientY-r.top)/r.height*img.naturalHeight)};};const handle=e=>{const p=pt(e);if(!p)return;post({type:e.type==='mousedown'?'mousedown':e.type==='mouseup'?'mouseup':'mousemove',x:p.x,y:p.y,button:e.button,buttons:e.buttons,rect:screenRectRef.current||undefined});};const close=()=>{try{fetch(url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}onClose&&onClose();};return/*#__PURE__*/React.createElement("div",{className:"relative w-full h-full flex flex-col"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-3 py-1.5 bg-slate-900/80 border border-slate-800 rounded-t-lg shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-violet-300 font-mono"},"VST GUI \u2014 nh\xFAng (frame-capture \xB7 ~10fps)"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},dead&&/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-red-400 font-mono"},"bridge m\u1EA5t k\u1EBFt n\u1ED1i"),/*#__PURE__*/React.createElement("button",{onClick:close,className:"px-2 py-0.5 bg-red-900/60 hover:bg-red-800 text-red-100 text-[10px] font-semibold rounded transition",title:"\u0110\xF3ng GUI v\xE0 tr\u1EA3 v\u1EC1 graph"},"\u2715 \u0110\xF3ng GUI"))),/*#__PURE__*/React.createElement("div",{ref:boxRef,tabIndex:0,className:"flex-1 relative bg-slate-950 border border-slate-800 rounded-b-lg overflow-hidden outline-none",onMouseDown:handle,onMouseUp:handle,onMouseMove:handle,onMouseLeave:()=>post({type:'mouseup',x:-1,y:-1,button:0}),onWheel:e=>{const p=pt(e);if(p)post({type:'wheel',x:p.x,y:p.y,deltaY:e.deltaY,rect:screenRectRef.current||undefined});},onKeyDown:e=>{e.preventDefault();post({type:'keydown',keyCode:e.keyCode||e.which});},onKeyUp:e=>{e.preventDefault();post({type:'keyup',keyCode:e.keyCode||e.which});}},/*#__PURE__*/React.createElement("img",{ref:imgRef,alt:"VST GUI",className:"w-full h-full object-contain select-none pointer-events-none",draggable:false})));};const vstRow=(s,idx,toggleV,changeV,uploadV,moveV,removeV,fxl,chainLen,openGui,showCarla=true,activeIdx=-1,onSelect=null)=>/*#__PURE__*/React.createElement("div",{key:idx,onClick:()=>onSelect&&onSelect(s,idx),className:`flex items-center gap-2 bg-slate-900/60 border rounded-lg px-2 py-1.5 flex-wrap cursor-pointer transition-all ${activeIdx===idx?'border-teal-400 bg-slate-800/80':'border-slate-800 hover:border-slate-600'}`},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleV(idx);},className:`w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 ${!s.bypass?'bg-teal-500 text-slate-950':'bg-slate-700 text-slate-300'}`,title:s.bypass?'Bypass':'Active'},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-300 font-mono w-6 shrink-0"},idx+1),/*#__PURE__*/React.createElement("select",{value:s.path||'',onClick:e=>e.stopPropagation(),onChange:e=>changeV(idx,e.target.value),className:"flex-1 min-w-[200px] bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1 text-[11px] outline-none focus:border-teal-500"},/*#__PURE__*/React.createElement("option",{value:""},"\u2014 Ch\u1ECDn VST FX \u2014"),(fxl||[]).map(f=>/*#__PURE__*/React.createElement("option",{key:f.path,value:f.path},f.name||f.id))),/*#__PURE__*/React.createElement("span",{className:`text-[9px] font-mono ${s.preset_b64?'text-emerald-400':'text-slate-600'}`},s.preset_b64?'✓ preset':'no preset'),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();uploadV(idx);},className:"px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[10px] font-semibold rounded transition",title:"Upload .vstpreset (Save preset)"},"Preset"),openGui&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();openGui(s,idx);},disabled:!s.path,className:"px-2 py-1 bg-violet-800 hover:bg-violet-700 disabled:opacity-30 text-violet-100 text-[10px] font-semibold rounded transition",title:"M\u1EDF GUI c\u1EE7a VST (ch\u1EC9nh th\xF4ng s\u1ED1 tr\u1EF1c ti\u1EBFp)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"monitor",className:"w-3 h-3"})," GUI"),showCarla&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}window.SonicAPI.openInCarla(s.name||s.path,s.path).then(r=>{if(r&&r.success)window.showToast&&window.showToast('Đã mở Carla với '+(s.name||s.path)+' — chỉnh âm trong Carla rồi Save preset','success');else window.showToast&&window.showToast('Không mở được Carla','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở Carla: '+(err.message||err),'error'));},className:"px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition",title:"M\u1EDF native GUI trong Carla"},"Carla"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,-1);},disabled:idx===0,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"L\xEAn"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-up",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,1);},disabled:idx===chainLen-1,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"Xu\u1ED1ng"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-down",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeV(idx);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5",title:"X\xF3a slot"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));// ── VST FX Plugin Picker (spec vst_fx_panel_integration.md §II) ──────────
// Modal chọn VST FX theo category (heuristic từ backend list_fx.category).
// Dùng chung FXRackModal + MasteringModal: [+] → picker → chèn VST card.
const VST_FX_CATEGORIES=[{id:'all',label:'Tất cả'},{id:'equalizer',label:'Equalizers / Dynamic EQ'},{id:'reverb_delay',label:'Reverbs & Delays'},{id:'comp_limiter',label:'Compressors & Limiters'},{id:'distortion',label:'Distortion / Saturation'},{id:'utility',label:'Utility'}];const VstPluginPicker=({open,onClose,plugins,onPick})=>{const[cat,setCat]=React.useState('all');React.useEffect(()=>{if(open)setCat('all');},[open]);if(!open)return null;const list=(plugins||[]).filter(p=>cat==='all'||(p.category||'utility')===cat);return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[125] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl max-h-[80vh] flex flex-col",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},"CH\u1ECCN VST FX \u2014 PLUGIN PICKER"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-wrap gap-1.5 shrink-0"},VST_FX_CATEGORIES.map(c=>/*#__PURE__*/React.createElement("button",{key:c.id,onClick:()=>setCat(c.id),className:`px-2.5 py-1 rounded text-[10px] font-semibold transition ${cat===c.id?'bg-teal-700 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300'}`},c.label))),/*#__PURE__*/React.createElement("div",{className:"flex-1 overflow-y-auto oz-scrollbar grid grid-cols-2 gap-2"},list.length===0&&/*#__PURE__*/React.createElement("div",{className:"col-span-2 text-[11px] text-slate-500 font-mono py-10 text-center"},"Kh\xF4ng c\xF3 VST FX trong m\u1EE5c n\xE0y."),list.map(f=>/*#__PURE__*/React.createElement("button",{key:f.path,onClick:()=>{onPick(f);onClose();},className:"p-2 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-lg text-left space-y-0.5 transition-colors"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-teal-300 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3 h-3"})," ",f.name||f.id),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono truncate"},f.path))))));};const FXRackModal=({track,onUpdateTrack,onClose})=>{const[activeType,setActiveType]=React.useState('eq');const[addModuleOpen,setAddModuleOpen]=React.useState(false);const dragChainIndexRef=React.useRef(null);// Wave Observer scope state (unified_fx_rack_panel_update.md §III.3)
diff --git a/app/templates/index.html b/app/templates/index.html
index e1a3c0a..c638784 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -50,7 +50,7 @@
-
+