diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index 41e50bd..b10f0c1 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -9170,6 +9170,10 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
var saved = localStorage.getItem('studio_media_explorer_tempo');
return saved ? parseInt(saved) : 120;
});
+ const [tempoText, setTempoText] = React.useState(String(function() {
+ var saved = localStorage.getItem('studio_media_explorer_tempo');
+ return saved ? parseInt(saved) : 120;
+ }()));
const [zoom, setZoom] = React.useState(1.0);
const [scrollOffset, setScrollOffset] = React.useState(0);
@@ -9216,11 +9220,22 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
selStartRef.current = selStart;
selEndRef.current = selEnd;
isLoopingRef.current = isLooping;
+ const computerPathRef = React.useRef(null);
+ const computerTreeRef = React.useRef({});
+ const computerRootsRef = React.useRef(null);
+ const treePaneRef = React.useRef(null);
+ const browseComputerDirRef = React.useRef(null);
React.useEffect(() => {
setScrollOffset(0);
}, [selected]);
+ // Keep the tempo text field in sync when tempo changes from elsewhere
+ // (e.g. auto-set from a clicked MIDI file).
+ React.useEffect(() => {
+ setTempoText(String(tempo));
+ }, [tempo]);
+
React.useEffect(() => {
window.mediaExplorerActive = true;
const handleDocumentClick = (e) => {
@@ -9476,6 +9491,56 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
}
}
}
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
+ if (window.mediaExplorerActive && folderRef.current === 'computer') {
+ const target = e.target;
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ // Build the flat list of visible (expanded) tree nodes.
+ const paths = [];
+ const walk = (entry, depth) => {
+ if (!entry || !entry.path) return;
+ paths.push({ path: entry.path, entry, depth });
+ const n = computerTreeRef.current[entry.path];
+ if (n && n.expanded && n.dirs) (n.dirs || []).forEach(d => walk(d, depth + 1));
+ };
+ (computerRootsRef.current || []).forEach(r => walk(r, 0));
+ if (!paths.length) return;
+ const curPath = computerPathRef.current;
+ let idx = paths.findIndex(p => p.path === curPath);
+ if (e.key === 'ArrowUp') {
+ const ni = idx < 0 ? paths.length - 1 : Math.max(0, idx - 1);
+ navigateTreeTo(paths[ni].path);
+ } else if (e.key === 'ArrowDown') {
+ const ni = Math.min(paths.length - 1, (idx < 0 ? -1 : idx) + 1);
+ navigateTreeTo(paths[ni].path);
+ } else if (e.key === 'ArrowRight') {
+ const node = computerTreeRef.current[curPath];
+ if (node && !node.expanded) {
+ setComputerTree(prev => ({ ...prev, [curPath]: { ...(prev[curPath] || {}), expanded: true } }));
+ if (!node.dirs || !node.dirs.length) {
+ if (browseComputerDirRef.current) browseComputerDirRef.current({ name: String(curPath).split('/').pop() || curPath, path: curPath, is_dir: true, handle: node.handle });
+ }
+ } else if (idx < 0) {
+ const p0 = paths[0];
+ if (p0) navigateTreeTo(p0.path);
+ }
+ } else if (e.key === 'ArrowLeft') {
+ const node = computerTreeRef.current[curPath];
+ if (node && node.expanded) {
+ setComputerTree(prev => ({ ...prev, [curPath]: { ...(prev[curPath] || {}), expanded: false } }));
+ } else if (curPath) {
+ const i = curPath.lastIndexOf('/');
+ if (i > 0) {
+ navigateTreeTo(curPath.substring(0, i));
+ }
+ }
+ }
+ }
+ }
if (e.key === 'c' && (e.ctrlKey || e.metaKey)) {
if (window.mediaExplorerActive) {
const target = e.target;
@@ -9502,6 +9567,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
const [computerFiles, setComputerFiles] = React.useState([]);
const [computerMode, setComputerMode] = React.useState('server');
const [clientRoot, setClientRoot] = React.useState(null);
+ computerPathRef.current = computerPath;
+ computerTreeRef.current = computerTree;
+ computerRootsRef.current = computerRoots;
const [favorites, setFavorites] = React.useState(function() {
try { return JSON.parse(localStorage.getItem('studio_media_explorer_favorites_v1') || '[]'); } catch (e) { return []; }
}());
@@ -9921,6 +9989,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
return null;
}
};
+ browseComputerDirRef.current = browseComputerDir;
const toggleComputerDir = async (entry) => {
if (!entry) return;
@@ -10116,6 +10185,14 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
if (selectTokenRef.current !== (token || selectTokenRef.current)) return;
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
if (!midiResult || !midiResult.length) return;
+ // Feature: selecting/playing a MIDI file auto-sets the playback tempo to
+ // the file's own BPM (so the preview plays in time).
+ const fileBpm = (midiResult[0] && midiResult[0].bpm) || 120;
+ const newTempo = Math.max(40, Math.min(300, Math.round(fileBpm)));
+ setTempo(newTempo);
+ setTempoText(String(newTempo));
+ tempoRef.current = newTempo;
+ try { localStorage.setItem('studio_media_explorer_tempo', String(newTempo)); } catch (e) {}
const ctx = getAudioContext();
const bpmVal = tempoRef.current || 120;
const secondsPerBeat = 60.0 / bpmVal;
@@ -10491,16 +10568,94 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, audioDuration, midiNotes, selected, folder, isPlaying, zoom, selStart, selEnd, scrollOffset]);
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
+ const commitTempo = (v) => {
+ const clamped = Math.max(40, Math.min(300, Math.round(v) || 120));
+ setTempo(clamped);
+ setTempoText(String(clamped));
+ tempoRef.current = clamped;
+ try { localStorage.setItem('studio_media_explorer_tempo', String(clamped)); } catch (e) {}
+ // Re-schedule a currently playing MIDI preview at the new tempo.
+ const cur = selectedRef.current;
+ if (isPlayingRef.current && cur && isMidiFile(cur) && (cur.handle || cur.path || cur.file_id || cur.fileId)) {
+ selectTokenRef.current++;
+ const token = selectTokenRef.current;
+ try { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') window.SonicSF.stopAll(); } catch (e) {}
+ playMidiPreview(cur, token);
+ }
+ };
+
+ // Center a folder node vertically in the tree pane (feature: file click focuses
+ // its parent folder in the middle of the tree).
+ const centerTreeNodeInPane = (path) => {
+ setTimeout(() => {
+ const pane = treePaneRef.current;
+ if (!pane) return;
+ const el = document.querySelector(`[data-tree-path="${path}"]`);
+ if (!el) return;
+ const paneRect = pane.getBoundingClientRect();
+ const elRect = el.getBoundingClientRect();
+ const target = pane.scrollTop + (elRect.top - paneRect.top) - paneRect.height / 2 + elRect.height / 2;
+ pane.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
+ }, 80);
+ };
+
+ // Flat list of currently visible (expanded) tree nodes, in render order.
+ const getVisibleTreePaths = () => {
+ const out = [];
+ const walk = (entry, depth) => {
+ if (!entry || !entry.path) return;
+ out.push({ path: entry.path, entry, depth });
+ const node = computerTreeRef.current[entry.path];
+ if (node && node.expanded && node.dirs) {
+ (node.dirs || []).forEach(d => walk(d, depth + 1));
+ }
+ };
+ (computerRootsRef.current || []).forEach(r => walk(r, 0));
+ return out;
+ };
+
+ const navigateTreeTo = (path) => {
+ const paths = getVisibleTreePaths();
+ const target = paths.find(p => p.path === path);
+ if (!target) return;
+ // Expand all ancestor nodes so the target is visible in the tree.
+ setComputerTree(prev => {
+ const next = { ...prev };
+ let current = path;
+ while (current) {
+ const n = next[current];
+ if (n) next[current] = { ...n, expanded: true };
+ const idx = current.lastIndexOf('/');
+ if (idx <= 0) break;
+ current = current.substring(0, idx);
+ }
+ return next;
+ });
+ setComputerPath(path);
+ if (browseComputerDirRef.current) browseComputerDirRef.current(target.entry);
+ centerTreeNodeInPane(path);
+ };
+
const handleSelect = (f) => {
if (!f || f.is_dir) return;
-
+
+ // When a MIDI file is clicked, automatically set the play tempo to the
+ // MIDI file's tempo (BPM from metadata, or after parsing in playMidiPreview).
+ if (isMidiFile(f) && f.bpm) {
+ const v = Math.max(40, Math.min(300, Math.round(parseFloat(f.bpm) || 120)));
+ setTempo(v);
+ setTempoText(String(v));
+ tempoRef.current = v;
+ try { localStorage.setItem('studio_media_explorer_tempo', String(v)); } catch (e) {}
+ }
+
// Find parent path of selected file and scroll it into view in Tree pane
if (f.path) {
const lastSlash = f.path.lastIndexOf('/');
if (lastSlash > 0) {
const parentPath = f.path.substring(0, lastSlash);
setComputerPath(parentPath);
-
+
// Expand all parent nodes in computerTree
setComputerTree(prev => {
const next = { ...prev };
@@ -10518,13 +10673,8 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
return next;
});
- // Scroll the parent tree element into view
- setTimeout(() => {
- const treeNodeEl = document.querySelector(`[data-tree-path="${parentPath}"]`);
- if (treeNodeEl) {
- treeNodeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- }, 100);
+ // Center the parent folder node in the middle of the tree pane
+ centerTreeNodeInPane(parentPath);
}
}
@@ -10652,7 +10802,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
{/* DIRECTORY TREE */}
-
+
<Track Templates>
<Project Directory>
@@ -10870,9 +11020,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
Tempo:
-
-
{ const v = Math.max(40, Math.min(300, parseInt(e.target.value) || 120)); setTempo(v); localStorage.setItem('studio_media_explorer_tempo', v.toString()); }} className="w-full text-xs text-right outline-none bg-transparent" />
-
+
+
{ const raw = e.target.value; setTempoText(raw); const n = parseInt(raw); if (n >= 40 && n <= 300) commitTempo(n); }} onBlur={() => { const n = parseInt(tempoText); commitTempo(n); }} onKeyDown={e => { if (e.key === 'Enter') { e.currentTarget.blur(); } }} className="w-full text-xs text-right outline-none bg-transparent" />
+
BPM
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index f3527ec..206e4e6 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -274,16 +274,19 @@ for(let i=0;i
{window.removeEventListener('resize',resizeAll);if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[isOpen]);React.useEffect(()=>{if(!isOpen){stopAudioDemo();knobsInitializedRef.current=false;}else{setTimeout(()=>{try{window.lucide.createIcons();}catch(e){}},100);}},[isOpen]);if(!isOpen)return null;const switchModule=name=>setOzState(prev=>({...prev,activeModule:name}));const bandKnob=(param,min,max,val,unit,label,freq,color,filterType)=>/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-2 flex flex-col justify-between items-center rounded-lg"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[10px] font-bold w-full",style:{color}},/*#__PURE__*/React.createElement("span",null,label),/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-mono text-slate-400"},freq)),/*#__PURE__*/React.createElement("div",{className:"my-1"},/*#__PURE__*/React.createElement(MasteringKnob,{param:param,min:min,max:max,value:ozState[param]!==undefined?ozState[param]:val,unit:unit,color:color,onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono text-center mt-1"},filterType));return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm",onClick:onClose},/*#__PURE__*/React.createElement("div",{id:"masteringModalBody",className:"flex flex-col bg-slate-950 text-slate-200 w-[95vw] h-[92vh] max-w-[1400px] rounded-2xl border border-slate-800/80 shadow-2xl overflow-hidden",onClick:e=>e.stopPropagation(),style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("header",{className:"h-12 bg-slate-900/90 border-b border-slate-800/80 flex items-center justify-between px-4 z-30 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("div",{className:"w-7 h-7 rounded-lg bg-cyan-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"zap",className:"w-3.5 h-3.5"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("h1",{className:"text-xs font-bold tracking-wider text-white flex items-center gap-2"},"MASTERING SUITE ",/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded oz-font-mono"},"WEB MASTERING V10.5")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 bg-slate-950 px-3 py-1 rounded-lg border border-slate-800/80"},/*#__PURE__*/React.createElement("button",{onClick:startAudioDemo,className:"px-3 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Play Reference")),/*#__PURE__*/React.createElement("button",{onClick:stopAudioDemo,className:"px-3 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs font-semibold flex items-center gap-1.5 transition-all"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Stop")),/*#__PURE__*/React.createElement("div",{className:"h-4 w-[1px] bg-slate-800 mx-1"}),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Preset:"),/*#__PURE__*/React.createElement("select",{className:"bg-slate-900 border border-slate-700 rounded px-2 py-0.5 text-xs text-cyan-300 outline-none focus:border-cyan-500"},/*#__PURE__*/React.createElement("option",{value:"adaptive"},"Adaptive Dynamic Master"),/*#__PURE__*/React.createElement("option",{value:"edm_club"},"EDM / Club Punch Maximizer"),/*#__PURE__*/React.createElement("option",{value:"wide_space"},"Cinematic Stereo Expansion"),/*#__PURE__*/React.createElement("option",{value:"transparent"},"Transparent High-Clarity Limiter")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Target LUFS:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded"},"-11.0 LUFS"),/*#__PURE__*/React.createElement("button",{onClick:()=>{getAudioContext();setMasterConnected(prev=>!prev);},className:`px-2 py-0.5 rounded text-[10px] font-bold border transition-colors ${masterConnected?'bg-emerald-700 border-emerald-500 text-white':'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}`},masterConnected?'Master ON':'Master OFF'),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-2 py-0.5 rounded text-[10px] font-bold border transition-colors bg-slate-800 border-slate-700 text-slate-300 hover:bg-[#c2410c] hover:border-red-600 hover:text-white"},"Đóng"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-500 hover:text-slate-300 ml-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"})))),/*#__PURE__*/React.createElement("div",{className:"h-16 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-2 overflow-x-auto shrink-0 oz-scrollbar"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 oz-font-mono shrink-0"},"CHAIN:"),/*#__PURE__*/React.createElement("div",{onClick:()=>switchModule('eq'),className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all ${ozState.activeModule==='eq'?'oz-card-active':'oz-card'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();setOzState(prev=>({...prev,eqActive:!prev.eqActive}));},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold",style:{backgroundColor:ozState.eqActive?'#38bdf8':'#334155',color:ozState.eqActive?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-slate-200"},"Dynamic EQ"),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-cyan-400 oz-font-mono"},"4-Band Peak"))),/*#__PURE__*/React.createElement("i",{"data-lucide":"activity",className:"w-3.5 h-3.5 text-slate-500"})),/*#__PURE__*/React.createElement("div",{onClick:()=>switchModule('imager'),className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all ${ozState.activeModule==='imager'?'oz-card-active':'oz-card'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();setOzState(prev=>({...prev,imagerActive:!prev.imagerActive}));},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold",style:{backgroundColor:ozState.imagerActive?'#38bdf8':'#334155',color:ozState.imagerActive?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-slate-200"},"Imager"),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-400 oz-font-mono"},"4-Band Width"))),/*#__PURE__*/React.createElement("i",{"data-lucide":"radio",className:"w-3.5 h-3.5 text-slate-500"})),/*#__PURE__*/React.createElement("div",{onClick:()=>switchModule('maximizer'),className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all ${ozState.activeModule==='maximizer'?'oz-card-active':'oz-card'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();setOzState(prev=>({...prev,maximizerActive:!prev.maximizerActive}));},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold",style:{backgroundColor:ozState.maximizerActive?'#38bdf8':'#334155',color:ozState.maximizerActive?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-slate-200"},"Maximizer"),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-400 oz-font-mono"},"IRC IV True Peak"))),/*#__PURE__*/React.createElement("i",{"data-lucide":"gauge",className:"w-3.5 h-3.5 text-slate-500"})),/*#__PURE__*/React.createElement("div",{className:"w-24 h-12 rounded-lg border border-dashed border-slate-800 flex items-center justify-center text-slate-600 hover:text-slate-400 hover:border-slate-600 cursor-pointer transition-all shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("main",{className:"flex-1 flex overflow-hidden min-h-0"},/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar"},/*#__PURE__*/React.createElement("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"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"headphones",className:"w-3 h-3 text-cyan-400"})," Delta Listen"),/*#__PURE__*/React.createElement("select",{className:"bg-slate-950 border border-slate-800 rounded px-2 py-0.5 text-[11px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"irc4"},"IRC IV - Classic"),/*#__PURE__*/React.createElement("option",{value:"irc3"},"IRC III - Balanced"),/*#__PURE__*/React.createElement("option",{value:"irc2"},"IRC II - Crisp"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-[11px]"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Learn Input Gain:"),/*#__PURE__*/React.createElement("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"))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='eq'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"relative w-full h-64 bg-slate-950 border border-slate-800/90 rounded-xl overflow-hidden shadow-inner cursor-crosshair"},/*#__PURE__*/React.createElement("canvas",{ref:eqCanvasRef,className:"w-full h-full block"})),/*#__PURE__*/React.createElement("div",{className:"oz-panel p-3 rounded-xl grid grid-cols-4 gap-3"},bandKnob('eqLowGain',-12,12,1.5,'dB','BAND 1 (LOW)','100 Hz','#22d3ee','Shelf Filter'),bandKnob('eqMid1Gain',-12,12,-1.0,'dB','BAND 2 (MID LOW)','822 Hz','#fbbf24','Dynamic Bell (Q: 0.7)'),bandKnob('eqMid2Gain',-12,12,2.0,'dB','BAND 3 (MID HIGH)','3.2 kHz','#a855f7','Dynamic Bell (Q: 1.2)'),bandKnob('eqHighGain',-12,12,1.8,'dB','BAND 4 (HIGH)','10 kHz','#34d399','High Shelf'))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='imager'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-12 gap-4 flex-1"},/*#__PURE__*/React.createElement("div",{className:"col-span-8 bg-slate-950 border border-slate-800/90 rounded-xl p-3 flex flex-col relative shadow-inner"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-cyan-400 uppercase oz-font-mono mb-2"},/*#__PURE__*/React.createElement("i",{"data-lucide":"radio",className:"w-3.5 h-3.5"})," Polar Vectorscope & Correlation Meter"),/*#__PURE__*/React.createElement("div",{className:"flex-1 relative w-full h-56 flex items-center justify-center"},/*#__PURE__*/React.createElement("canvas",{ref:imagerCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-4 oz-panel p-3 rounded-xl flex flex-col justify-between"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-300 uppercase oz-font-mono"},"4-Band Stereo Width"),/*#__PURE__*/React.createElement("div",{className:"space-y-3 my-auto"},[{id:'w1',label:'Band 1 (0-100Hz)',color:'#22d3ee',val:ozState.w1},{id:'w2',label:'Band 2 (100-1kHz)',color:'#fbbf24',val:ozState.w2},{id:'w3',label:'Band 3 (1k-6kHz)',color:'#a855f7',val:ozState.w3},{id:'w4',label:'Band 4 (6k-20kHz)',color:'#34d399',val:ozState.w4}].map(b=>/*#__PURE__*/React.createElement("div",{key:b.id},/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-[11px] font-mono mb-1"},/*#__PURE__*/React.createElement("span",{style:{color:b.color,fontWeight:700}},b.label),/*#__PURE__*/React.createElement("span",{id:b.id+'Val'},b.val,"%")),/*#__PURE__*/React.createElement("input",{type:"range",min:"-100",max:"100",value:b.val,onChange:e=>setOzState(prev=>({...prev,[b.id]:parseInt(e.target.value)})),className:"w-full h-1 cursor-pointer",style:{accentColor:b.color}}))))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='maximizer'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-400 uppercase oz-font-mono mb-3"},"Maximizer Gain Boost"),/*#__PURE__*/React.createElement("div",{className:"my-2"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxGain",min:0,max:12,value:ozState.maxGain,unit:"dB",color:"#22d3ee",size:"large",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs oz-font-mono flex flex-col gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center w-full"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Ceiling Level:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold"},ozState.ceiling.toFixed(2)," dB")),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:ozState.ceiling,onChange:e=>setOzState(prev=>({...prev,ceiling:parseFloat(e.target.value)})),className:"w-full h-1 cursor-pointer accent-cyan-400"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxUpward",min:0,max:10,value:ozState.maxUpward,unit:"dB",label:"UPWARD COMPRESS",color:"#22d3ee",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxSoftClip",min:0,max:100,value:ozState.maxSoftClip,unit:"%",label:"SOFT CLIPPER",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxTransient",min:0,max:100,value:ozState.maxTransient,unit:"%",label:"TRANSIENT EMPHASIS",color:"#34d399",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))}))))),/*#__PURE__*/React.createElement("div",{className:"border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between pb-1.5 border-b border-slate-800/80 mb-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-[11px] font-bold text-white uppercase tracking-wider"},"Wave Observer"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/60 px-1.5 py-0.2 rounded font-mono"},"Real-time Oscilloscope")),/*#__PURE__*/React.createElement("div",{className:"flex gap-2"},['Scope','Settings','Help','About'].map(tab=>/*#__PURE__*/React.createElement("button",{key:tab,className:`px-2 py-0.5 rounded text-[10px] font-bold ${tab==='Scope'?'bg-cyan-950 text-cyan-300 border border-cyan-800/60':'text-slate-400 hover:text-slate-200'}`},tab)))),/*#__PURE__*/React.createElement("div",{className:"relative w-full h-32 bg-[#090d16] border border-slate-800/80 rounded-lg overflow-hidden mb-2"},/*#__PURE__*/React.createElement("canvas",{ref:woCanvasRef,className:"w-full h-full block"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between gap-4 text-[10px] oz-font-mono text-slate-400 mt-1 select-none"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 bg-slate-950 px-2.5 py-1.5 rounded border border-slate-800/80 shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-300"},"Input"),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 w-20"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-[8px] text-slate-500 w-2"},"L"),/*#__PURE__*/React.createElement("div",{className:"w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center"},/*#__PURE__*/React.createElement("div",{ref:woLeftMeterRef,className:"h-full bg-cyan-400 transition-all duration-75",style:{width:'0%'}}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-[8px] text-slate-500 w-2"},"R"),/*#__PURE__*/React.createElement("div",{className:"w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center"},/*#__PURE__*/React.createElement("div",{ref:woRightMeterRef,className:"h-full bg-teal-500 transition-all duration-75",style:{width:'0%'}}))))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-4 bg-slate-950 px-3 py-1 rounded border border-slate-800/80 flex-1 justify-around"},/*#__PURE__*/React.createElement("span",{className:"font-bold text-slate-300 uppercase tracking-widest text-[9px]"},"Scope"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Channel"),/*#__PURE__*/React.createElement("select",{value:woChannel,onChange:e=>setWoChannel(e.target.value),className:"bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"),/*#__PURE__*/React.createElement("option",{value:"left"},"Left Only"),/*#__PURE__*/React.createElement("option",{value:"right"},"Right Only"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Mode"),/*#__PURE__*/React.createElement("select",{value:woMode,onChange:e=>setWoMode(e.target.value),className:"bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"waveform"},"Waveform"),/*#__PURE__*/React.createElement("option",{value:"envelope"},"Envelope"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Duration:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold w-10 text-right"},woDuration.toFixed(3),"s"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0.5",max:"5.0",step:"0.1",value:woDuration,onChange:e=>setWoDuration(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer accent-cyan-400"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"V.Zoom:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold w-12 text-right"},woZoom.toFixed(1)," dB"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"24",step:"0.5",value:woZoom,onChange:e=>setWoZoom(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer accent-cyan-400"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>setWoPaused(!woPaused),className:`px-3 py-1 rounded font-bold border transition shadow-md shrink-0 flex items-center gap-1 ${woPaused?'bg-amber-800 hover:bg-amber-700 border-amber-500 text-amber-100':'bg-slate-800 hover:bg-slate-700 border-slate-700 text-slate-200'}`},woPaused?'Resume':'Pause')))),/*#__PURE__*/React.createElement("div",{className:"w-64 bg-slate-900 border-l border-slate-800/80 p-3 flex flex-col justify-between shadow-2xl oz-font-mono text-xs z-20 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-2 mb-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-white tracking-wider uppercase flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3 h-3 text-cyan-400"})," I/O METERS"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-300 px-1 py-0.5 rounded"},"TRUE PEAK")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-[10px] text-center mb-2"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"IN PEAK"),/*#__PURE__*/React.createElement("div",{id:"inPeakText",className:"text-cyan-400 font-bold oz-font-mono"},"-inf dB")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"OUT PEAK"),/*#__PURE__*/React.createElement("div",{id:"outPeakText",className:"text-emerald-400 font-bold oz-font-mono"},"-inf dB"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-slate-950 border border-slate-800/90 rounded-xl p-2 flex justify-around relative overflow-hidden my-1"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"IN"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-5 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:inMeterCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"OUT"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-6 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:outMeterCanvasRef,className:"w-full h-full block"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1.5 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setOzState(prev=>({...prev,isBypassed:!prev.isBypassed})),className:`py-1.5 rounded text-[10px] font-bold border transition-colors ${ozState.isBypassed?'bg-cyan-600 border-cyan-500 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}`},"Bypass"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 rounded text-[10px] font-bold border border-slate-700 transition-colors"},"Gain Match"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Codec"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Dither"))))));};// ── Media Explorer Panel (from md/51_MEDIA_EXPLORER.md) ──
-const MEDIA_LIBRARY_SAMPLES=[{name:"MIDI_Loop_01.mid",events:95,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_02_Bass.mid",events:48,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_03_Lead.mid",events:110,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_04.mid",events:76,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_05_Bass.mid",events:52,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_06.mid",events:88,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"}];const MediaExplorerPanel=({height,clipboardRef})=>{const[userFiles,setUserFiles]=React.useState([]);const[folder,setFolder]=React.useState('library');const[selected,setSelected]=React.useState(null);const[filterText,setFilterText]=React.useState('');const[viewMode,setViewMode]=React.useState('details');const[isPlaying,setIsPlaying]=React.useState(false);const[isPaused,setIsPaused]=React.useState(false);const[isLooping,setIsLooping]=React.useState(false);const[autoPlay,setAutoPlay]=React.useState(true);const[pitch,setPitch]=React.useState(0.0);const[rate,setRate]=React.useState(1.0);const[volumeDb,setVolumeDb]=React.useState(0.0);const[currentTime,setCurrentTime]=React.useState(0);const[peaks,setPeaks]=React.useState(null);const[audioBuffer,setAudioBuffer]=React.useState(null);const[audioDuration,setAudioDuration]=React.useState(0);const[midiNotes,setMidiNotes]=React.useState(null);const[midiTotal,setMidiTotal]=React.useState(4);const[midiBars,setMidiBars]=React.useState(1);const[midiTotalBeats,setMidiTotalBeats]=React.useState(16);const[midiFileBpm,setMidiFileBpm]=React.useState(120);const[tempo,setTempo]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_tempo');return saved?parseInt(saved):120;});const[zoom,setZoom]=React.useState(1.0);const[scrollOffset,setScrollOffset]=React.useState(0);const scrollOffsetRef=React.useRef(0);scrollOffsetRef.current=scrollOffset;const[selStart,setSelStart]=React.useState(null);const[selEnd,setSelEnd]=React.useState(null);const[isDragging,setIsDragging]=React.useState(false);const[previewCtxMenu,setPreviewCtxMenu]=React.useState(null);// { x, y }
+const MEDIA_LIBRARY_SAMPLES=[{name:"MIDI_Loop_01.mid",events:95,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_02_Bass.mid",events:48,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_03_Lead.mid",events:110,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_04.mid",events:76,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_05_Bass.mid",events:52,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_06.mid",events:88,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"}];const MediaExplorerPanel=({height,clipboardRef})=>{const[userFiles,setUserFiles]=React.useState([]);const[folder,setFolder]=React.useState('library');const[selected,setSelected]=React.useState(null);const[filterText,setFilterText]=React.useState('');const[viewMode,setViewMode]=React.useState('details');const[isPlaying,setIsPlaying]=React.useState(false);const[isPaused,setIsPaused]=React.useState(false);const[isLooping,setIsLooping]=React.useState(false);const[autoPlay,setAutoPlay]=React.useState(true);const[pitch,setPitch]=React.useState(0.0);const[rate,setRate]=React.useState(1.0);const[volumeDb,setVolumeDb]=React.useState(0.0);const[currentTime,setCurrentTime]=React.useState(0);const[peaks,setPeaks]=React.useState(null);const[audioBuffer,setAudioBuffer]=React.useState(null);const[audioDuration,setAudioDuration]=React.useState(0);const[midiNotes,setMidiNotes]=React.useState(null);const[midiTotal,setMidiTotal]=React.useState(4);const[midiBars,setMidiBars]=React.useState(1);const[midiTotalBeats,setMidiTotalBeats]=React.useState(16);const[midiFileBpm,setMidiFileBpm]=React.useState(120);const[tempo,setTempo]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_tempo');return saved?parseInt(saved):120;});const[tempoText,setTempoText]=React.useState(String(function(){var saved=localStorage.getItem('studio_media_explorer_tempo');return saved?parseInt(saved):120;}()));const[zoom,setZoom]=React.useState(1.0);const[scrollOffset,setScrollOffset]=React.useState(0);const scrollOffsetRef=React.useRef(0);scrollOffsetRef.current=scrollOffset;const[selStart,setSelStart]=React.useState(null);const[selEnd,setSelEnd]=React.useState(null);const[isDragging,setIsDragging]=React.useState(false);const[previewCtxMenu,setPreviewCtxMenu]=React.useState(null);// { x, y }
const containerRef=React.useRef(null);// Refs mirror latest state so drawCanvas (also called from rAF clock with a
// stale closure) always draws the currently selected file, not the old one.
-const selectedRef=React.useRef(null);const peaksRef=React.useRef(null);const audioBufferRef=React.useRef(null);const audioDurationRef=React.useRef(0);const midiNotesRef=React.useRef(null);const midiTotalRef=React.useRef(4);const midiBarsRef=React.useRef(1);const midiTotalBeatsRef=React.useRef(16);const midiFileBpmRef=React.useRef(120);const isPlayingRef=React.useRef(false);const isPausedRef=React.useRef(false);const folderRef=React.useRef('library');const tempoRef=React.useRef(120);const currentTimeRef=React.useRef(0);const selStartRef=React.useRef(null);const selEndRef=React.useRef(null);const isLoopingRef=React.useRef(false);selectedRef.current=selected;peaksRef.current=peaks;audioBufferRef.current=audioBuffer;audioDurationRef.current=audioDuration;midiNotesRef.current=midiNotes;midiTotalRef.current=midiTotal;midiBarsRef.current=midiBars;midiTotalBeatsRef.current=midiTotalBeats;midiFileBpmRef.current=midiFileBpm;isPlayingRef.current=isPlaying;isPausedRef.current=isPaused;folderRef.current=folder;tempoRef.current=tempo;currentTimeRef.current=currentTime;selStartRef.current=selStart;selEndRef.current=selEnd;isLoopingRef.current=isLooping;React.useEffect(()=>{setScrollOffset(0);},[selected]);React.useEffect(()=>{window.mediaExplorerActive=true;const handleDocumentClick=e=>{if(containerRef.current&&containerRef.current.contains(e.target)){window.mediaExplorerActive=true;}else{window.mediaExplorerActive=false;}};document.addEventListener('mousedown',handleDocumentClick,{capture:true});return()=>{document.removeEventListener('mousedown',handleDocumentClick,{capture:true});window.mediaExplorerActive=false;};},[]);const getCanvasLayout=()=>{const canvas=canvasRef.current;if(!canvas)return{w:300,h:100,pxPerBeat:42,pxPerSec:84,offset:0,dur:0,contentW:0};const w=canvas.clientWidth;const h=canvas.clientHeight;const f=selectedRef.current;const isMidi=isMidiFile(f);const curTempo=tempoRef.current||120;const pxPerBeat=42*zoom;const pxPerSec=pxPerBeat*curTempo/60;const dur=fileDuration(f);let contentW=0;if(isMidi){const isRealMidi=midiNotesRef.current&&midiNotesRef.current.length&&midiTotalBeatsRef.current>0;const totalBeats=isRealMidi?Math.max(midiTotalBeatsRef.current,4):Math.max(4,Math.ceil(dur*curTempo/60)||16);contentW=Math.max(1,totalBeats*pxPerBeat);}else{contentW=Math.max(1,dur*pxPerSec);}let offset=scrollOffset;if(isPlayingRef.current&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,currentTimeRef.current*pxPerSec-w/2));}else{offset=Math.max(0,Math.min(contentW-w,scrollOffset));}return{w,h,pxPerBeat,pxPerSec,offset,dur,contentW};};const getSelectedAudioBuffer=async()=>{if(audioBufferRef.current)return audioBufferRef.current;if(!selectedRef.current)return null;const buf=await readLocalFileBuffer(selectedRef.current);if(!buf)return null;try{const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);setAudioBuffer(decoded);return decoded;}catch(e){console.error(e);return null;}};const handleCanvasWheel=e=>{if(!selected)return;e.preventDefault();if(e.shiftKey){const scrollSpeed=45;const direction=e.deltaY>0?1:-1;setScrollOffset(prev=>{const{contentW,w}=getCanvasLayout();const maxScroll=Math.max(0,contentW-w);return Math.max(0,Math.min(maxScroll,prev+direction*scrollSpeed));});return;}const zoomFactor=1.15;if(e.deltaY<0){setZoom(prev=>Math.min(10.0,prev*zoomFactor));}else{setZoom(prev=>Math.max(0.2,prev/zoomFactor));}};// React attaches onWheel passively at the root, so e.preventDefault() there is
+const selectedRef=React.useRef(null);const peaksRef=React.useRef(null);const audioBufferRef=React.useRef(null);const audioDurationRef=React.useRef(0);const midiNotesRef=React.useRef(null);const midiTotalRef=React.useRef(4);const midiBarsRef=React.useRef(1);const midiTotalBeatsRef=React.useRef(16);const midiFileBpmRef=React.useRef(120);const isPlayingRef=React.useRef(false);const isPausedRef=React.useRef(false);const folderRef=React.useRef('library');const tempoRef=React.useRef(120);const currentTimeRef=React.useRef(0);const selStartRef=React.useRef(null);const selEndRef=React.useRef(null);const isLoopingRef=React.useRef(false);selectedRef.current=selected;peaksRef.current=peaks;audioBufferRef.current=audioBuffer;audioDurationRef.current=audioDuration;midiNotesRef.current=midiNotes;midiTotalRef.current=midiTotal;midiBarsRef.current=midiBars;midiTotalBeatsRef.current=midiTotalBeats;midiFileBpmRef.current=midiFileBpm;isPlayingRef.current=isPlaying;isPausedRef.current=isPaused;folderRef.current=folder;tempoRef.current=tempo;currentTimeRef.current=currentTime;selStartRef.current=selStart;selEndRef.current=selEnd;isLoopingRef.current=isLooping;const computerPathRef=React.useRef(null);const computerTreeRef=React.useRef({});const computerRootsRef=React.useRef(null);const treePaneRef=React.useRef(null);const browseComputerDirRef=React.useRef(null);React.useEffect(()=>{setScrollOffset(0);},[selected]);// Keep the tempo text field in sync when tempo changes from elsewhere
+// (e.g. auto-set from a clicked MIDI file).
+React.useEffect(()=>{setTempoText(String(tempo));},[tempo]);React.useEffect(()=>{window.mediaExplorerActive=true;const handleDocumentClick=e=>{if(containerRef.current&&containerRef.current.contains(e.target)){window.mediaExplorerActive=true;}else{window.mediaExplorerActive=false;}};document.addEventListener('mousedown',handleDocumentClick,{capture:true});return()=>{document.removeEventListener('mousedown',handleDocumentClick,{capture:true});window.mediaExplorerActive=false;};},[]);const getCanvasLayout=()=>{const canvas=canvasRef.current;if(!canvas)return{w:300,h:100,pxPerBeat:42,pxPerSec:84,offset:0,dur:0,contentW:0};const w=canvas.clientWidth;const h=canvas.clientHeight;const f=selectedRef.current;const isMidi=isMidiFile(f);const curTempo=tempoRef.current||120;const pxPerBeat=42*zoom;const pxPerSec=pxPerBeat*curTempo/60;const dur=fileDuration(f);let contentW=0;if(isMidi){const isRealMidi=midiNotesRef.current&&midiNotesRef.current.length&&midiTotalBeatsRef.current>0;const totalBeats=isRealMidi?Math.max(midiTotalBeatsRef.current,4):Math.max(4,Math.ceil(dur*curTempo/60)||16);contentW=Math.max(1,totalBeats*pxPerBeat);}else{contentW=Math.max(1,dur*pxPerSec);}let offset=scrollOffset;if(isPlayingRef.current&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,currentTimeRef.current*pxPerSec-w/2));}else{offset=Math.max(0,Math.min(contentW-w,scrollOffset));}return{w,h,pxPerBeat,pxPerSec,offset,dur,contentW};};const getSelectedAudioBuffer=async()=>{if(audioBufferRef.current)return audioBufferRef.current;if(!selectedRef.current)return null;const buf=await readLocalFileBuffer(selectedRef.current);if(!buf)return null;try{const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);setAudioBuffer(decoded);return decoded;}catch(e){console.error(e);return null;}};const handleCanvasWheel=e=>{if(!selected)return;e.preventDefault();if(e.shiftKey){const scrollSpeed=45;const direction=e.deltaY>0?1:-1;setScrollOffset(prev=>{const{contentW,w}=getCanvasLayout();const maxScroll=Math.max(0,contentW-w);return Math.max(0,Math.min(maxScroll,prev+direction*scrollSpeed));});return;}const zoomFactor=1.15;if(e.deltaY<0){setZoom(prev=>Math.min(10.0,prev*zoomFactor));}else{setZoom(prev=>Math.max(0.2,prev/zoomFactor));}};// React attaches onWheel passively at the root, so e.preventDefault() there is
// ignored and Chrome logs "Unable to preventDefault inside passive event listener".
// Use a native non-passive wheel listener so page scroll is actually blocked.
const handleCanvasWheelRef=React.useRef(handleCanvasWheel);handleCanvasWheelRef.current=handleCanvasWheel;React.useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const h=e=>handleCanvasWheelRef.current(e);canvas.addEventListener('wheel',h,{passive:false});return()=>canvas.removeEventListener('wheel',h);},[]);const handleCanvasMouseDown=e=>{if(!selected)return;if(e.button===2)return;// Right click context menu
const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const clientX=e.clientX-rect.left;const{pxPerSec,pxPerBeat,offset}=getCanvasLayout();const isMidi=isMidiFile(selected);const value=isMidi?(clientX+offset)/pxPerBeat// in beats
:(clientX+offset)/pxPerSec;// in seconds
-setSelStart(value);setSelEnd(value);setIsDragging(true);setPreviewCtxMenu(null);};const handleCanvasMouseMove=e=>{if(!isDragging||!selected)return;const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const clientX=e.clientX-rect.left;const{pxPerSec,pxPerBeat,offset}=getCanvasLayout();const isMidi=isMidiFile(selected);const value=isMidi?(clientX+offset)/pxPerBeat:(clientX+offset)/pxPerSec;setSelEnd(value);};const handleCanvasMouseUp=e=>{if(isDragging){setIsDragging(false);}};const handleCanvasContextMenu=e=>{e.preventDefault();if(!selected||selStart===null||selEnd===null||Math.abs(selStart-selEnd)<0.01)return;setPreviewCtxMenu({x:e.clientX,y:e.clientY});};const handleCopySelection=async()=>{if(!selected||selStart===null||selEnd===null)return;const isMidi=isMidiFile(selected);const startVal=Math.min(selStart,selEnd);const endVal=Math.max(selStart,selEnd);if(isMidi){if(!midiNotes||!midiNotes.length){window.showToast&&window.showToast('Không có dữ liệu MIDI để sao chép','warning');return;}const copiedNotes=midiNotes.filter(n=>n.start_beat>=startVal&&n.start_beat<=endVal).map(n=>({...n,start_beat:n.start_beat-startVal}));if(!copiedNotes.length){window.showToast&&window.showToast('Không có note MIDI nào trong vùng chọn','warning');return;}const selectDurBeats=endVal-startVal;const secondsPerBeat=60/(tempo||120);const selectDurSec=selectDurBeats*secondsPerBeat;const clipObj={type:'midi',notes:copiedNotes,duration:selectDurSec,name:selected.name||'MIDI Selection',color:'#a855f7'};if(clipboardRef)clipboardRef.current=clipObj;window.globalStudioClipboard=clipObj;window.showToast&&window.showToast(`Đã sao chép ${copiedNotes.length} notes MIDI.`,'success');}else{const activeBuf=await getSelectedAudioBuffer();if(!activeBuf){window.showToast&&window.showToast('Không thể tải dữ liệu âm thanh để sao chép','error');return;}const sr=activeBuf.sampleRate;const startSample=Math.floor(startVal*sr);const endSample=Math.floor(endVal*sr);const len=Math.max(1,endSample-startSample);try{const ctx=getAudioContext();const numCh=activeBuf.numberOfChannels||1;const clipBuffer=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch{const handleGlobalClick=()=>setPreviewCtxMenu(null);window.addEventListener('click',handleGlobalClick);return()=>window.removeEventListener('click',handleGlobalClick);},[]);React.useEffect(()=>{const handleKeyDown=e=>{if(e.key===' '||e.code==='Space'){if(window.mediaExplorerActive){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}e.preventDefault();e.stopPropagation();if(isPlayingRef.current){stopMediaPlayback();}else{const cur=selectedRef.current;if(cur){selectTokenRef.current++;playSelected(cur,selectTokenRef.current);}}}}if(e.key==='c'&&(e.ctrlKey||e.metaKey)){if(window.mediaExplorerActive){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}if(selStartRef.current!==null&&selEndRef.current!==null&&Math.abs(selStartRef.current-selEndRef.current)>0.01){e.preventDefault();e.stopPropagation();handleCopySelection();}}}};window.addEventListener('keydown',handleKeyDown,{capture:true});return()=>{window.removeEventListener('keydown',handleKeyDown,{capture:true});};},[]);const[computerRoots,setComputerRoots]=React.useState(null);const[computerTree,setComputerTree]=React.useState({});const[computerPath,setComputerPath]=React.useState(null);const[computerFiles,setComputerFiles]=React.useState([]);const[computerMode,setComputerMode]=React.useState('server');const[clientRoot,setClientRoot]=React.useState(null);const[favorites,setFavorites]=React.useState(function(){try{return JSON.parse(localStorage.getItem('studio_media_explorer_favorites_v1')||'[]');}catch(e){return[];}}());const[favContext,setFavContext]=React.useState(null);const[favoritedExpanded,setFavoritedExpanded]=React.useState(true);const[colWidths,setColWidths]=React.useState({file:260,size:100,type:100});const startColResize=(colKey,e)=>{e.preventDefault();const startX=e.clientX;const startWidth=colWidths[colKey];const onMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const newWidth=Math.max(50,startWidth+deltaX);setColWidths(prev=>({...prev,[colKey]:newWidth}));};const onMouseUp=()=>{document.removeEventListener('mousemove',onMouseMove);document.removeEventListener('mouseup',onMouseUp);};document.addEventListener('mousemove',onMouseMove);document.addEventListener('mouseup',onMouseUp);};const[synthInst,setSynthInst]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_synth');return saved?JSON.parse(saved):null;}());const[synthOpen,setSynthOpen]=React.useState(false);const[synthFilter,setSynthFilter]=React.useState('');const[synthList,setSynthList]=React.useState(null);const[synthLoading,setSynthLoading]=React.useState(false);const synthListRef=React.useRef(null);synthListRef.current=synthList;const synthInstRef=React.useRef(null);synthInstRef.current=synthInst;const[treeWidth,setTreeWidth]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_tree_width');return saved?parseInt(saved):176;}());const treeWidthRef=React.useRef(176);treeWidthRef.current=treeWidth;const startTreeResize=e=>{e.preventDefault();const startX=e.clientX;const startW=treeWidthRef.current;const onMove=ev=>{const newW=Math.max(110,Math.min(420,startW+(ev.clientX-startX)));treeWidthRef.current=newW;setTreeWidth(newW);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);localStorage.setItem('studio_media_explorer_tree_width',treeWidthRef.current.toString());};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};const canvasRef=React.useRef(null);const playStateRef=React.useRef(null);const rafRef=React.useRef(null);const loopTimerRef=React.useRef(null);const selectTokenRef=React.useRef(0);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listMyFiles){window.SonicAPI.listMyFiles([]).then(data=>setUserFiles(data||[])).catch(()=>{});}return()=>{stopMediaPlayback();};// eslint-disable-next-line react-hooks/exhaustive-deps
+setSelStart(value);setSelEnd(value);setIsDragging(true);setPreviewCtxMenu(null);};const handleCanvasMouseMove=e=>{if(!isDragging||!selected)return;const canvas=canvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const clientX=e.clientX-rect.left;const{pxPerSec,pxPerBeat,offset}=getCanvasLayout();const isMidi=isMidiFile(selected);const value=isMidi?(clientX+offset)/pxPerBeat:(clientX+offset)/pxPerSec;setSelEnd(value);};const handleCanvasMouseUp=e=>{if(isDragging){setIsDragging(false);}};const handleCanvasContextMenu=e=>{e.preventDefault();if(!selected||selStart===null||selEnd===null||Math.abs(selStart-selEnd)<0.01)return;setPreviewCtxMenu({x:e.clientX,y:e.clientY});};const handleCopySelection=async()=>{if(!selected||selStart===null||selEnd===null)return;const isMidi=isMidiFile(selected);const startVal=Math.min(selStart,selEnd);const endVal=Math.max(selStart,selEnd);if(isMidi){if(!midiNotes||!midiNotes.length){window.showToast&&window.showToast('Không có dữ liệu MIDI để sao chép','warning');return;}const copiedNotes=midiNotes.filter(n=>n.start_beat>=startVal&&n.start_beat<=endVal).map(n=>({...n,start_beat:n.start_beat-startVal}));if(!copiedNotes.length){window.showToast&&window.showToast('Không có note MIDI nào trong vùng chọn','warning');return;}const selectDurBeats=endVal-startVal;const secondsPerBeat=60/(tempo||120);const selectDurSec=selectDurBeats*secondsPerBeat;const clipObj={type:'midi',notes:copiedNotes,duration:selectDurSec,name:selected.name||'MIDI Selection',color:'#a855f7'};if(clipboardRef)clipboardRef.current=clipObj;window.globalStudioClipboard=clipObj;window.showToast&&window.showToast(`Đã sao chép ${copiedNotes.length} notes MIDI.`,'success');}else{const activeBuf=await getSelectedAudioBuffer();if(!activeBuf){window.showToast&&window.showToast('Không thể tải dữ liệu âm thanh để sao chép','error');return;}const sr=activeBuf.sampleRate;const startSample=Math.floor(startVal*sr);const endSample=Math.floor(endVal*sr);const len=Math.max(1,endSample-startSample);try{const ctx=getAudioContext();const numCh=activeBuf.numberOfChannels||1;const clipBuffer=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch{const handleGlobalClick=()=>setPreviewCtxMenu(null);window.addEventListener('click',handleGlobalClick);return()=>window.removeEventListener('click',handleGlobalClick);},[]);React.useEffect(()=>{const handleKeyDown=e=>{if(e.key===' '||e.code==='Space'){if(window.mediaExplorerActive){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}e.preventDefault();e.stopPropagation();if(isPlayingRef.current){stopMediaPlayback();}else{const cur=selectedRef.current;if(cur){selectTokenRef.current++;playSelected(cur,selectTokenRef.current);}}}}if(e.key==='ArrowUp'||e.key==='ArrowDown'||e.key==='ArrowLeft'||e.key==='ArrowRight'){if(window.mediaExplorerActive&&folderRef.current==='computer'){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}e.preventDefault();e.stopPropagation();// Build the flat list of visible (expanded) tree nodes.
+const paths=[];const walk=(entry,depth)=>{if(!entry||!entry.path)return;paths.push({path:entry.path,entry,depth});const n=computerTreeRef.current[entry.path];if(n&&n.expanded&&n.dirs)(n.dirs||[]).forEach(d=>walk(d,depth+1));};(computerRootsRef.current||[]).forEach(r=>walk(r,0));if(!paths.length)return;const curPath=computerPathRef.current;let idx=paths.findIndex(p=>p.path===curPath);if(e.key==='ArrowUp'){const ni=idx<0?paths.length-1:Math.max(0,idx-1);navigateTreeTo(paths[ni].path);}else if(e.key==='ArrowDown'){const ni=Math.min(paths.length-1,(idx<0?-1:idx)+1);navigateTreeTo(paths[ni].path);}else if(e.key==='ArrowRight'){const node=computerTreeRef.current[curPath];if(node&&!node.expanded){setComputerTree(prev=>({...prev,[curPath]:{...(prev[curPath]||{}),expanded:true}}));if(!node.dirs||!node.dirs.length){if(browseComputerDirRef.current)browseComputerDirRef.current({name:String(curPath).split('/').pop()||curPath,path:curPath,is_dir:true,handle:node.handle});}}else if(idx<0){const p0=paths[0];if(p0)navigateTreeTo(p0.path);}}else if(e.key==='ArrowLeft'){const node=computerTreeRef.current[curPath];if(node&&node.expanded){setComputerTree(prev=>({...prev,[curPath]:{...(prev[curPath]||{}),expanded:false}}));}else if(curPath){const i=curPath.lastIndexOf('/');if(i>0){navigateTreeTo(curPath.substring(0,i));}}}}}if(e.key==='c'&&(e.ctrlKey||e.metaKey)){if(window.mediaExplorerActive){const target=e.target;if(target&&(target.tagName==='INPUT'||target.tagName==='TEXTAREA'||target.isContentEditable)){return;}if(selStartRef.current!==null&&selEndRef.current!==null&&Math.abs(selStartRef.current-selEndRef.current)>0.01){e.preventDefault();e.stopPropagation();handleCopySelection();}}}};window.addEventListener('keydown',handleKeyDown,{capture:true});return()=>{window.removeEventListener('keydown',handleKeyDown,{capture:true});};},[]);const[computerRoots,setComputerRoots]=React.useState(null);const[computerTree,setComputerTree]=React.useState({});const[computerPath,setComputerPath]=React.useState(null);const[computerFiles,setComputerFiles]=React.useState([]);const[computerMode,setComputerMode]=React.useState('server');const[clientRoot,setClientRoot]=React.useState(null);computerPathRef.current=computerPath;computerTreeRef.current=computerTree;computerRootsRef.current=computerRoots;const[favorites,setFavorites]=React.useState(function(){try{return JSON.parse(localStorage.getItem('studio_media_explorer_favorites_v1')||'[]');}catch(e){return[];}}());const[favContext,setFavContext]=React.useState(null);const[favoritedExpanded,setFavoritedExpanded]=React.useState(true);const[colWidths,setColWidths]=React.useState({file:260,size:100,type:100});const startColResize=(colKey,e)=>{e.preventDefault();const startX=e.clientX;const startWidth=colWidths[colKey];const onMouseMove=moveEvent=>{const deltaX=moveEvent.clientX-startX;const newWidth=Math.max(50,startWidth+deltaX);setColWidths(prev=>({...prev,[colKey]:newWidth}));};const onMouseUp=()=>{document.removeEventListener('mousemove',onMouseMove);document.removeEventListener('mouseup',onMouseUp);};document.addEventListener('mousemove',onMouseMove);document.addEventListener('mouseup',onMouseUp);};const[synthInst,setSynthInst]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_synth');return saved?JSON.parse(saved):null;}());const[synthOpen,setSynthOpen]=React.useState(false);const[synthFilter,setSynthFilter]=React.useState('');const[synthList,setSynthList]=React.useState(null);const[synthLoading,setSynthLoading]=React.useState(false);const synthListRef=React.useRef(null);synthListRef.current=synthList;const synthInstRef=React.useRef(null);synthInstRef.current=synthInst;const[treeWidth,setTreeWidth]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_tree_width');return saved?parseInt(saved):176;}());const treeWidthRef=React.useRef(176);treeWidthRef.current=treeWidth;const startTreeResize=e=>{e.preventDefault();const startX=e.clientX;const startW=treeWidthRef.current;const onMove=ev=>{const newW=Math.max(110,Math.min(420,startW+(ev.clientX-startX)));treeWidthRef.current=newW;setTreeWidth(newW);};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);localStorage.setItem('studio_media_explorer_tree_width',treeWidthRef.current.toString());};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};const canvasRef=React.useRef(null);const playStateRef=React.useRef(null);const rafRef=React.useRef(null);const loopTimerRef=React.useRef(null);const selectTokenRef=React.useRef(0);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listMyFiles){window.SonicAPI.listMyFiles([]).then(data=>setUserFiles(data||[])).catch(()=>{});}return()=>{stopMediaPlayback();};// eslint-disable-next-line react-hooks/exhaustive-deps
},[]);// ── Session persistence: keep loaded folder/files/tree across panel toggles & reloads ──
const SESSION_KEY='studio_media_explorer_session_v1';const saveClientRootHandle=(key='client_root',handle=clientRoot)=>{if(!handle||!window.indexedDB)return;try{const req=indexedDB.open('sonicforge_media_explorer',1);req.onupgradeneeded=e=>{const db=e.target.result;if(!db.objectStoreNames.contains('root_handle'))db.createObjectStore('root_handle');};req.onsuccess=()=>{const db=req.result;const tx=db.transaction('root_handle','readwrite');tx.objectStore('root_handle').put(handle,key);};}catch(e){}};const loadClientRootHandle=(key='client_root')=>{return new Promise(resolve=>{if(!window.indexedDB){resolve(null);return;}try{const req=indexedDB.open('sonicforge_media_explorer',1);req.onupgradeneeded=e=>{const db=e.target.result;if(!db.objectStoreNames.contains('root_handle'))db.createObjectStore('root_handle');};req.onsuccess=()=>{const db=req.result;try{const tx=db.transaction('root_handle','readonly');const g=tx.objectStore('root_handle').get(key);g.onsuccess=()=>resolve(g.result||null);g.onerror=()=>resolve(null);}catch(e2){resolve(null);}};req.onerror=()=>resolve(null);}catch(e){resolve(null);}});};const saveSession=React.useCallback(()=>{try{const stripHandle=o=>{if(Array.isArray(o))return o.map(stripHandle);if(o&&typeof o==='object'){const out={};for(const k of Object.keys(o)){if(k==='handle')continue;out[k]=stripHandle(o[k]);}return out;}return o;};const treeSnapshot={};Object.keys(computerTree).forEach(path=>{const node=computerTree[path];treeSnapshot[path]={dirs:stripHandle(node?node.dirs:[]),expanded:!!(node&&node.expanded)};});const snap={folder,computerMode,computerPath,clientRootName:clientRoot?clientRoot.name:null,computerRoots:stripHandle(computerRoots||[]),computerTree:treeSnapshot,computerFiles:stripHandle(computerFiles||[]),selected:selected?stripHandle({name:selected.name,path:selected.path,kind:selected.kind,is_dir:selected.is_dir,size_mb:selected.size_mb}):null,savedAt:Date.now()};localStorage.setItem(SESSION_KEY,JSON.stringify(snap));}catch(e){}},[folder,computerMode,computerPath,clientRoot,computerRoots,computerTree,computerFiles,selected]);React.useEffect(()=>{saveSession();if(computerMode==='client'&&clientRoot&&clientRoot.kind==='directory')saveClientRootHandle();},[saveSession,computerMode,clientRoot]);React.useEffect(()=>{(async()=>{let lastFolder='library';let lastComputerPath='favorited';try{const raw=localStorage.getItem(SESSION_KEY);if(raw){const snap=JSON.parse(raw);if(snap.folder)lastFolder=snap.folder;if(snap.computerPath)lastComputerPath=snap.computerPath;setFolder(lastFolder);}}catch(e){}// 1. Khôi phục client-side root handle từ IndexedDB
const savedHandle=await loadClientRootHandle();let restoredClientRoot=null;if(savedHandle&&savedHandle.kind==='directory'){setClientRoot(savedHandle);restoredClientRoot=savedHandle;setComputerMode('client');const rootEntry={name:savedHandle.name,path:'root',is_dir:true,handle:savedHandle};setComputerRoots([rootEntry]);// Quét đúng 1 cấp con dưới root của client
@@ -295,7 +298,7 @@ const favKey='client:'+rootHandle.name;saveClientRootHandle('client_root',rootHa
setFavorites(prev=>{const exists=prev.some(f=>f.path===favKey);if(exists)return prev;const next=[...prev,{path:favKey,name:rootHandle.name,is_dir:true}];try{localStorage.setItem('studio_media_explorer_favorites_v1',JSON.stringify(next));}catch(e2){}return next;});window.showToast&&window.showToast('Đã thêm thư mục client vào Favorited: '+rootHandle.name,'info');await browseComputerDir(rootEntry);return true;};// Luôn bắt buộc hiện Window Picker chọn thư mục của client-side khi click vào My Computer
if(window.showDirectoryPicker){try{const picked=await window.showDirectoryPicker({mode:'read'});if(picked&&picked.kind==='directory'){return useClientRoot(picked);}}catch(e){// User cancelled or error
}}// Fallback sang Server-side chỉ khi browser không hỗ trợ
-setComputerMode('server');setComputerRoots(null);try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/computer`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();const roots=data.roots||[];setComputerRoots(roots.length?roots:[{path:'/',name:'Root (/)',is_dir:true}]);if(!roots.length)window.showToast&&window.showToast('Không tìm thấy ổ đĩa nào','warning');const rootList=roots.length?roots:[{path:'/',name:'Root (/)',is_dir:true}];rootList.slice(0,10).forEach(async root=>{await browseComputerDir(root);});}catch(e){setComputerRoots([{path:'/',name:'Root (/)',is_dir:true}]);window.showToast&&window.showToast('Không thể truy cập My Computer: '+e.message,'error');}};const browseClientDir=async entry=>{const handle=entry&&entry.handle;if(!handle||!handle.entries)return null;const dirs=[];const files=[];const parentPath=entry.path;try{for await(const[name,h]of handle.entries()){if(name.startsWith('.'))continue;if(h.kind==='directory'){dirs.push({name,path:parentPath+'/'+name,is_dir:true,handle:h,parent:handle});}else{const ext=(name.split('.').pop()||'').toLowerCase();const kind=ext==='mid'||ext==='midi'?'midi':['wav','mp3','ogg','flac','aiff','aif','m4a','aac','opus'].includes(ext)?'audio':'other';let size=0;try{const f=await h.getFile();size=f.size;}catch(e2){}files.push({name,path:parentPath+'/'+name,is_dir:false,size_mb:size?+(size/1048576).toFixed(2):0,ext:'.'+ext,kind,handle:h});}}dirs.sort((a,b)=>a.name.localeCompare(b.name));files.sort((a,b)=>a.name.localeCompare(b.name));setComputerPath(parentPath);setComputerFiles(files);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[parentPath]:{handle,parent:entry.parent||null,dirs,expanded:true}}));return{dirs,files};}catch(e){window.showToast&&window.showToast('Không thể đọc thư mục: '+e.message,'error');return null;}};const browseComputerDir=async entry=>{if(!entry)return null;if(computerMode==='client'||entry.handle){if(entry.handle&&entry.handle.entries){const parentInfo=computerTree[entry.path];return await browseClientDir({...entry,parent:parentInfo?parentInfo.parent:entry.parent});}}const path=entry.path||entry;if(!path)return null;try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();setComputerPath(data.path);setComputerFiles(data.files||[]);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[path]:{dirs:data.dirs||[],expanded:true}}));return{dirs:data.dirs||[],files:data.files||[]};}catch(e){window.showToast&&window.showToast('Không thể mở thư mục: '+e.message,'error');return null;}};const toggleComputerDir=async entry=>{if(!entry)return;const path=entry.path||entry;const node=computerTree[path];if(node&&node.expanded){setComputerTree(prev=>({...prev,[path]:{...prev[path],expanded:false}}));}else{await browseComputerDir(entry);}};// ── Favorites: thư mục yêu thích ──
+setComputerMode('server');setComputerRoots(null);try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/computer`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();const roots=data.roots||[];setComputerRoots(roots.length?roots:[{path:'/',name:'Root (/)',is_dir:true}]);if(!roots.length)window.showToast&&window.showToast('Không tìm thấy ổ đĩa nào','warning');const rootList=roots.length?roots:[{path:'/',name:'Root (/)',is_dir:true}];rootList.slice(0,10).forEach(async root=>{await browseComputerDir(root);});}catch(e){setComputerRoots([{path:'/',name:'Root (/)',is_dir:true}]);window.showToast&&window.showToast('Không thể truy cập My Computer: '+e.message,'error');}};const browseClientDir=async entry=>{const handle=entry&&entry.handle;if(!handle||!handle.entries)return null;const dirs=[];const files=[];const parentPath=entry.path;try{for await(const[name,h]of handle.entries()){if(name.startsWith('.'))continue;if(h.kind==='directory'){dirs.push({name,path:parentPath+'/'+name,is_dir:true,handle:h,parent:handle});}else{const ext=(name.split('.').pop()||'').toLowerCase();const kind=ext==='mid'||ext==='midi'?'midi':['wav','mp3','ogg','flac','aiff','aif','m4a','aac','opus'].includes(ext)?'audio':'other';let size=0;try{const f=await h.getFile();size=f.size;}catch(e2){}files.push({name,path:parentPath+'/'+name,is_dir:false,size_mb:size?+(size/1048576).toFixed(2):0,ext:'.'+ext,kind,handle:h});}}dirs.sort((a,b)=>a.name.localeCompare(b.name));files.sort((a,b)=>a.name.localeCompare(b.name));setComputerPath(parentPath);setComputerFiles(files);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[parentPath]:{handle,parent:entry.parent||null,dirs,expanded:true}}));return{dirs,files};}catch(e){window.showToast&&window.showToast('Không thể đọc thư mục: '+e.message,'error');return null;}};const browseComputerDir=async entry=>{if(!entry)return null;if(computerMode==='client'||entry.handle){if(entry.handle&&entry.handle.entries){const parentInfo=computerTree[entry.path];return await browseClientDir({...entry,parent:parentInfo?parentInfo.parent:entry.parent});}}const path=entry.path||entry;if(!path)return null;try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();setComputerPath(data.path);setComputerFiles(data.files||[]);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[path]:{dirs:data.dirs||[],expanded:true}}));return{dirs:data.dirs||[],files:data.files||[]};}catch(e){window.showToast&&window.showToast('Không thể mở thư mục: '+e.message,'error');return null;}};browseComputerDirRef.current=browseComputerDir;const toggleComputerDir=async entry=>{if(!entry)return;const path=entry.path||entry;const node=computerTree[path];if(node&&node.expanded){setComputerTree(prev=>({...prev,[path]:{...prev[path],expanded:false}}));}else{await browseComputerDir(entry);}};// ── Favorites: thư mục yêu thích ──
const isFavorite=entry=>{if(!entry)return false;return(favorites||[]).some(f=>f.path===(entry.path||entry));};const toggleFavorite=(entry,e)=>{if(e&&e.stopPropagation)e.stopPropagation();if(!entry)return;const path=entry.path||entry;const name=entry.name||path.split('/').pop()||path;setFavorites(prev=>{const exists=prev.some(f=>f.path===path);const next=exists?prev.filter(f=>f.path!==path):[...prev,{path,name,is_dir:true}];try{localStorage.setItem('studio_media_explorer_favorites_v1',JSON.stringify(next));}catch(e2){}return next;});window.showToast&&window.showToast('Đã '+(isFavorite(entry)?'gỡ khỏi':'thêm vào')+' Favorited: '+name,'info');};const openFavorite=fav=>{if(!fav)return;setFolder('computer');const entry={path:fav.path,name:fav.name,is_dir:true};// Cố gắng tìm handle trong cây đã load để mở trực tiếp
const node=computerTree[fav.path];if(node&&node.handle){setComputerTree({});// Reset cache
browseComputerDir({...entry,handle:node.handle});}else if(fav.path&&fav.path.startsWith('client:')){(async()=>{let favHandle=null;try{favHandle=await loadClientRootHandle(fav.path);}catch(e){}if(favHandle){// Yêu cầu quyền đọc (permission có thể bị thu hồi)
@@ -305,7 +308,9 @@ const segments=fav.path.split('/').slice(1);let curHandle=clientRoot;const walk=
browseComputerDir(parts.length?parts.join('\\'):computerPath.split(/[\\/]/)[0]+'\\');}};const readLocalFileBuffer=async f=>{if(f&&f.handle&&typeof f.handle.getFile==='function'){const file=await f.handle.getFile();return await file.arrayBuffer();}const url=filePreviewUrl(f);if(!url)return null;const resp=await fetch(url);return await resp.arrayBuffer();};const filePreviewUrl=f=>{if(f&&f.path&&!(f.handle&&f.handle.getFile))return`${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(f.path)}`;const fid=f&&(f.file_id||f.fileId);return fid?`${API_BASE_URL}/api/v1/audio/download/${fid}`:null;};const toggleSynthDropdown=()=>{if(synthOpen){setSynthOpen(false);return;}setSynthFilter('');// Clear search filter on open
setSynthOpen(true);if(synthListRef.current)return;setSynthLoading(true);(window.SonicAPI&&window.SonicAPI.listPlugins?window.SonicAPI.listPlugins():Promise.resolve({soundfonts:[]})).then(async data=>{const sfonts=data&&data.soundfonts||[];if(!sfonts.length){setSynthList([]);setSynthLoading(false);return;}const results=await Promise.all(sfonts.map(sf=>{const baseId=String(sf.id||'').replace('sf_','');return(window.SonicAPI.listSoundfontInstruments?window.SonicAPI.listSoundfontInstruments(baseId):Promise.resolve({presets:[]})).then(r=>({sf,presets:r&&r.presets||[]})).catch(()=>({sf,presets:[]}));}));setSynthList(results);setSynthLoading(false);}).catch(()=>{setSynthList([]);setSynthLoading(false);});};const selectSynthInst=inst=>{setSynthInst(inst);synthInstRef.current=inst;setSynthOpen(false);localStorage.setItem('studio_media_explorer_synth',JSON.stringify(inst));// Realtime: re-schedule current MIDI preview with the newly selected instrument
const cur=selectedRef.current;if(isPlayingRef.current&&cur&&isMidiFile(cur)&&(cur.handle||cur.path||cur.file_id||cur.fileId)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}playMidiPreview(cur,token);}};const filteredSynthList=React.useMemo(()=>{if(!synthList)return[];if(!synthFilter.trim())return synthList;const query=synthFilter.toLowerCase().trim();return synthList.map(group=>{const presets=(group.presets||[]).filter(p=>(p.name||'').toLowerCase().includes(query)||String(p.program).includes(query));return{...group,presets};}).filter(group=>group.presets.length>0);},[synthList,synthFilter]);const playMidiPreview=async(f,token)=>{// Play real MIDI file through selected synth instrument (SonicSF)
-if(!f||!window.SonicSF)return;try{const buf=await readLocalFileBuffer(f);if(!buf)return;if(selectTokenRef.current!==(token||selectTokenRef.current))return;const midiResult=(typeof parseMidiFile==='function'?parseMidiFile:window.parseMidiFile)(buf);if(!midiResult||!midiResult.length)return;const ctx=getAudioContext();const bpmVal=tempoRef.current||120;const secondsPerBeat=60.0/bpmVal;const startWallTime=ctx.currentTime+0.05;const curInst=synthInstRef.current;const program=curInst?curInst.program:undefined;const sfId=curInst?curInst.sfId:undefined;const bank=curInst?curInst.bank:0;if(curInst&&window.SonicSF.selectInstrument){try{await window.SonicSF.selectInstrument(0,bank,program,sfId);}catch(e2){}}// Re-check token after the async await — stale playMidiPreview (older file)
+if(!f||!window.SonicSF)return;try{const buf=await readLocalFileBuffer(f);if(!buf)return;if(selectTokenRef.current!==(token||selectTokenRef.current))return;const midiResult=(typeof parseMidiFile==='function'?parseMidiFile:window.parseMidiFile)(buf);if(!midiResult||!midiResult.length)return;// Feature: selecting/playing a MIDI file auto-sets the playback tempo to
+// the file's own BPM (so the preview plays in time).
+const fileBpm=midiResult[0]&&midiResult[0].bpm||120;const newTempo=Math.max(40,Math.min(300,Math.round(fileBpm)));setTempo(newTempo);setTempoText(String(newTempo));tempoRef.current=newTempo;try{localStorage.setItem('studio_media_explorer_tempo',String(newTempo));}catch(e){}const ctx=getAudioContext();const bpmVal=tempoRef.current||120;const secondsPerBeat=60.0/bpmVal;const startWallTime=ctx.currentTime+0.05;const curInst=synthInstRef.current;const program=curInst?curInst.program:undefined;const sfId=curInst?curInst.sfId:undefined;const bank=curInst?curInst.bank:0;if(curInst&&window.SonicSF.selectInstrument){try{await window.SonicSF.selectInstrument(0,bank,program,sfId);}catch(e2){}}// Re-check token after the async await — stale playMidiPreview (older file)
// must not schedule notes over the newly selected file.
if(selectTokenRef.current!==(token||selectTokenRef.current))return;const prog=curInst&&curInst.program!==undefined?curInst.program:undefined;const eng=curInst?{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:prog!==undefined?prog:0}:undefined;const totalSec=midiResult[0].duration||4;const totalBeats=midiResult[0].totalBeats||16;const hasSelection=selStart!==null&&selEnd!==null&&Math.abs(selStart-selEnd)>0.01;const loopStartBeats=hasSelection?Math.min(selStart,selEnd):0;const loopEndBeats=hasSelection?Math.max(selStart,selEnd):totalBeats;const loopDurationBeats=loopEndBeats-loopStartBeats;const loopDurationSec=loopDurationBeats*secondsPerBeat;const loopStartSec=loopStartBeats*secondsPerBeat;const allNotes=[];midiResult.forEach(track=>{(track.notes||[]).forEach(note=>{allNotes.push(Object.assign({},note,{trackOffset:track.startTime||0}));});});const schedulePass=passStartTime=>{allNotes.forEach(note=>{const noteStartBeat=note.start_beat||0;if(hasSelection){if(noteStartBeat=loopEndBeats)return;}const shiftedStartBeat=hasSelection?noteStartBeat-loopStartBeats:noteStartBeat;const startSec=shiftedStartBeat*secondsPerBeat+(note.trackOffset||0);const durMs=Math.max(80,(note.duration_beats||1)*secondsPerBeat*1000);window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,durMs,passStartTime+startSec,prog,null,0,eng);});};schedulePass(startWallTime);// Loop scheduling: keep looping continuously until Stop is pressed.
if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}if(isLoopingRef.current){loopTimerRef.current=setInterval(()=>{if(selectTokenRef.current!==(token||selectTokenRef.current)){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}if(!isPlayingRef.current||isPausedRef.current)return;if(!isLoopingRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}const passStart=ctx.currentTime+0.05;schedulePass(passStart);playStateRef.current=Object.assign({},playStateRef.current,{startedAt:passStart,fakeStart:passStart});},Math.max(200,loopDurationSec*1000));}// Keep a fake clock so canvas playhead animates; loop uses playStateRef
@@ -317,15 +322,22 @@ const pxPerBeat=42*zoom;const pxPerSec=pxPerBeat*(curTempo||120)/60;if(isMidiFil
for(let b=0;b<=beats;b+=4){const x=b*pxPerBeat-offset;if(x<-10||x>w+10)continue;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,h-14);ctx.stroke();}ctx.fillStyle='#9ca3af';if(curMidiNotes&&curMidiNotes.length){const pitchMin=48,pitchMax=84;const pitchRange=Math.max(1,pitchMax-pitchMin);curMidiNotes.forEach(n=>{const x=n.start_beat*pxPerBeat-offset;if(x<-30||x>w+30)return;const nw=Math.max(3,(n.duration_beats||1)*pxPerBeat);const y=h-14-8-(Math.min(pitchMax,Math.max(pitchMin,n.pitch))-pitchMin)/pitchRange*(h-30);ctx.fillRect(x,y,nw,5);});}else{const events=f.events||76;for(let i=0;iw?Math.max(0,Math.min(w,t*pxPerSec-offset)):Math.min(w,t*pxPerSec);if(playing&&dur>0){ctx.fillStyle='#ef4444';ctx.fillRect(midiPlayheadX-1,0,2,h-14);}}else{const pk=curPeaks&&curPeaks.length>0?curPeaks:null;if(pk){const contentW=Math.max(1,dur*pxPerSec);const offsetVal=scrollOffsetRef.current;let offset=offsetVal;if(playing&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,t*pxPerSec-w/2));}else{offset=Math.max(0,Math.min(contentW-w,offsetVal));}const mid=h/2;ctx.fillStyle='#22c55e';const barW=Math.max(1,contentW/pk.length);for(let i=0;iw+3)continue;const ph=Math.max(2,pk[i]*(h/2-4));ctx.fillRect(x,mid-ph,barW,ph*2);}// Draw selection overlay
if(drawHasSel){const startVal=Math.min(drawSelStart,drawSelEnd);const endVal=Math.max(drawSelStart,drawSelEnd);const xStart=startVal*pxPerSec-offset;const xEnd=endVal*pxPerSec-offset;ctx.fillStyle='rgba(59, 130, 246, 0.25)';ctx.fillRect(xStart,0,xEnd-xStart,h-14);ctx.strokeStyle='#3b82f6';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(xStart,0);ctx.lineTo(xStart,h-14);ctx.moveTo(xEnd,0);ctx.lineTo(xEnd,h-14);ctx.stroke();}const audioPlayheadX=contentW>w?Math.max(0,Math.min(w,t*pxPerSec-offset)):t*pxPerSec;if(playing&&dur>0){ctx.fillStyle='#ef4444';ctx.fillRect(audioPlayheadX-1,0,2,h-14);}}else{ctx.fillStyle='#666';ctx.font='11px monospace';ctx.fillText('Waveform unavailable',10,h/2);}}// ruler
-ctx.fillStyle='#111';ctx.fillRect(0,h-14,w,14);ctx.fillStyle='#888';ctx.font='9px JetBrains Mono, monospace';const isRealMidi=isMidiFile(f)&&curMidiNotes&&curMidiNotes.length&&curMidiTotalBeats>0;const rulerTotalBeats=isRealMidi?Math.max(curMidiTotalBeats,4):Math.max(4,Math.ceil(dur*(curTempo||120)/60)||16);const rulerContentW=Math.max(1,rulerTotalBeats*pxPerBeat);const rulerOffset=playing&&rulerContentW>w&&dur>0?Math.max(0,Math.min(rulerContentW-w,t*pxPerSec-w/2)):0;for(let b=0;b<=rulerTotalBeats;b+=4){const x=b*pxPerBeat-rulerOffset;if(x<-20||x>w+20)continue;ctx.fillText(String(Math.floor(b/4)),x+2,h-3);}};React.useEffect(()=>{drawCanvas(currentTime);},[peaks,audioBuffer,audioDuration,midiNotes,selected,folder,isPlaying,zoom,selStart,selEnd,scrollOffset]);React.useEffect(()=>()=>{if(rafRef.current)cancelAnimationFrame(rafRef.current);},[]);const handleSelect=f=>{if(!f||f.is_dir)return;// Find parent path of selected file and scroll it into view in Tree pane
+ctx.fillStyle='#111';ctx.fillRect(0,h-14,w,14);ctx.fillStyle='#888';ctx.font='9px JetBrains Mono, monospace';const isRealMidi=isMidiFile(f)&&curMidiNotes&&curMidiNotes.length&&curMidiTotalBeats>0;const rulerTotalBeats=isRealMidi?Math.max(curMidiTotalBeats,4):Math.max(4,Math.ceil(dur*(curTempo||120)/60)||16);const rulerContentW=Math.max(1,rulerTotalBeats*pxPerBeat);const rulerOffset=playing&&rulerContentW>w&&dur>0?Math.max(0,Math.min(rulerContentW-w,t*pxPerSec-w/2)):0;for(let b=0;b<=rulerTotalBeats;b+=4){const x=b*pxPerBeat-rulerOffset;if(x<-20||x>w+20)continue;ctx.fillText(String(Math.floor(b/4)),x+2,h-3);}};React.useEffect(()=>{drawCanvas(currentTime);},[peaks,audioBuffer,audioDuration,midiNotes,selected,folder,isPlaying,zoom,selStart,selEnd,scrollOffset]);React.useEffect(()=>()=>{if(rafRef.current)cancelAnimationFrame(rafRef.current);},[]);const commitTempo=v=>{const clamped=Math.max(40,Math.min(300,Math.round(v)||120));setTempo(clamped);setTempoText(String(clamped));tempoRef.current=clamped;try{localStorage.setItem('studio_media_explorer_tempo',String(clamped));}catch(e){}// Re-schedule a currently playing MIDI preview at the new tempo.
+const cur=selectedRef.current;if(isPlayingRef.current&&cur&&isMidiFile(cur)&&(cur.handle||cur.path||cur.file_id||cur.fileId)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}playMidiPreview(cur,token);}};// Center a folder node vertically in the tree pane (feature: file click focuses
+// its parent folder in the middle of the tree).
+const centerTreeNodeInPane=path=>{setTimeout(()=>{const pane=treePaneRef.current;if(!pane)return;const el=document.querySelector(`[data-tree-path="${path}"]`);if(!el)return;const paneRect=pane.getBoundingClientRect();const elRect=el.getBoundingClientRect();const target=pane.scrollTop+(elRect.top-paneRect.top)-paneRect.height/2+elRect.height/2;pane.scrollTo({top:Math.max(0,target),behavior:'smooth'});},80);};// Flat list of currently visible (expanded) tree nodes, in render order.
+const getVisibleTreePaths=()=>{const out=[];const walk=(entry,depth)=>{if(!entry||!entry.path)return;out.push({path:entry.path,entry,depth});const node=computerTreeRef.current[entry.path];if(node&&node.expanded&&node.dirs){(node.dirs||[]).forEach(d=>walk(d,depth+1));}};(computerRootsRef.current||[]).forEach(r=>walk(r,0));return out;};const navigateTreeTo=path=>{const paths=getVisibleTreePaths();const target=paths.find(p=>p.path===path);if(!target)return;// Expand all ancestor nodes so the target is visible in the tree.
+setComputerTree(prev=>{const next={...prev};let current=path;while(current){const n=next[current];if(n)next[current]={...n,expanded:true};const idx=current.lastIndexOf('/');if(idx<=0)break;current=current.substring(0,idx);}return next;});setComputerPath(path);if(browseComputerDirRef.current)browseComputerDirRef.current(target.entry);centerTreeNodeInPane(path);};const handleSelect=f=>{if(!f||f.is_dir)return;// When a MIDI file is clicked, automatically set the play tempo to the
+// MIDI file's tempo (BPM from metadata, or after parsing in playMidiPreview).
+if(isMidiFile(f)&&f.bpm){const v=Math.max(40,Math.min(300,Math.round(parseFloat(f.bpm)||120)));setTempo(v);setTempoText(String(v));tempoRef.current=v;try{localStorage.setItem('studio_media_explorer_tempo',String(v));}catch(e){}}// Find parent path of selected file and scroll it into view in Tree pane
if(f.path){const lastSlash=f.path.lastIndexOf('/');if(lastSlash>0){const parentPath=f.path.substring(0,lastSlash);setComputerPath(parentPath);// Expand all parent nodes in computerTree
-setComputerTree(prev=>{const next={...prev};let current=parentPath;while(current){if(!next[current]){next[current]={dirs:[],expanded:true};}else{next[current]={...next[current],expanded:true};}const idx=current.lastIndexOf('/');if(idx<=0)break;current=current.substring(0,idx);}return next;});// Scroll the parent tree element into view
-setTimeout(()=>{const treeNodeEl=document.querySelector(`[data-tree-path="${parentPath}"]`);if(treeNodeEl){treeNodeEl.scrollIntoView({behavior:'smooth',block:'nearest'});}},100);}}selectTokenRef.current++;const token=selectTokenRef.current;setSelected(f);setCurrentTime(0);setPeaks(null);setAudioBuffer(null);setAudioDuration(0);setMidiNotes(null);setSelStart(null);setSelEnd(null);setPreviewCtxMenu(null);stopMediaPlayback();if(f.kind==='other')return;if(autoPlay){playSelected(f,token);}if(!isMidiFile(f)&&(f.path||f.file_id||f.fileId))loadWaveform(f);};const renderComputerNode=(entry,depth,isRoot)=>{const nodePath=entry.path;const node=computerTree[nodePath];const expanded=node&&node.expanded;const dirs=node?node.dirs:[];const pad=12+depth*12;const fav=isFavorite(entry);return/*#__PURE__*/React.createElement(React.Fragment,{key:nodePath},/*#__PURE__*/React.createElement("div",{"data-tree-path":nodePath,className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath===nodePath?'bg-slate-300 text-slate-900':'hover:bg-slate-200 text-slate-800'}`,style:{paddingLeft:pad},onClick:()=>browseComputerDir(entry),onDoubleClick:e=>{e.stopPropagation();toggleComputerDir(entry);},onContextMenu:e=>{e.preventDefault();e.stopPropagation();setFavContext(Object.assign({},entry,{x:e.clientX,y:e.clientY}));}},/*#__PURE__*/React.createElement("i",{className:`fa-solid ${expanded?'fa-minus':'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`,onClick:e=>{e.stopPropagation();toggleComputerDir(entry);}}),/*#__PURE__*/React.createElement("i",{className:`fa-solid ${isRoot?'fa-hard-drive text-[#6ea8dc]':'fa-folder text-[#d9a752]'} shrink-0`}),/*#__PURE__*/React.createElement("span",{className:"truncate flex-1"},entry.name),fav&&/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-star text-amber-500 text-[9px] shrink-0"})),expanded&&dirs.map(d=>renderComputerNode(d,depth+1,false)));};const toggleLoop=()=>{setIsLooping(prev=>{const next=!prev;// Sync ref immediately so playMidiPreview (called below) sees the new value
+setComputerTree(prev=>{const next={...prev};let current=parentPath;while(current){if(!next[current]){next[current]={dirs:[],expanded:true};}else{next[current]={...next[current],expanded:true};}const idx=current.lastIndexOf('/');if(idx<=0)break;current=current.substring(0,idx);}return next;});// Center the parent folder node in the middle of the tree pane
+centerTreeNodeInPane(parentPath);}}selectTokenRef.current++;const token=selectTokenRef.current;setSelected(f);setCurrentTime(0);setPeaks(null);setAudioBuffer(null);setAudioDuration(0);setMidiNotes(null);setSelStart(null);setSelEnd(null);setPreviewCtxMenu(null);stopMediaPlayback();if(f.kind==='other')return;if(autoPlay){playSelected(f,token);}if(!isMidiFile(f)&&(f.path||f.file_id||f.fileId))loadWaveform(f);};const renderComputerNode=(entry,depth,isRoot)=>{const nodePath=entry.path;const node=computerTree[nodePath];const expanded=node&&node.expanded;const dirs=node?node.dirs:[];const pad=12+depth*12;const fav=isFavorite(entry);return/*#__PURE__*/React.createElement(React.Fragment,{key:nodePath},/*#__PURE__*/React.createElement("div",{"data-tree-path":nodePath,className:`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${computerPath===nodePath?'bg-slate-300 text-slate-900':'hover:bg-slate-200 text-slate-800'}`,style:{paddingLeft:pad},onClick:()=>browseComputerDir(entry),onDoubleClick:e=>{e.stopPropagation();toggleComputerDir(entry);},onContextMenu:e=>{e.preventDefault();e.stopPropagation();setFavContext(Object.assign({},entry,{x:e.clientX,y:e.clientY}));}},/*#__PURE__*/React.createElement("i",{className:`fa-solid ${expanded?'fa-minus':'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`,onClick:e=>{e.stopPropagation();toggleComputerDir(entry);}}),/*#__PURE__*/React.createElement("i",{className:`fa-solid ${isRoot?'fa-hard-drive text-[#6ea8dc]':'fa-folder text-[#d9a752]'} shrink-0`}),/*#__PURE__*/React.createElement("span",{className:"truncate flex-1"},entry.name),fav&&/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-star text-amber-500 text-[9px] shrink-0"})),expanded&&dirs.map(d=>renderComputerNode(d,depth+1,false)));};const toggleLoop=()=>{setIsLooping(prev=>{const next=!prev;// Sync ref immediately so playMidiPreview (called below) sees the new value
isLoopingRef.current=next;const cur=selectedRef.current;const st=playStateRef.current;const sStart=selStartRef.current;const sEnd=selEndRef.current;const hasSelection=sStart!==null&&sEnd!==null&&Math.abs(sStart-sEnd)>0.01;if(st&&st.source){st.source.loop=next;// When enabling loop for a currently playing audio buffer, also update
// the loop points to the current selection so it loops continuously
// over the selected region until Stop is pressed.
if(next&&st.source.buffer){if(hasSelection){st.source.loopStart=Math.min(sStart,sEnd);st.source.loopEnd=Math.max(sStart,sEnd);}else{st.source.loopStart=0;st.source.loopEnd=st.source.buffer.duration;}}}if(next&&isMidiFile(cur)){// Re-schedule loop for the currently previewing MIDI file
-if(cur&&(cur.handle||cur.path||cur.file_id||cur.fileId)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}playMidiPreview(cur,token);}}return next;});};const toggleRate=dir=>setRate(prev=>Math.max(0.25,Math.min(4.0,Math.round((prev+dir*0.1)*100)/100)));const togglePitch=dir=>setPitch(prev=>Math.max(-24,Math.min(24,prev+dir*0.5)));const selIsMidi=isMidiFile(selected);const selDur=fileDuration(selected);const selBpm=selected&&selected.bpm||tempo;return/*#__PURE__*/React.createElement("div",{ref:containerRef,className:"flex flex-col w-full h-full text-slate-900 overflow-hidden select-none",style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("div",{className:"h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 flex-1 mr-2"},/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Back",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-left"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Forward",onClick:()=>setFolder('uploads')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-right"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Up Directory",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-up"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Refresh",onClick:()=>{if(folder==='computer'){const node=computerTree[computerPath];if(node&&node.handle)browseComputerDir({name:computerPath.split('/').pop()||computerPath,path:computerPath,is_dir:true,handle:node.handle});else if(computerPath)browseComputerDir({name:computerPath.split(/[\\/]/).pop()||computerPath,path:computerPath,is_dir:true});}else if(window.SonicAPI&&window.SonicAPI.listMyFiles)window.SonicAPI.listMyFiles([]).then(d=>setUserFiles(d||[]));}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-rotate-right"})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center bg-white border border-[#808080] h-5 px-1"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"}),/*#__PURE__*/React.createElement("span",{className:"flex-1 text-xs text-slate-800 truncate"},folder==='computer'?computerPath||'My Computer':folder==='library'?'Media Library':folder==='uploads'?'Uploads':'Processed'),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center bg-white border border-[#808080] h-5 px-1 w-40"},/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Filter/Search...",value:filterText,onChange:e=>setFilterText(e.target.value),className:"w-full text-xs outline-none bg-transparent font-sans text-slate-800"}),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"})),/*#__PURE__*/React.createElement("button",{className:"px-2 py-0.5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold",onClick:()=>setViewMode(viewMode==='details'?'list':'details')},/*#__PURE__*/React.createElement("span",null,viewMode==='details'?'Details':'List')," ",/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-[9px]"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 relative"},/*#__PURE__*/React.createElement("div",{className:"flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden"},/*#__PURE__*/React.createElement("div",{className:"overflow-y-auto p-1",style:{width:treeWidth+'px',minWidth:treeWidth+'px',maxWidth:treeWidth+'px'}},/*#__PURE__*/React.createElement("div",{className:"space-y-0.5 font-sans"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 px-1 py-0.5 text-slate-700"},/*#__PURE__*/React.createElement("span",{className:"w-3"}),"