feat: fix canvas audio, drag&drop Media Explorer vào timeline

This commit is contained in:
2026-08-02 18:10:02 +07:00
parent c09f994ede
commit 0853a13f62
4 changed files with 83 additions and 14 deletions
+65 -4
View File
@@ -9025,6 +9025,7 @@ const MediaExplorerPanel = ({ height }) => {
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);
@@ -9039,6 +9040,7 @@ const MediaExplorerPanel = ({ height }) => {
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);
@@ -9052,6 +9054,7 @@ const MediaExplorerPanel = ({ height }) => {
selectedRef.current = selected;
peaksRef.current = peaks;
audioBufferRef.current = audioBuffer;
audioDurationRef.current = audioDuration;
midiNotesRef.current = midiNotes;
midiTotalRef.current = midiTotal;
midiBarsRef.current = midiBars;
@@ -9257,7 +9260,7 @@ const MediaExplorerPanel = ({ height }) => {
}
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 || (audioBufferRef.current && matches ? audioBufferRef.current.duration : 0) || 0;
return f.duration || (matches && audioDurationRef.current ? audioDurationRef.current : 0) || (audioBufferRef.current && matches ? audioBufferRef.current.duration : 0) || 0;
};
const folderFiles = React.useMemo(() => {
@@ -9286,6 +9289,7 @@ const MediaExplorerPanel = ({ height }) => {
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));
@@ -9309,6 +9313,7 @@ const MediaExplorerPanel = ({ height }) => {
const data = await resp.json();
if (selectTokenRef.current !== token) return;
setPeaks(data.peaks || []);
if (data.duration) setAudioDuration(data.duration);
} catch (e) { setPeaks(null); }
};
@@ -9629,6 +9634,7 @@ const MediaExplorerPanel = ({ height }) => {
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;
@@ -9761,11 +9767,12 @@ const MediaExplorerPanel = ({ height }) => {
}
const mid = h / 2;
ctx.fillStyle = '#22c55e';
const barW = Math.max(1, contentW / pk.length);
for (let i = 0; i < pk.length; i++) {
const x = (i / pk.length) * contentW - offset;
if (x < -3 || x > w + 3) continue;
const ph = Math.max(2, pk[i] * (h / 2 - 4));
ctx.fillRect(x, mid - ph, Math.max(1, w / pk.length), ph * 2);
ctx.fillRect(x, mid - ph, barW, ph * 2);
}
const audioPlayheadX = contentW > w ? Math.min(w / 2, t * pxPerSec) : Math.min(w, t * pxPerSec);
if (playing && dur > 0) {
@@ -9805,6 +9812,7 @@ const MediaExplorerPanel = ({ height }) => {
setCurrentTime(0);
setPeaks(null);
setAudioBuffer(null);
setAudioDuration(0);
setMidiNotes(null);
stopMediaPlayback();
if (f.kind === 'other') return;
@@ -9944,6 +9952,14 @@ const MediaExplorerPanel = ({ height }) => {
const icon = f.is_dir ? 'fa-folder text-[#d9a752]' : (isMidi ? 'fa-music text-purple-600' : (f.kind === 'audio' ? 'fa-file-audio text-emerald-600' : 'fa-file text-zinc-500'));
return (
<tr key={(f.path || f.file_id || f.name) + i}
draggable={!f.is_dir}
onDragStart={e => {
if (f.is_dir) { e.preventDefault(); return; }
e.dataTransfer.setData('text/plain', f.name || f.original_name || '');
e.dataTransfer.effectAllowed = 'copy';
window.__mediaExplorerDragFile = f;
}}
onDragEnd={() => { window.__mediaExplorerDragFile = null; }}
className={`cursor-pointer hover:bg-blue-100 ${isSel ? 'file-row-selected' : ''}`}
onClick={() => f.is_dir ? browseComputerDir(f) : handleSelect(f)}
onDoubleClick={() => f.is_dir && browseComputerDir(f)}>
@@ -16871,6 +16887,32 @@ const App = () => {
window.parseMidiFile = parseMidiFile;
// Load File on Track (with server upload)
// Resolve Media Explorer drag file real File (for timeline drop)
const resolveMediaExplorerDropFile = async () => {
const mef = window.__mediaExplorerDragFile;
if (!mef) return null;
try {
if (mef.handle && typeof mef.handle.getFile === 'function') {
const f = await mef.handle.getFile();
return new File([f], f.name || mef.name, { type: f.type || 'application/octet-stream' });
}
if (mef.file_id || mef.fileId) {
const fid = mef.file_id || mef.fileId;
const resp = await fetch(`${API_BASE_URL}/api/v1/audio/download/${fid}`);
const blob = await resp.blob();
return new File([blob], mef.name || mef.original_name || fid, { type: blob.type || 'audio/wav' });
}
if (mef.path) {
const resp = await fetch(`${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(mef.path)}`);
const blob = await resp.blob();
return new File([blob], mef.name || mef.path.split(/[\\/]/).pop(), { type: blob.type || 'application/octet-stream' });
}
} catch (e) {
showToast('Không thể nạp file từ Media Explorer: ' + e.message, 'error');
}
return null;
};
const loadFileOnTrack = async (trackId, file) => {
if (!file) return;
var fileName = file.name || '';
@@ -21609,8 +21651,20 @@ const App = () => {
})), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full",
onDragOver: e => e.preventDefault(),
onDrop: e => {
onDrop: async e => {
e.preventDefault();
// Media Explorer drag (client FS handle / server file_id / local path)
if (window.__mediaExplorerDragFile) {
const f = await resolveMediaExplorerDropFile();
window.__mediaExplorerDragFile = null;
if (!f) return;
if (/\.mid$|\.midi$/i.test(f.name || '')) {
handleDropMidiToNewTracks(f);
} else if (selectedTrackId) {
loadFileOnTrack(selectedTrackId, f);
}
return;
}
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (!f) return;
if (/\.mid$|\.midi$/i.test(f.name || '')) {
@@ -21641,9 +21695,16 @@ const App = () => {
},
className: `shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`,
onDragOver: e => e.preventDefault(),
onDrop: e => {
onDrop: async e => {
e.preventDefault();
e.stopPropagation();
// Media Explorer drag (client FS handle / server file_id / local path)
if (window.__mediaExplorerDragFile) {
const f = await resolveMediaExplorerDropFile();
window.__mediaExplorerDragFile = null;
if (f) loadFileOnTrack(track.id, f);
return;
}
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (!f) return;
loadFileOnTrack(track.id, f);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608021850" defer></script>
<script src="/static/js/app.precompiled.js?v=202608021930" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+5
View File
@@ -1134,3 +1134,8 @@
- **Tóm tắt thay đổi:** (1) `parseMidiFile` trả thêm `totalBeats`/`bars`/`bpm`/`ticksPerBeat` → metadata + footer + ruler canvas hiển thị bars đúng (thay vì suy từ duration). (2) Thêm `synthInstRef``selectSynthInst` re-schedule preview MIDI ngay khi đổi instrument đang phát (realtime). (3) MIDI preview hỗ trợ **loop**: `loopTimerRef` setInterval re-schedule notes mỗi vòng khi `isLooping`; `toggleLoop` cũng re-schedule khi đang preview; `stopMediaPlayback` clear interval. (4) Thêm **FontAwesome CDN** vào index.html → icon Stop/Play/Pause/Loop + Back/Forward/Up/Refresh hiển thị. (5) `openMyComputer` restore thư mục đã mở từ session (click My Computer → hiện content folder trong ô File, không mở picker lại nếu đã có session + handle IndexedDB).
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Smoke jsdom: metadata Bars/Beats/BPM hiển thị, footer "Bar X/Y"; đổi synth Strings→prog 48 re-schedule OK; loop bật→notes 64→96 (re-schedule vòng 2); icons đủ; folder restore khi click My Computer OK. Hard reload.
### [2026-08-02 19:30] Task: Fix canvas audio + drag & drop Media Explorer → timeline
- **Tóm tắt thay đổi:** (1) Fix canvas audio hiển thị sai: waveform server trả `duration` nhưng không lưu → `dur=0` nên ruler/playhead/time sai. Thêm state `audioDuration` (set ở loadWaveform local/server + playSelected, clear ở handleSelect), `fileDuration` ưu tiên dùng; bar width waveform sửa theo `contentW` thay vì `w` để scroll đúng. (2) Drag & drop file từ Media Explorer vào timeline MAIN SESSION + SECTION-TAB: file row `draggable` + `onDragStart` set `window.__mediaExplorerDragFile`; App thêm `resolveMediaExplorerDropFile()` chuyển entry → real File (client handle `getFile()`, server `file_id` fetch download, local `path` fetch `/media/file`); drop handlers trên track lane + wrapper timeline xử lý cả drag ME lẫn drag OS file (MIDI → new track / audio → load vào track).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: wav client → metadata Duration 3.00s (trước là 0); drag file row → payload text/plain=name + `__mediaExplorerDragFile` set → drop vào track lane → resolve thành File + load (uploadToServer gọi). Hard reload.