diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index 24ee459..cf69ff1 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -9009,7 +9009,7 @@ const MEDIA_LIBRARY_SAMPLES = [
{ name: "MIDI_Loop_06.mid", events: 88, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" }
];
-const MediaExplorerPanel = ({ height }) => {
+const MediaExplorerPanel = ({ height, clipboardRef }) => {
const [userFiles, setUserFiles] = React.useState([]);
const [folder, setFolder] = React.useState('library');
const [selected, setSelected] = React.useState(null);
@@ -9034,7 +9034,13 @@ const MediaExplorerPanel = ({ height }) => {
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 [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 }
// 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);
@@ -9065,6 +9071,201 @@ const MediaExplorerPanel = ({ height }) => {
folderRef.current = folder;
tempoRef.current = tempo;
currentTimeRef.current = currentTime;
+
+ 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 = 0;
+ if (isPlayingRef.current && contentW > w && dur > 0) {
+ offset = Math.max(0, Math.min(contentW - w, currentTimeRef.current * pxPerSec - w / 2));
+ }
+ 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();
+ 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));
+ }
+ };
+
+ 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 < numCh; ch++) {
+ clipBuffer.copyToChannel(activeBuf.getChannelData(ch).subarray(startSample, endSample), ch);
+ }
+
+ const clipObj = {
+ buffer: clipBuffer,
+ name: selected.name || 'Audio Selection',
+ volumeDb: 0,
+ pan: 0,
+ color: '#10b981',
+ sampleRate: sr,
+ channels: numCh,
+ speed: 1.0
+ };
+
+ if (clipboardRef) clipboardRef.current = clipObj;
+ window.globalStudioClipboard = clipObj;
+
+ window.showToast && window.showToast(`Đã sao chép đoạn audio dài ${(len / sr).toFixed(2)}s.`, 'success');
+ } catch (e) {
+ console.error(e);
+ window.showToast && window.showToast('Lỗi khi trích xuất âm thanh vùng chọn', 'error');
+ }
+ }
+ };
+
+ React.useEffect(() => {
+ const handleGlobalClick = () => setPreviewCtxMenu(null);
+ window.addEventListener('click', handleGlobalClick);
+ return () => window.removeEventListener('click', handleGlobalClick);
+ }, []);
+
const [computerRoots, setComputerRoots] = React.useState(null);
const [computerTree, setComputerTree] = React.useState({});
const [computerPath, setComputerPath] = React.useState(null);
@@ -9370,6 +9571,7 @@ const MediaExplorerPanel = ({ height }) => {
const openMyComputer = async () => {
setFolder('computer');
const useClientRoot = async (rootHandle) => {
+ setComputerTree({}); // Dọn sạch cache cây thư mục cũ
setComputerMode('client');
setClientRoot(rootHandle);
const rootEntry = { name: rootHandle.name, path: 'root', is_dir: true, handle: rootHandle };
@@ -9524,19 +9726,37 @@ const MediaExplorerPanel = ({ height }) => {
// 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) {
- setClientRoot(favHandle);
- setComputerMode('client');
- const rootEntry = { name: favHandle.name, path: 'root', is_dir: true, handle: favHandle };
- setComputerRoots([rootEntry]);
- setComputerPath('root');
- setComputerFiles([]);
- await browseComputerDir(rootEntry);
+ // Yêu cầu quyền đọc (permission có thể bị thu hồi)
+ let permitted = true;
+ try {
+ if (typeof favHandle.queryPermission === 'function') {
+ const st = await favHandle.queryPermission({ mode: 'read' });
+ if (st !== 'granted' && typeof favHandle.requestPermission === 'function') {
+ const r = await favHandle.requestPermission({ mode: 'read' });
+ permitted = r === 'granted';
+ }
+ }
+ } catch (e) { permitted = false; }
+
+ if (permitted) {
+ setComputerTree({}); // Reset cache
+ setClientRoot(favHandle);
+ setComputerMode('client');
+ const rootEntry = { name: favHandle.name, path: 'root', is_dir: true, handle: favHandle };
+ setComputerRoots([rootEntry]);
+ setComputerPath('root');
+ setComputerFiles([]);
+ await browseComputerDir(rootEntry);
+ } else {
+ window.showToast && window.showToast('Không có quyền truy cập thư mục này', 'warning');
+ }
} else {
window.showToast && window.showToast('Không thể khôi phục quyền truy cập thư mục này', 'error');
}
@@ -9803,7 +10023,7 @@ const MediaExplorerPanel = ({ height }) => {
ctx.scale(2, 2);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#181818'; ctx.fillRect(0, 0, w, h);
- // Read from refs so the rAF clock's stale closure still draws the latest file
+
const f = selectedRef.current;
const curPeaks = peaksRef.current;
const curAudioBuffer = audioBufferRef.current;
@@ -9819,32 +10039,35 @@ const MediaExplorerPanel = ({ height }) => {
return;
}
const dur = fileDuration(f);
+
+ // Scale layout parameters using zoom
+ const pxPerBeat = 42 * zoom;
+ const pxPerSec = pxPerBeat * (curTempo || 120) / 60;
+
if (isMidiFile(f)) {
ctx.strokeStyle = '#333';
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
ctx.strokeStyle = '#444';
- // Content scrolls when wider than frame: playhead stays at frame center
- const bpmV = curTempo || 120;
- const pxPerBeat = 42;
- const totalBeats = (curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0)
- ? curMidiTotalBeats
- : Math.max(curMidiTotal * bpmV / 60, 4);
+
+ const isRealMidi = curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
+ const totalBeats = isRealMidi ? curMidiTotalBeats : Math.max(curMidiTotal * (curTempo || 120) / 60, 4);
const beats = Math.max(totalBeats, 4);
const contentW = Math.max(w, beats * pxPerBeat);
- const pxPerSec = pxPerBeat * bpmV / 60;
+
let offset = 0;
if (playing && contentW > w && dur > 0) {
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
}
- // bar lines (every 4 beats) + beat labels
+
+ // bar lines (every 4 beats)
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) {
- // Real piano-roll: rows = pitches (48..84), columns = beats
const pitchMin = 48, pitchMax = 84;
const pitchRange = Math.max(1, pitchMax - pitchMin);
curMidiNotes.forEach(n => {
@@ -9862,8 +10085,23 @@ const MediaExplorerPanel = ({ height }) => {
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
}
}
- // playhead (drawn over MIDI): moves to frame center, then stops there & content scrolls.
- // Khi content không scroll được nữa (offset đạt max) thì playhead tiếp tục chạy tới cuối canvas.
+
+ // Draw selection overlay
+ if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
+ const startVal = Math.min(selStart, selEnd);
+ const endVal = Math.max(selStart, selEnd);
+ const xStart = startVal * pxPerBeat - offset;
+ const xEnd = endVal * pxPerBeat - 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 midiPlayheadX = contentW > w ? Math.max(0, Math.min(w, t * pxPerSec - offset)) : Math.min(w, t * pxPerSec);
if (playing && dur > 0) {
ctx.fillStyle = '#ef4444';
@@ -9872,10 +10110,6 @@ const MediaExplorerPanel = ({ height }) => {
} else {
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
if (pk) {
- // Audio hiển thị theo tỉ lệ tempo (pxPerSec), không giãn đầy khung preview
- const audioBpm = curTempo || 120;
- const pxPerBeat = 42;
- const pxPerSec = pxPerBeat * audioBpm / 60;
const contentW = Math.max(1, dur * pxPerSec);
let offset = 0;
if (playing && contentW > w && dur > 0) {
@@ -9890,8 +10124,23 @@ const MediaExplorerPanel = ({ height }) => {
const ph = Math.max(2, pk[i] * (h / 2 - 4));
ctx.fillRect(x, mid - ph, barW, ph * 2);
}
- // Playhead theo tỉ lệ tempo; khi content rộng hơn frame thì khóa giữa + scroll.
- // Khi content không scroll được nữa (offset đạt max) thì playhead tiếp tục chạy tới cuối canvas.
+
+ // Draw selection overlay
+ if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
+ const startVal = Math.min(selStart, selEnd);
+ const endVal = Math.max(selStart, selEnd);
+ 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';
@@ -9902,25 +10151,22 @@ const MediaExplorerPanel = ({ height }) => {
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 rulerBpm = curTempo || 120;
- const rulerPxPerBeat = 42;
const isRealMidi = isMidiFile(f) && curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
- const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
- // Tỉ lệ theo tempo (không giãn đầy khung)
- const rulerContentW = Math.max(1, rulerTotalBeats * rulerPxPerBeat);
- const rulerPxPerSec = rulerPxPerBeat * rulerBpm / 60;
- const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * rulerPxPerSec - w / 2)) : 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 * rulerPxPerBeat - rulerOffset;
+ 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]);
+ React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, audioDuration, midiNotes, selected, folder, isPlaying, zoom, selStart, selEnd]);
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
const handleSelect = (f) => {
@@ -9933,6 +10179,9 @@ const MediaExplorerPanel = ({ height }) => {
setAudioBuffer(null);
setAudioDuration(0);
setMidiNotes(null);
+ setSelStart(null);
+ setSelEnd(null);
+ setPreviewCtxMenu(null);
stopMediaPlayback();
if (f.kind === 'other') return;
if (autoPlay) { playSelected(f, token); }
@@ -10030,25 +10279,17 @@ const MediaExplorerPanel = ({ height }) => {
<Track Templates>
<Project Directory>
-
- My Computer
-
- {folder === 'computer' && computerPath !== 'favorited' && computerRoots && (
-
- {computerRoots.map(root => renderComputerNode(root, 0, true))}
-
- )}
-
-
-
{ e.stopPropagation(); setFavoritedExpanded(!favoritedExpanded); }}>
-
Favorited
+ {/* FAVORITED */}
+
{ openFavorited(); setFavoritedExpanded(!favoritedExpanded); }}>
+ Favorited
{favoritedExpanded && (
{favorites.map((fav, fi) => (
openFavorite(fav)}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setFavContext(Object.assign({}, fav, { x: e.clientX, y: e.clientY })); }}>
@@ -10062,6 +10303,16 @@ const MediaExplorerPanel = ({ height }) => {
)}
+ {/* MY COMPUTER */}
+
+ My Computer
+
+ {folder === 'computer' && computerPath !== 'favorited' && computerRoots && (
+
+ {computerRoots.map(root => renderComputerNode(root, 0, true))}
+
+ )}
+
setFolder('library')}>
Media Library
@@ -10251,7 +10502,65 @@ const MediaExplorerPanel = ({ height }) => {
{/* CANVAS + METADATA */}
-
+
+
+ {/* FLOATING ZOOM CONTROLS */}
+
+
+
+
+
+
+ {/* PREVIEW CONTEXT MENU */}
+ {previewCtxMenu && (
+
e.stopPropagation()}
+ >
+
{
+ handleCopySelection();
+ setPreviewCtxMenu(null);
+ }}
+ >
+ Copy
+
+
setPreviewCtxMenu(null)}
+ >
+ Cancel
+
+
+ )}
{selected ? (
@@ -12176,13 +12485,43 @@ const App = () => {
};
const handleSubTabPaste = tabId => {
const st = subTabsRef.current.find(s => s.id === tabId);
- if (!st || !st.buffer) return;
- if (!clipboardRef.current || !clipboardRef.current.buffer) {
- showToast('Clipboard trống.', 'warning');
+ if (!st) return;
+
+ // Check if Piano Roll Tab
+ if (st.type === 'PIANO_ROLL') {
+ const clip = clipboardRef.current || window.globalStudioClipboard;
+ if (!clip || clip.type !== 'midi' || !clip.notes) {
+ showToast('Clipboard không chứa dữ liệu MIDI.', 'warning');
+ return;
+ }
+ const secondsPerBeat = 60 / (bpm || 120);
+ const pasteBeat = (st.currentTime || 0) / secondsPerBeat;
+
+ const newNotes = clip.notes.map(n => ({
+ id: 'note_' + Math.random().toString(36).substr(2, 9),
+ pitch: n.pitch,
+ start_beat: pasteBeat + n.start_beat,
+ duration_beats: n.duration_beats,
+ velocity: n.velocity || 0.8
+ }));
+
+ setSubTabs(prev => prev.map(s => s.id === tabId ? {
+ ...s,
+ notes: [...(s.notes || []), ...newNotes]
+ } : s));
+ showToast('Đã dán note MIDI vào Piano Roll.', 'success');
+ return;
+ }
+
+ // Default Waveform Audio Paste
+ if (!st.buffer) return;
+ const clip = clipboardRef.current || window.globalStudioClipboard;
+ if (!clip || !clip.buffer) {
+ showToast('Clipboard trống hoặc không chứa dữ liệu âm thanh.', 'warning');
return;
}
const ctx = getAudioContext();
- const clipBuf = clipboardRef.current.buffer;
+ const clipBuf = clip.buffer;
const sr = st.buffer.sampleRate;
const data = st.buffer.getChannelData(0);
const insertTime = st.currentTime || 0;
@@ -13730,10 +14069,74 @@ const App = () => {
contextMenuDelete();
};
const doPaste = (targetTrackId, pasteTime) => {
- if (!clipboardRef.current || !clipboardRef.current.buffer) {
+ const clip = clipboardRef.current || window.globalStudioClipboard;
+ if (!clip) {
showToast('Clipboard trống.', 'warning');
return null;
}
+
+ // Support MIDI Clip Paste
+ if (clip.type === 'midi') {
+ const { notes, name, duration, color } = clip;
+ const targetTrack = activeTracks.find(t => t.id === targetTrackId);
+ const newMidiItem = {
+ id: 'midi_' + Date.now(),
+ startTime: pasteTime,
+ duration: duration || 4,
+ name: (name || 'Pasted MIDI').replace(/\.\w+$/, '') + ' (Pasted)',
+ notes: notes.map(n => ({
+ id: 'note_' + Math.random().toString(36).substr(2, 9),
+ pitch: n.pitch,
+ start_beat: n.start_beat,
+ duration_beats: n.duration_beats,
+ velocity: n.velocity || 0.8
+ }))
+ };
+
+ if (targetTrack) {
+ updateActiveTracks(p => p.map(t => {
+ if (t.id === targetTrackId) {
+ const existingMidi = t.midiItems || [];
+ return {
+ ...t,
+ midiItems: [...existingMidi, newMidiItem]
+ };
+ }
+ return t;
+ }));
+ setCurrentTime(pasteTime);
+ showToast('Đã dán MIDI vào track.', 'success');
+ return targetTrackId;
+ } else {
+ const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
+ const rearrangeNewId = 'track_pasted_midi_' + Date.now();
+ updateActiveTracks(prev => [...prev, {
+ id: rearrangeNewId,
+ name: `Pasted_${name || 'MIDI'}`,
+ buffer: null,
+ startTime: 0,
+ clips: [],
+ midiItems: [newMidiItem],
+ volumeDb: 0,
+ pan: 0,
+ muted: false,
+ solo: false,
+ color: color || colors[prev.length % colors.length],
+ markers: [],
+ serverFileId: null
+ }]);
+ setSelectedTrackId(rearrangeNewId);
+ setCurrentTime(pasteTime);
+ showToast('Đã dán track MIDI mới từ clipboard.', 'success');
+ return rearrangeNewId;
+ }
+ }
+
+ // Default Audio Clip Paste
+ if (!clip.buffer) {
+ showToast('Clipboard không chứa dữ liệu âm thanh hợp lệ.', 'warning');
+ return null;
+ }
const {
buffer: clipBuffer,
name,
@@ -13743,7 +14146,7 @@ const App = () => {
sampleRate,
channels,
speed
- } = clipboardRef.current;
+ } = clip;
const ctx = getAudioContext();
const targetTrack = activeTracks.find(t => t.id === targetTrackId);
const newClip = {
@@ -22664,7 +23067,8 @@ const App = () => {
})))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 min-h-0 overflow-hidden bg-[#262626]"
}, /*#__PURE__*/React.createElement(MediaExplorerPanel, {
- height: mediaExplorerPanelHeight
+ height: mediaExplorerPanelHeight,
+ clipboardRef: clipboardRef
}))));
})(), /*#__PURE__*/React.createElement("div", {
className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0"
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index 5b552e2..522a92c 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -232,22 +232,30 @@ 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})=>{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;}());// Refs mirror latest state so drawCanvas (also called from rAF clock with a
+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[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 }
+// 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);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;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[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
+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);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;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=0;if(isPlayingRef.current&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,currentTimeRef.current*pxPerSec-w/2));}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();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));}};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);},[]);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[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
await browseComputerDir(rootEntry);}else{setComputerRoots(null);setComputerMode('client');}// 2. Khôi phục thư mục của phiên làm việc trước
if(lastFolder==='computer'&&lastComputerPath&&lastComputerPath!=='my_computer'&&lastComputerPath!=='favorited'){if(restoredClientRoot){if(lastComputerPath==='root'){browseComputerDir({name:restoredClientRoot.name,path:'root',is_dir:true,handle:restoredClientRoot});}else if(lastComputerPath.startsWith('root/')){const segments=lastComputerPath.split('/').slice(1);let curHandle=restoredClientRoot;let success=true;for(const seg of segments){try{curHandle=await curHandle.getDirectoryHandle(seg);}catch(err){success=false;break;}}if(success){browseComputerDir({name:segments[segments.length-1]||restoredClientRoot.name,path:lastComputerPath,is_dir:true,handle:curHandle});}else{setComputerPath('favorited');}}else{setComputerPath('favorited');}}else{setComputerPath('favorited');}}else{setComputerPath(lastComputerPath==='my_computer'?'favorited':lastComputerPath||'favorited');}})();// eslint-disable-next-line react-hooks/exhaustive-deps
-},[]);const isMidiFile=f=>f&&(f.kind==='midi'||/\.(mid|midi)$/i.test(f.name||f.original_name||''));const fileDuration=f=>{if(!f)return 0;if(isMidiFile(f)){if(midiTotalRef.current&&midiNotesRef.current&&midiNotesRef.current.length)return midiTotalRef.current;return(f.lengthQn||16)*60/(f.bpm||tempoRef.current||120);}const sel=selectedRef.current;const matches=sel&&(f.path&&f.path===sel.path||!f.path&&(f.file_id||f.fileId)===(sel.file_id||sel.fileId));return f.duration||(matches&&audioDurationRef.current?audioDurationRef.current:0)||(audioBufferRef.current&&matches?audioBufferRef.current.duration:0)||0;};const folderFiles=React.useMemo(()=>{if(folder==='library')return MEDIA_LIBRARY_SAMPLES;if(folder==='computer'){if(computerPath==='my_computer'||computerPath==='favorited'||!computerPath){return favorites||[];}const node=computerTree[computerPath]||{dirs:[]};return[...(node.dirs||[]),...computerFiles];}return userFiles.filter(f=>folder==='uploads'?(f.type||'Upload')==='Upload':(f.type||'Processed')==='Processed');},[folder,userFiles,computerFiles,computerTree,computerPath,favorites]);const visibleFiles=React.useMemo(()=>{const q=filterText.toLowerCase().trim();if(!q)return folderFiles;return folderFiles.filter(f=>(f.name||f.original_name||'').toLowerCase().includes(q));},[folderFiles,filterText]);const loadWaveform=async f=>{const token=selectTokenRef.current;if(f&&(f.handle||f.path)){try{const buf=await readLocalFileBuffer(f);if(selectTokenRef.current!==token)return;if(!buf){setPeaks(null);return;}const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);if(selectTokenRef.current!==token)return;setAudioBuffer(decoded);setAudioDuration(decoded.duration);const data=decoded.getChannelData(0);const count=600;const step=Math.max(1,Math.floor(data.length/count));const pk=[];for(let i=0;im)m=v;}pk.push(m);}setPeaks(pk);}catch(e){if(selectTokenRef.current===token)setPeaks(null);}return;}const fid=f.file_id||f.fileId;if(!fid){setPeaks(null);return;}try{const resp=await fetch(`${API_BASE_URL}/api/v1/audio/waveform/${fid}?num_peaks=600`);const data=await resp.json();if(selectTokenRef.current!==token)return;setPeaks(data.peaks||[]);if(data.duration)setAudioDuration(data.duration);}catch(e){setPeaks(null);}};const openFavorited=()=>{setFolder('computer');setComputerPath('favorited');setComputerFiles([]);};const openMyComputer=async()=>{setFolder('computer');const useClientRoot=async rootHandle=>{setComputerMode('client');setClientRoot(rootHandle);const rootEntry={name:rootHandle.name,path:'root',is_dir:true,handle:rootHandle};setComputerRoots([rootEntry]);setComputerPath('root');setComputerFiles([]);// Lưu handle vào IndexedDB làm client_root và làm link Favorited
+},[]);const isMidiFile=f=>f&&(f.kind==='midi'||/\.(mid|midi)$/i.test(f.name||f.original_name||''));const fileDuration=f=>{if(!f)return 0;if(isMidiFile(f)){if(midiTotalRef.current&&midiNotesRef.current&&midiNotesRef.current.length)return midiTotalRef.current;return(f.lengthQn||16)*60/(f.bpm||tempoRef.current||120);}const sel=selectedRef.current;const matches=sel&&(f.path&&f.path===sel.path||!f.path&&(f.file_id||f.fileId)===(sel.file_id||sel.fileId));return f.duration||(matches&&audioDurationRef.current?audioDurationRef.current:0)||(audioBufferRef.current&&matches?audioBufferRef.current.duration:0)||0;};const folderFiles=React.useMemo(()=>{if(folder==='library')return MEDIA_LIBRARY_SAMPLES;if(folder==='computer'){if(computerPath==='my_computer'||computerPath==='favorited'||!computerPath){return favorites||[];}const node=computerTree[computerPath]||{dirs:[]};return[...(node.dirs||[]),...computerFiles];}return userFiles.filter(f=>folder==='uploads'?(f.type||'Upload')==='Upload':(f.type||'Processed')==='Processed');},[folder,userFiles,computerFiles,computerTree,computerPath,favorites]);const visibleFiles=React.useMemo(()=>{const q=filterText.toLowerCase().trim();if(!q)return folderFiles;return folderFiles.filter(f=>(f.name||f.original_name||'').toLowerCase().includes(q));},[folderFiles,filterText]);const loadWaveform=async f=>{const token=selectTokenRef.current;if(f&&(f.handle||f.path)){try{const buf=await readLocalFileBuffer(f);if(selectTokenRef.current!==token)return;if(!buf){setPeaks(null);return;}const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);if(selectTokenRef.current!==token)return;setAudioBuffer(decoded);setAudioDuration(decoded.duration);const data=decoded.getChannelData(0);const count=600;const step=Math.max(1,Math.floor(data.length/count));const pk=[];for(let i=0;im)m=v;}pk.push(m);}setPeaks(pk);}catch(e){if(selectTokenRef.current===token)setPeaks(null);}return;}const fid=f.file_id||f.fileId;if(!fid){setPeaks(null);return;}try{const resp=await fetch(`${API_BASE_URL}/api/v1/audio/waveform/${fid}?num_peaks=600`);const data=await resp.json();if(selectTokenRef.current!==token)return;setPeaks(data.peaks||[]);if(data.duration)setAudioDuration(data.duration);}catch(e){setPeaks(null);}};const openFavorited=()=>{setFolder('computer');setComputerPath('favorited');setComputerFiles([]);};const openMyComputer=async()=>{setFolder('computer');const useClientRoot=async rootHandle=>{setComputerTree({});// Dọn sạch cache cây thư mục cũ
+setComputerMode('client');setClientRoot(rootHandle);const rootEntry={name:rootHandle.name,path:'root',is_dir:true,handle:rootHandle};setComputerRoots([rootEntry]);setComputerPath('root');setComputerFiles([]);// Lưu handle vào IndexedDB làm client_root và làm link Favorited
const favKey='client:'+rootHandle.name;saveClientRootHandle('client_root',rootHandle);saveClientRootHandle(favKey,rootHandle);// Tự động thêm link đến thư mục đó ở Favorited (không ghi đè các mục cũ)
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 ──
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){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){setClientRoot(favHandle);setComputerMode('client');const rootEntry={name:favHandle.name,path:'root',is_dir:true,handle:favHandle};setComputerRoots([rootEntry]);setComputerPath('root');setComputerFiles([]);await browseComputerDir(rootEntry);}else{window.showToast&&window.showToast('Không thể khôi phục quyền truy cập thư mục này','error');}})();}else if(fav.path==='root'&&clientRoot){browseComputerDir({name:clientRoot.name,path:'root',is_dir:true,handle:clientRoot});}else if(fav.path&&fav.path.startsWith('root/')&&clientRoot){// Duyệt lại từ root handle tới folder favorite
+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)
+let permitted=true;try{if(typeof favHandle.queryPermission==='function'){const st=await favHandle.queryPermission({mode:'read'});if(st!=='granted'&&typeof favHandle.requestPermission==='function'){const r=await favHandle.requestPermission({mode:'read'});permitted=r==='granted';}}}catch(e){permitted=false;}if(permitted){setComputerTree({});// Reset cache
+setClientRoot(favHandle);setComputerMode('client');const rootEntry={name:favHandle.name,path:'root',is_dir:true,handle:favHandle};setComputerRoots([rootEntry]);setComputerPath('root');setComputerFiles([]);await browseComputerDir(rootEntry);}else{window.showToast&&window.showToast('Không có quyền truy cập thư mục này','warning');}}else{window.showToast&&window.showToast('Không thể khôi phục quyền truy cập thư mục này','error');}})();}else if(fav.path==='root'&&clientRoot){browseComputerDir({name:clientRoot.name,path:'root',is_dir:true,handle:clientRoot});}else if(fav.path&&fav.path.startsWith('root/')&&clientRoot){// Duyệt lại từ root handle tới folder favorite
const segments=fav.path.split('/').slice(1);let curHandle=clientRoot;const walk=async idx=>{if(idx>=segments.length){browseComputerDir({name:segments[idx-1]||clientRoot.name,path:fav.path,is_dir:true,handle:curHandle});return;}try{const child=await curHandle.getDirectoryHandle(segments[idx]);curHandle=child;walk(idx+1);}catch(e){window.showToast&&window.showToast('Không mở được thư mục favorite','error');}};walk(0);}else if(fav.path){browseComputerDir(entry);}};const goComputerParent=()=>{if(!computerPath)return;if(computerMode==='client'||clientRoot){const node=computerTree[computerPath];const parentHandle=node&&node.parent;if(parentHandle){const parentEntry=computerRoots&&computerRoots[0]&&parentHandle===clientRoot?computerRoots[0]:{name:parentHandle.name,path:computerPath.split('/').slice(0,-1).join('/')||'root',is_dir:true,handle:parentHandle};browseComputerDir({...parentEntry,parent:parentHandle===clientRoot?null:computerTree[parentEntry.path]?computerTree[parentEntry.path].parent:null});}return;}const isUnix=computerPath.startsWith('/');const parts=computerPath.split(/[\\/]/).filter(Boolean);parts.pop();if(isUnix){browseComputerDir(parts.length?'/'+parts.join('/'):'/');}else{// Windows: quay về root ổ đĩa nếu đã lên tới đỉnh
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;}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 playMidiPreview=async(f,token)=>{// Play real MIDI file through selected synth instrument (SonicSF)
@@ -256,19 +264,13 @@ if(!f||!window.SonicSF)return;try{const buf=await readLocalFileBuffer(f);if(!buf
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 allNotes=[];midiResult.forEach(track=>{(track.notes||[]).forEach(note=>{allNotes.push(Object.assign({},note,{trackOffset:track.startTime||0}));});});const schedulePass=passStartTime=>{allNotes.forEach(note=>{const startSec=(note.start_beat||0)*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
if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}if(isLooping){loopTimerRef.current=setInterval(()=>{if(selectTokenRef.current!==(token||selectTokenRef.current)){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}if(!isPlayingRef.current||isPausedRef.current)return;const passStart=ctx.currentTime+0.05;schedulePass(passStart);playStateRef.current=Object.assign({},playStateRef.current,{startedAt:passStart,fakeStart:passStart});},Math.max(200,totalSec*1000));}// Keep a fake clock so canvas playhead animates; loop uses playStateRef
playStateRef.current={source:null,ctx,startedAt:startWallTime,fakeStart:startWallTime,midiTotal:totalSec};setMidiNotes(allNotes);setMidiTotal(totalSec);setMidiBars(midiResult[0].bars||1);setMidiTotalBeats(midiResult[0].totalBeats||16);setMidiFileBpm(midiResult[0].bpm||120);setIsPlaying(true);setIsPaused(false);startCanvasClock();}catch(e){console.error('MIDI preview failed',e);}};const stopMediaPlayback=React.useCallback(()=>{if(playStateRef.current){try{if(playStateRef.current.source)playStateRef.current.source.stop();}catch(e){}try{if(playStateRef.current.source)playStateRef.current.source.disconnect();}catch(e){}playStateRef.current=null;}if(rafRef.current)cancelAnimationFrame(rafRef.current);rafRef.current=null;if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}if(window.SonicSF&&typeof window.SonicSF.stopAll==='function'){try{window.SonicSF.stopAll();}catch(e){}}setIsPlaying(false);setIsPaused(false);},[]);const playSelected=async(f,token)=>{if(!f)return;stopMediaPlayback();if(isMidiFile(f)){if(f.handle||f.path||f.file_id||f.fileId){// Real MIDI file: play through selected synth instrument
-await playMidiPreview(f,token);return;}playStateRef.current={source:null,ctx:null,fakeStart:performance.now()/1000};setIsPlaying(true);setIsPaused(false);startCanvasClock();return;}const fid=f.file_id||f.fileId;const buf=await readLocalFileBuffer(f);if(selectTokenRef.current!==(token||selectTokenRef.current))return;if(!buf)return;try{const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);if(selectTokenRef.current!==(token||selectTokenRef.current))return;setAudioBuffer(decoded);setAudioDuration(decoded.duration);const src=ctx.createBufferSource();src.buffer=decoded;src.loop=isLooping;src.playbackRate.value=rate;const gain=ctx.createGain();const linear=volumeDb<=-50?0:Math.pow(10,volumeDb/20);gain.gain.value=linear;src.connect(gain);gain.connect(ctx.destination);src.start();const startedAt=ctx.currentTime;playStateRef.current={source:src,ctx,startedAt};setIsPlaying(true);setIsPaused(false);startCanvasClock();}catch(e){console.error('Preview failed',e);}};const togglePause=()=>{const st=playStateRef.current;if(!st)return;if(isPaused){if(st.ctx)st.ctx.resume();setIsPaused(false);}else{if(st.ctx)st.ctx.suspend();setIsPaused(true);}};const startCanvasClock=()=>{if(rafRef.current)cancelAnimationFrame(rafRef.current);const tick=()=>{rafRef.current=requestAnimationFrame(tick);let t=currentTimeRef.current;const st=playStateRef.current;if(st&&!isPausedRef.current){t=st.ctx?st.ctx.currentTime-st.startedAt:performance.now()/1000-st.fakeStart;}setCurrentTime(t);currentTimeRef.current=t;drawCanvas(t);};tick();};const drawCanvas=t=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const w=canvas.clientWidth,h=canvas.clientHeight;if(!w||!h)return;canvas.width=w*2;canvas.height=h*2;ctx.scale(2,2);ctx.clearRect(0,0,w,h);ctx.fillStyle='#181818';ctx.fillRect(0,0,w,h);// Read from refs so the rAF clock's stale closure still draws the latest file
-const f=selectedRef.current;const curPeaks=peaksRef.current;const curAudioBuffer=audioBufferRef.current;const curMidiNotes=midiNotesRef.current;const curMidiTotal=midiTotalRef.current;const curMidiBars=midiBarsRef.current;const curMidiTotalBeats=midiTotalBeatsRef.current;const curTempo=tempoRef.current;const playing=isPlayingRef.current;if(!f){ctx.fillStyle='#555';ctx.font='12px JetBrains Mono, monospace';ctx.fillText('No file selected',10,h/2);return;}const dur=fileDuration(f);if(isMidiFile(f)){ctx.strokeStyle='#333';for(let y=0;y0?curMidiTotalBeats:Math.max(curMidiTotal*bpmV/60,4);const beats=Math.max(totalBeats,4);const contentW=Math.max(w,beats*pxPerBeat);const pxPerSec=pxPerBeat*bpmV/60;let offset=0;if(playing&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,t*pxPerSec-w/2));}// bar lines (every 4 beats) + beat labels
-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){// Real piano-roll: rows = pitches (48..84), columns = beats
-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){// Audio hiển thị theo tỉ lệ tempo (pxPerSec), không giãn đầy khung preview
-const audioBpm=curTempo||120;const pxPerBeat=42;const pxPerSec=pxPerBeat*audioBpm/60;const contentW=Math.max(1,dur*pxPerSec);let offset=0;if(playing&&contentW>w&&dur>0){offset=Math.max(0,Math.min(contentW-w,t*pxPerSec-w/2));}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);}// Playhead theo tỉ lệ tempo; khi content rộng hơn frame thì khóa giữa + scroll.
-// Khi content không scroll được nữa (offset đạt max) thì playhead tiếp tục chạy tới cuối canvas.
-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 rulerBpm=curTempo||120;const rulerPxPerBeat=42;const isRealMidi=isMidiFile(f)&&curMidiNotes&&curMidiNotes.length&&curMidiTotalBeats>0;const rulerTotalBeats=isRealMidi?Math.max(curMidiTotalBeats,4):Math.max(4,Math.ceil(dur*rulerBpm/60)||16);// Tỉ lệ theo tempo (không giãn đầy khung)
-const rulerContentW=Math.max(1,rulerTotalBeats*rulerPxPerBeat);const rulerPxPerSec=rulerPxPerBeat*rulerBpm/60;const rulerOffset=playing&&rulerContentW>w&&dur>0?Math.max(0,Math.min(rulerContentW-w,t*rulerPxPerSec-w/2)):0;for(let b=0;b<=rulerTotalBeats;b+=4){const x=b*rulerPxPerBeat-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]);React.useEffect(()=>()=>{if(rafRef.current)cancelAnimationFrame(rafRef.current);},[]);const handleSelect=f=>{if(!f||f.is_dir)return;selectTokenRef.current++;const token=selectTokenRef.current;setSelected(f);setCurrentTime(0);setPeaks(null);setAudioBuffer(null);setAudioDuration(0);setMidiNotes(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",{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;if(playStateRef.current&&playStateRef.current.source)playStateRef.current.source.loop=next;if(next&&isMidiFile(selectedRef.current)){// Re-schedule loop for the currently previewing MIDI file
-const cur=selectedRef.current;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",{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"}),"