fix(ui): master chain panel - xoa toolbar Delta Listen, GUI VST mo mac dinh spawn fresh moi lan mo panel, busy spinner + banner loi + nut Thu lai khi spawn GUI cham/fail

This commit is contained in:
2026-09-02 20:32:20 +07:00
parent 8e4fed5f05
commit c32c77e4f4
2 changed files with 2920 additions and 2813 deletions
+139 -56
View File
@@ -13384,7 +13384,7 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
// 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 VstGuiEmbed = ({ url, onClose, onDead, closeOnUnmount, onUnmounting }) => {
const imgRef = React.useRef(null);
const boxRef = React.useRef(null);
const deadRef = React.useRef(false);
@@ -13396,7 +13396,38 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
deadRef.current = false;
setDead(false);
setFrameLoaded(false);
const tick = setInterval(() => { const img = imgRef.current; if (img && img.complete) img.src = url + '/frame?t=' + Date.now(); }, 100);
// Frame pump (fix flash): tải frame kế vào Image() ẩn, swap vào <img>
// hiển thị chỉ khi load THÀNH CÔNG -> khong bao gio trang/broken giua
// 2 frame. Loi frame (bridge ban): giu frame cu, retry cham hon.
let stopped = false;
let loader = null;
let pumpTimer = null;
const pump = () => {
if (stopped) return;
try {
const im = new Image();
loader = im;
const u = url + '/frame?t=' + Date.now();
im.onload = () => {
if (stopped) return;
const img = imgRef.current;
if (img) img.src = u;
setFrameLoaded(true);
// Cap ~12fps: bridge JPEG-encode moi /frame (khong cache) — pump
// nhanh hon chi dot CPU + decode main-thread -> audio WS (cung main
// thread) tre -> gi?t am khi play + GUI mo.
pumpTimer = window.setTimeout(pump, 80);
};
im.onerror = () => {
if (stopped) return;
pumpTimer = window.setTimeout(pump, 350);
};
im.src = u;
} catch (e) {
pumpTimer = window.setTimeout(pump, 350);
}
};
pump();
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(); }
@@ -13407,10 +13438,22 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
// rect refresh below re-anchors it over the panel.
try { fetch(url + '/show', { method: 'POST' }).catch(() => {}); } catch (e) {}
return () => {
clearInterval(tick); clearInterval(ping);
// Panel closed -> hide the native window (bridge stays alive so
// reopening is fast). The "Đóng GUI" button still POSTs /close (exit).
try { fetch(url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {}
stopped = true;
if (loader) { loader.onload = null; loader.onerror = null; loader = null; }
if (pumpTimer) clearTimeout(pumpTimer);
clearInterval(ping);
// closeOnUnmount (master chain panel dong): dong HAN bridge GUI thay vi
// hide — lan mo panel sau spawn lai fresh (instance cu treo/khong hien
// thi se khong con duoc tai su dung). Capture preset cuoi truoc khi dong.
if (closeOnUnmount) {
try {
fetch(url + '/preset').then(r => r.json()).then(d => { try { if (onUnmounting) onUnmounting((d && d.preset_b64) || ''); } catch (e) {} }).catch(() => { try { if (onUnmounting) onUnmounting(''); } catch (e) {} }).then(() => { try { fetch(url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} });
} catch (e) { try { if (onUnmounting) onUnmounting(''); } catch (e2) {} try { fetch(url + '/close', { method: 'POST' }).catch(() => {}); } catch (e3) {} }
} else {
// Track FX rack panel: hide (bridge stays alive — reopen nhanh).
// The "Đóng GUI" button still POSTs /close (exit).
try { fetch(url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {}
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
@@ -14229,6 +14272,13 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
const masterGuiOpenRef = React.useRef(null);
masterGuiOpenRef.current = masterGuiOpen;
const masterGuiBusyRef = React.useRef(false); // request openFxGui đang bay (chưa có verdict 20-70s)
// Busy/error render-state: GUI embed spawn chậm (Ozone ~30-60s) — UI phải
// hiện trạng thái thay vì im lặng ("GUI không hiện"). Error + nút Thử lại.
const [masterGuiBusy, setMasterGuiBusy] = React.useState(false);
const [masterGuiError, setMasterGuiError] = React.useState(null);
const masterGuiAttemptRef = React.useRef(null); // {path,name} cho nút Thử lại
const beginMasterGuiBusy = () => { masterGuiBusyRef.current = true; setMasterGuiBusy(true); setMasterGuiError(null); };
const endMasterGuiBusy = () => { masterGuiBusyRef.current = false; setMasterGuiBusy(false); };
const closeMasterGui = () => {
const g = masterGuiOpenRef.current;
if (g) {
@@ -14242,9 +14292,20 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
delete _fxGuiBaselineByPath[g.path];
}
setActiveMasterVstPath(null);
setMasterGuiError(null);
setOzState(prev => { const n = { ...(prev || {}) }; delete n.gui_open_path; delete n.gui_open_name; return n; });
};
React.useEffect(() => () => { const g = masterGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} const _mk = 'master:' + g.path; if (_presetRestartTimers[_mk]) { clearTimeout(_presetRestartTimers[_mk]); delete _presetRestartTimers[_mk]; } if (g.path) delete _fxGuiBaselineByPath[g.path]; } }, []);
// Panel (modal) dong -> VstGuiEmbed unmount -> bridge GUI da /close (dung
// closeOnUnmount). Reset state GUI de lan mo panel sau SPAWN LAI fresh (y/c:
// "mo master chain panel phai load lai GUI cua VST FX") — nhung GIU
// gui_open_path + preset da capture de effect auto-reopen ben duoi chay.
const closeMasterGuiForPanel = (presetB64) => {
const g = masterGuiOpenRef.current;
if (g && g.path && presetB64) _capturedPresetByPath[g.path] = presetB64;
setMasterGuiOpen(null);
setActiveMasterVstPath(null);
};
React.useEffect(() => () => { const g = masterGuiOpenRef.current; if (g) { const _mk = 'master:' + g.path; if (_presetRestartTimers[_mk]) { clearTimeout(_presetRestartTimers[_mk]); delete _presetRestartTimers[_mk]; } if (g.path) delete _fxGuiBaselineByPath[g.path]; } }, []);
// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
React.useEffect(() => {
if (!masterGuiOpen || !masterGuiOpen.url) return;
@@ -14255,17 +14316,36 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [masterGuiOpen]);
// Reopen the saved GUI when the panel is opened after a project restore.
// MỞ MẶC ĐỊNH khi mở panel: GUI VST load lại fresh mỗi lần (bridge cũ đã
// /close khi đóng panel — không tái dùng instance cũ). Chạy đúng lúc panel
// vừa mở (isOpen false->true) — không tự mở lại sau khi user bấm "Đóng GUI"
// ngay trong phiên. Nếu chưa có gui_open_path (chưa từng mở thành công) thì
// fallback mở VST đầu tiên trong master chain.
const masterPanelWasOpenRef = React.useRef(false);
React.useEffect(() => {
if (!isOpen || !ozState || !ozState.gui_open_path || masterGuiOpen || masterGuiBusyRef.current) return;
const s = (ozState.vstFxChain || []).find(x => x.path === ozState.gui_open_path);
const justOpened = !!isOpen && !masterPanelWasOpenRef.current;
masterPanelWasOpenRef.current = !!isOpen;
if (!isOpen || !justOpened) return;
if (masterGuiOpen || masterGuiBusyRef.current) return;
let path = ozState && ozState.gui_open_path;
let name = ozState && ozState.gui_open_name;
let s = (ozState.vstFxChain || []).find(x => x && x.path === path);
if (!s) {
const first = (ozState.vstFxChain || []).find(x => x && x.path);
if (!first) return;
s = first; path = first.path; name = first.name || first.path;
}
if (!s || !window.SonicAPI || !window.SonicAPI.openFxGui) return;
masterGuiBusyRef.current = true;
window.SonicAPI.openFxGui({ path: s.path, name: ozState.gui_open_name || s.name || s.path, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => {
masterGuiBusyRef.current = false;
if (r && r.embed_url) setMasterGuiOpen({ path: s.path, url: r.embed_url });
}).catch(() => { masterGuiBusyRef.current = false; });
masterGuiAttemptRef.current = { path: s.path, name: name || s.name || s.path };
beginMasterGuiBusy();
window.SonicAPI.openFxGui({ path: s.path, name: name || s.name || s.path, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => {
endMasterGuiBusy();
if (r && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: name || s.name || s.path })); }
else if (r && r.already_running && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: name || s.name || s.path })); }
else setMasterGuiError('Không mở được GUI embed cho ' + (name || s.name || s.path) + ' — plugin có thể crash khi tải. Bấm Thử lại.');
}).catch(err => { endMasterGuiBusy(); setMasterGuiError('Lỗi mở GUI: ' + (err.message || err)); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, ozState && ozState.gui_open_path]);
}, [isOpen]);
const setMasterVstChain = (next) => setOzState(prev => {
const chain = prev.chain || [];
const out = [];
@@ -14296,14 +14376,22 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
if (masterGuiOpen) closeMasterGui();
if (s.path) setActiveMasterVstPath(s.path);
if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; }
masterGuiBusyRef.current = true;
masterGuiAttemptRef.current = { path: s.path, name: s.name || s.path };
beginMasterGuiBusy();
window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => {
masterGuiBusyRef.current = false;
endMasterGuiBusy();
if (r && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); }
else if (r && r.already_running && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); }
else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)', 'info');
else window.showToast && window.showToast('Không mở được GUI embed', 'error');
}).catch(err => { masterGuiBusyRef.current = false; window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error'); });
else { setMasterGuiError('Không mở được GUI embed cho ' + (s.name || s.path) + ' — plugin có thể crash khi tải. Bấm Thử lại để spawn lại.'); window.showToast && window.showToast('Không mở được GUI embed', 'error'); }
}).catch(err => { endMasterGuiBusy(); setMasterGuiError('Lỗi mở GUI: ' + (err.message || err)); window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error'); });
};
// Nút "Thử lại" trên banner lỗi — spawn lại GUI fresh cho lần mở gần nhất.
const retryMasterGui = () => {
const t = masterGuiAttemptRef.current;
if (!t || !t.path) return;
if (masterGuiOpen) return;
openMasterVstGui({ path: t.path, name: t.name || t.path });
};
const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.path === path); if (!fx) return; setMasterVstChain([...masterVstChain, { type: 'vst3', path: fx.path, name: fx.name || fx.id, preset_b64: '', bypass: false }]);
openMasterVstGui({ path: fx.path, name: fx.name || fx.id }); };
@@ -15116,40 +15204,31 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
{/* LEFT: MODULE VIEWS */}
{masterGuiOpen ? (
<div className="flex-1 flex flex-col bg-slate-950 relative min-h-0">
<VstGuiEmbed url={masterGuiOpen.url} onClose={closeMasterGui}
<VstGuiEmbed url={masterGuiOpen.url} onClose={closeMasterGui} closeOnUnmount
onUnmounting={closeMasterGuiForPanel}
onDead={() => { window.showToast && window.showToast('Bridge VST GUI đã mất kết nối — trả về graph', 'error'); closeMasterGui(); }} />
</div>
) : (
<div className="flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar">
{/* SUB HEADER TOOLBAR */}
<div className="h-9 bg-slate-900/60 border-b border-slate-800/80 px-4 flex items-center justify-between text-xs oz-font-mono shrink-0">
<div className="flex items-center gap-3">
<span className="text-slate-400 flex items-center gap-1.5"><i data-lucide="headphones" className="w-3 h-3 text-cyan-400"></i> Delta Listen</span>
<select className="bg-slate-950 border border-slate-800 rounded px-2 py-0.5 text-[11px] text-slate-300 outline-none">
<option value="irc4">IRC IV - Classic</option>
<option value="irc3">IRC III - Balanced</option>
<option value="irc2">IRC II - Crisp</option>
</select>
</div>
<div className="flex items-center gap-2 text-[11px]">
{ozState.activeModule === 'eq' && (
<div className="flex items-center gap-1.5">
<span className="text-slate-400">EQ Presets:</span>
<select
value={ozState.eqPreset || 'flat'}
onChange={e => applyEQPreset(e.target.value)}
className="bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-[11px] outline-none focus:border-cyan-500">
{Object.keys(EQ_PRESET_LIBRARY).map(k => (
<option key={k} value={k}>{EQ_PRESET_LIBRARY[k].name}</option>
))}
</select>
</div>
{/* STATUS GUI VST — thay toolbar "Delta Listen" (không cần: GUI VST FX load mặc định) */}
{(masterGuiBusy || masterGuiError) && (
<div className={`h-9 px-4 flex items-center gap-2 text-[11px] oz-font-mono shrink-0 border-b ${masterGuiError ? 'bg-red-950/40 border-red-900/60 text-red-300' : 'bg-cyan-950/40 border-cyan-900/60 text-cyan-300'}`}>
{masterGuiBusy && !masterGuiError && (
<>
<span className="w-3 h-3 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin inline-block shrink-0"></span>
<span className="truncate">Đang mở GUI VST — plugin khởi tạo có thể mất 30-60 giây, xin chờ...</span>
</>
)}
{masterGuiError && (
<>
<span className="truncate flex-1">{masterGuiError}</span>
<button onClick={retryMasterGui} className="px-2 py-0.5 bg-red-800 hover:bg-red-700 text-white rounded text-[10px] font-bold shrink-0">Thử lại</button>
<button onClick={() => setMasterGuiError(null)} title="Ẩn" className="text-red-400 hover:text-red-200 shrink-0"><i data-lucide="x" className="w-3 h-3"></i></button>
</>
)}
<span className="text-slate-400">Learn Input Gain:</span>
<button className="bg-cyan-950 text-cyan-300 border border-cyan-800/80 px-2 py-0.5 rounded font-bold hover:bg-cyan-900 transition-colors">-11.0 LUFS</button>
</div>
</div>
)}
{/* VIEW: DYNAMIC EQ */}
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'eq' ? '' : 'hidden'}`}>
@@ -23772,17 +23851,21 @@ const App = () => {
fetch(API_BASE_URL, { signal: ctl.signal })
.then(r => { if (!cancelled) { setServerStatus(r.ok ? 'connected' : 'error'); offlineCount = 0; } })
.catch(() => {
if (!cancelled) {
setServerStatus('offline');
// Self-heal: engine chết/mất kết nối liên tục (~9s) → reload trang
// để bootstrap chạy lại: ưu tiên port engine của chính app
// (engine_port.txt) + fallback quét 8000-8010. Trước đây kẹt
// OFFLINE vĩnh viễn khi engine bị instance khác giết port.
offlineCount++;
if (offlineCount >= 3) {
offlineCount = 0;
if (cancelled) return;
offlineCount++;
// Pill chi chuyen OFFLINE sau >=2 fail lien tiep (1 fail le do webview
// nghen/timeout khong lam "SERVER OFFLINE/CONNECTED" doi lien tuc).
// Reload sau 4 fail (~12s) + guard chong reload-loop (toi da 1 lan/15s).
setServerStatus(offlineCount >= 2 ? 'offline' : 'checking...');
if (offlineCount >= 4) {
offlineCount = 0;
const now = Date.now();
let last = 0;
try { last = parseInt(sessionStorage.getItem('sf_reload_ts') || '0', 10) || 0; } catch (e) {}
if (now - last > 15000) {
try { sessionStorage.setItem('sf_reload_ts', String(now)); } catch (e) {}
try { window.location.reload(); } catch (e) {}
}
} else { setServerStatus('offline'); }
}
})
.finally(() => { busy = false; clearTimeout(t); });
File diff suppressed because one or more lines are too long