feat: Media Explorer persist tree width, canvas realtime, Synth instrument preview MIDI
This commit is contained in:
+150
-10
@@ -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 có 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
Reference in New Issue
Block a user