feat: Media Explorer persist tree width, canvas realtime, Synth instrument preview MIDI

This commit is contained in:
2026-08-02 17:08:54 +07:00
parent c435d00897
commit 1de6bd6b57
4 changed files with 165 additions and 16 deletions
+150 -10
View File
@@ -9025,13 +9025,27 @@ const MediaExplorerPanel = ({ height }) => {
const [currentTime, setCurrentTime] = React.useState(0);
const [peaks, setPeaks] = React.useState(null);
const [audioBuffer, setAudioBuffer] = React.useState(null);
const [midiNotes, setMidiNotes] = React.useState(null);
const [midiTotal, setMidiTotal] = React.useState(4);
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 [treeWidth, setTreeWidth] = React.useState(176);
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 [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) => {
@@ -9039,11 +9053,14 @@ const MediaExplorerPanel = ({ height }) => {
const startX = e.clientX;
const startW = treeWidthRef.current;
const onMove = (ev) => {
setTreeWidth(Math.max(110, Math.min(420, startW + (ev.clientX - startX))));
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);
@@ -9064,7 +9081,10 @@ const MediaExplorerPanel = ({ height }) => {
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)) return (f.lengthQn || 16) * 60 / (f.bpm || 120);
if (isMidiFile(f)) {
if (midiTotal && midiNotes && midiNotes.length) return midiTotal;
return (f.lengthQn || 16) * 60 / (f.bpm || 120);
}
const matches = selected && ((f.path && f.path === selected.path) || (!f.path && (f.file_id || f.fileId) === (selected.file_id || selected.fileId)));
return f.duration || (audioBuffer && matches ? audioBuffer.duration : 0) || 0;
};
@@ -9268,6 +9288,71 @@ const MediaExplorerPanel = ({ height }) => {
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);
setSynthOpen(false);
localStorage.setItem('studio_media_explorer_synth', JSON.stringify(inst));
};
const playMidiPreview = async (f, token) => {
// Play real MIDI file through selected synth instrument (SonicSF)
if (!f || !window.SonicSF) return;
try {
const buf = await readLocalFileBuffer(f);
if (!buf) return;
if (selectTokenRef.current !== (token || selectTokenRef.current)) return;
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
if (!midiResult || !midiResult.length) return;
const ctx = getAudioContext();
const bpmVal = parseInt(synthInst && synthInst.bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
const startWallTime = ctx.currentTime + 0.05;
const program = synthInst ? synthInst.program : undefined;
const sfId = synthInst ? synthInst.sfId : undefined;
const bank = synthInst ? synthInst.bank : 0;
if (synthInst && window.SonicSF.selectInstrument) {
try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {}
}
const prog = (synthInst && synthInst.program !== undefined) ? synthInst.program : undefined;
const eng = synthInst ? { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: prog !== undefined ? prog : 0 } : undefined;
midiResult.forEach(track => {
(track.notes || []).forEach(note => {
const startSec = (note.start_beat || 0) * secondsPerBeat;
const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000);
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, startWallTime + startSec, prog, null, 0, eng);
});
});
// Keep a fake clock so canvas playhead animates; loop uses playStateRef
playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: midiResult[0].duration || 4 };
setMidiNotes(midiResult[0].notes || []);
setMidiTotal(midiResult[0].duration || 4);
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) {}
@@ -9284,6 +9369,11 @@ const MediaExplorerPanel = ({ height }) => {
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);
@@ -9365,17 +9455,30 @@ const MediaExplorerPanel = ({ height }) => {
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';
const beats = (f.lengthQn || 16);
const totalBeats = midiNotes && midiNotes.length ? Math.max(midiTotal * (f.bpm || 120) / 60, 4) : (f.lengthQn || 16);
const beats = totalBeats;
for (let b = 0; b <= beats; b += 4) {
const x = (b / beats) * w;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
}
ctx.fillStyle = '#9ca3af';
const events = f.events || 76;
for (let i = 0; i < events; i++) {
const nx = ((i * 17 + 76) % 95) / 100 * (w - 20);
const ny = ((i * 13 + 76) % (h - 30)) + 4;
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
if (midiNotes && midiNotes.length) {
// Real piano-roll: rows = pitches (48..84), columns = beats
const pitchMin = 48, pitchMax = 84;
const pitchRange = Math.max(1, pitchMax - pitchMin);
midiNotes.forEach(n => {
const x = (n.start_beat / beats) * w;
const nw = Math.max(3, (n.duration_beats / beats) * w);
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; i < events; i++) {
const nx = ((i * 17 + 76) % 95) / 100 * (w - 20);
const ny = ((i * 13 + 76) % (h - 30)) + 4;
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
}
}
} else {
const pk = peaks && peaks.length > 0 ? peaks : null;
@@ -9408,7 +9511,7 @@ const MediaExplorerPanel = ({ height }) => {
}
};
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, selected, folder, isPlaying]);
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, midiNotes, selected, folder, isPlaying]);
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
const handleSelect = (f) => {
@@ -9419,6 +9522,7 @@ const MediaExplorerPanel = ({ height }) => {
setCurrentTime(0);
setPeaks(null);
setAudioBuffer(null);
setMidiNotes(null);
stopMediaPlayback();
if (f.kind === 'other') return;
if (autoPlay) { playSelected(f, token); }
@@ -9583,6 +9687,41 @@ const MediaExplorerPanel = ({ height }) => {
<button id="btnAutoPlay" className={`h-6 px-2 border rounded-sm font-bold text-[10px] flex items-center gap-1 ${autoPlay ? 'bg-gradient-to-r from-cyan-600 to-emerald-600 text-white border-slate-700' : 'bg-[#e0e0e0] text-slate-600 border-[#707070]'}`} onClick={() => setAutoPlay(p => !p)}>
<i className="fa-solid fa-bolt text-[9px]"></i> <span>Auto-Play</span>
</button>
<div className="relative">
<button id="btnSynth" className={`h-6 px-2 border rounded-sm font-bold text-[10px] flex items-center gap-1 ${synthInst ? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white border-slate-700' : 'bg-[#e0e0e0] text-slate-600 border-[#707070]'}`} title="Chọn instrument để preview MIDI" onClick={toggleSynthDropdown}>
<i className="fa-solid fa-music text-[9px]"></i> <span>Synth{synthInst ? ': ' + (synthInst.name || '?') : ''}</span>
<i className="fa-solid fa-caret-down text-[8px]"></i>
</button>
{synthOpen && (
<div className="absolute bottom-8 left-0 z-40 w-64 bg-white border border-[#808080] shadow-xl rounded-sm text-xs text-slate-800 font-sans max-h-72 overflow-y-auto">
<div className="sticky top-0 bg-[#e0e0e0] px-2 py-1 font-bold border-b border-[#a0a0a0] flex items-center justify-between">
<span>Select Instrument</span>
<button onClick={() => setSynthOpen(false)} className="text-slate-500 hover:text-slate-900"><i className="fa-solid fa-xmark"></i></button>
</div>
{synthLoading && <div className="px-2 py-2 text-slate-400 italic">Loading...</div>}
{!synthLoading && (!synthList || synthList.length === 0) && (
<div className="px-2 py-2 text-slate-400 italic">Không SoundFont nào</div>
)}
<div className={`flex items-center gap-1 px-2 py-1 cursor-pointer hover:bg-blue-100 ${!synthInst ? 'bg-slate-200' : ''}`} onClick={() => selectSynthInst(null)}>
<i className="fa-solid fa-ban text-slate-400"></i> None (mặc định)
</div>
{!synthLoading && synthList && synthList.map(group => (
<div key={group.sf.id || group.sf.name}>
<div className="px-2 py-1 bg-[#f0f0f0] font-semibold text-slate-600 border-t border-[#e0e0e0] truncate">{group.sf.display || group.sf.name || group.sf.id}</div>
{(group.presets || []).slice(0, 200).map((p, pi) => {
const progId = p.id || p.name || ('preset_' + pi);
return (
<div key={progId} className={`flex items-center gap-1 px-2 pl-4 py-0.5 cursor-pointer hover:bg-blue-100 truncate ${synthInst && synthInst.program === p.program && synthInst.sfId === (group.sf.id || group.sf.name) ? 'bg-slate-200' : ''}`}
onClick={() => selectSynthInst({ sfId: group.sf.id, sfName: group.sf.display || group.sf.name || group.sf.id, bank: p.bank || 0, program: p.program, name: p.name || ('Program ' + p.program) })}>
{p.bank === 128 ? <i className="fa-solid fa-drum text-slate-400"></i> : <i className="fa-solid fa-music text-slate-400"></i>} {p.name || ('Program ' + p.program)}
</div>
);
})}
</div>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 font-mono text-[11px]">
<div className="flex items-center gap-1">
@@ -16399,6 +16538,7 @@ const App = () => {
}
return result.length > 0 ? result : null;
};
window.parseMidiFile = parseMidiFile;
// Load File on Track (with server upload)
const loadFileOnTrack = async (trackId, file) => {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,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=202608021650" defer></script>
<script src="/static/js/app.precompiled.js?v=202608021715" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+5
View File
@@ -1104,3 +1104,8 @@
- **Tóm tắt thay đổi:** `MediaExplorerPanel`: (1) `handleSelect` giờ clear ngay `setPeaks(null)` + `setAudioBuffer(null)` + `stopMediaPlayback()` khi chọn file mới → canvas xóa dữ liệu cũ ngay; (2) thêm `selectTokenRef` guard async race: `loadWaveform``playSelected` check token sau mỗi await, kết quả cũ (file trước) không ghi đè file mới chọn; (3) gọi `drawCanvas` qua effect phụ thuộc `[peaks, audioBuffer, selected]` → vẽ lại realtime.
- **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: click file → row selected + metadata MIDI events hiển thị; switch nhanh liên tục → file cuối đúng được chọn, không bị race. Hard reload.
### [2026-08-02 17:15] Task: Media Explorer — persist tree width, canvas realtime, Synth instrument cho MIDI
- **Tóm tắt thay đổi:** (1) Tree width lưu vào `localStorage['studio_media_explorer_tree_width']` khi kéo resize (onUp), khởi tạo đọc lại — reload vẫn giữ độ rộng. (2) Canvas cập nhật realtime khi click file: clear `peaks`/`audioBuffer`/`midiNotes` ngay khi chọn + `selectTokenRef` chống race async; MIDI real vẽ piano-roll nốt thật (màu xám theo pitch 48-84). (3) Nút **Synth** cạnh Auto-Play: dropdown chọn SoundFont + preset (load qua `listPlugins`+`listSoundfontInstruments`), lưu vào `localStorage['studio_media_explorer_synth']`; preview MIDI file thật play qua `window.SonicSF.playNote` với instrument đã chọn (`parseMidiFile` expose ra `window.parseMidiFile` vì nằm trong App scope).
- **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: tree resize → localStorage `276`; Synth dropdown hiện Piano/Grand → chọn persisted; click `tune.mid` → SonicSF.playNote gọi với program 0. Hard reload.