feat: fix canvas audio, drag&drop Media Explorer vào timeline
This commit is contained in:
+65
-4
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user