feat: bars MIDI đúng, synth realtime, loop preview, icons FA, lưu folder path

This commit is contained in:
2026-08-02 18:01:34 +07:00
parent 249e2afea2
commit c09f994ede
4 changed files with 171 additions and 37 deletions
+144 -22
View File
@@ -9027,6 +9027,9 @@ const MediaExplorerPanel = ({ height }) => {
const [audioBuffer, setAudioBuffer] = React.useState(null); const [audioBuffer, setAudioBuffer] = React.useState(null);
const [midiNotes, setMidiNotes] = React.useState(null); const [midiNotes, setMidiNotes] = React.useState(null);
const [midiTotal, setMidiTotal] = React.useState(4); 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() { const [tempo, setTempo] = React.useState(function() {
var saved = localStorage.getItem('studio_media_explorer_tempo'); var saved = localStorage.getItem('studio_media_explorer_tempo');
return saved ? parseInt(saved) : 120; return saved ? parseInt(saved) : 120;
@@ -9038,6 +9041,9 @@ const MediaExplorerPanel = ({ height }) => {
const audioBufferRef = React.useRef(null); const audioBufferRef = React.useRef(null);
const midiNotesRef = React.useRef(null); const midiNotesRef = React.useRef(null);
const midiTotalRef = React.useRef(4); 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 isPlayingRef = React.useRef(false);
const isPausedRef = React.useRef(false); const isPausedRef = React.useRef(false);
const folderRef = React.useRef('library'); const folderRef = React.useRef('library');
@@ -9048,6 +9054,9 @@ const MediaExplorerPanel = ({ height }) => {
audioBufferRef.current = audioBuffer; audioBufferRef.current = audioBuffer;
midiNotesRef.current = midiNotes; midiNotesRef.current = midiNotes;
midiTotalRef.current = midiTotal; midiTotalRef.current = midiTotal;
midiBarsRef.current = midiBars;
midiTotalBeatsRef.current = midiTotalBeats;
midiFileBpmRef.current = midiFileBpm;
isPlayingRef.current = isPlaying; isPlayingRef.current = isPlaying;
isPausedRef.current = isPaused; isPausedRef.current = isPaused;
folderRef.current = folder; folderRef.current = folder;
@@ -9068,6 +9077,8 @@ const MediaExplorerPanel = ({ height }) => {
const [synthLoading, setSynthLoading] = React.useState(false); const [synthLoading, setSynthLoading] = React.useState(false);
const synthListRef = React.useRef(null); const synthListRef = React.useRef(null);
synthListRef.current = synthList; synthListRef.current = synthList;
const synthInstRef = React.useRef(null);
synthInstRef.current = synthInst;
const [treeWidth, setTreeWidth] = React.useState(function() { const [treeWidth, setTreeWidth] = React.useState(function() {
var saved = localStorage.getItem('studio_media_explorer_tree_width'); var saved = localStorage.getItem('studio_media_explorer_tree_width');
return saved ? parseInt(saved) : 176; return saved ? parseInt(saved) : 176;
@@ -9094,6 +9105,7 @@ const MediaExplorerPanel = ({ height }) => {
const canvasRef = React.useRef(null); const canvasRef = React.useRef(null);
const playStateRef = React.useRef(null); const playStateRef = React.useRef(null);
const rafRef = React.useRef(null); const rafRef = React.useRef(null);
const loopTimerRef = React.useRef(null);
const selectTokenRef = React.useRef(0); const selectTokenRef = React.useRef(0);
React.useEffect(() => { React.useEffect(() => {
@@ -9302,6 +9314,36 @@ const MediaExplorerPanel = ({ height }) => {
const openMyComputer = async () => { const openMyComputer = async () => {
setFolder('computer'); setFolder('computer');
// Restore last opened folder from session (click My Computer show folder content directly)
try {
const raw = localStorage.getItem(SESSION_KEY);
if (raw) {
const snap = JSON.parse(raw);
if (snap && snap.computerPath && snap.computerFiles) {
setComputerPath(snap.computerPath);
setComputerFiles(snap.computerFiles);
setComputerMode(snap.computerMode || 'server');
if (snap.computerTree) {
const tree = {};
Object.keys(snap.computerTree).forEach(p => { tree[p] = { dirs: snap.computerTree[p].dirs || [], expanded: snap.computerTree[p].expanded }; });
setComputerTree(tree);
}
if (snap.computerRoots && snap.computerRoots.length) setComputerRoots(snap.computerRoots);
if (snap.selected) setSelected(snap.selected);
// For client mode, re-attach real handles by walking from the stored root handle
if (snap.computerMode === 'client') {
const savedHandle = await loadClientRootHandle();
if (savedHandle && savedHandle.kind === 'directory') {
setClientRoot(savedHandle);
setComputerRoots([{ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle }]);
setComputerMode('client');
browseComputerDir({ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle });
}
}
return;
}
}
} catch (e) {}
if (window.showDirectoryPicker) { if (window.showDirectoryPicker) {
try { try {
const handle = await window.showDirectoryPicker({ mode: 'read' }); const handle = await window.showDirectoryPicker({ mode: 'read' });
@@ -9470,8 +9512,17 @@ const MediaExplorerPanel = ({ height }) => {
const selectSynthInst = (inst) => { const selectSynthInst = (inst) => {
setSynthInst(inst); setSynthInst(inst);
synthInstRef.current = inst;
setSynthOpen(false); setSynthOpen(false);
localStorage.setItem('studio_media_explorer_synth', JSON.stringify(inst)); 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) => { const playMidiPreview = async (f, token) => {
@@ -9484,31 +9535,54 @@ const MediaExplorerPanel = ({ height }) => {
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf); const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
if (!midiResult || !midiResult.length) return; if (!midiResult || !midiResult.length) return;
const ctx = getAudioContext(); const ctx = getAudioContext();
const bpmVal = tempoRef.current || parseInt(synthInst && synthInst.bpm) || 120; const bpmVal = tempoRef.current || 120;
const secondsPerBeat = 60.0 / bpmVal; const secondsPerBeat = 60.0 / bpmVal;
const startWallTime = ctx.currentTime + 0.05; const startWallTime = ctx.currentTime + 0.05;
const program = synthInst ? synthInst.program : undefined; const curInst = synthInstRef.current;
const sfId = synthInst ? synthInst.sfId : undefined; const program = curInst ? curInst.program : undefined;
const bank = synthInst ? synthInst.bank : 0; const sfId = curInst ? curInst.sfId : undefined;
if (synthInst && window.SonicSF.selectInstrument) { const bank = curInst ? curInst.bank : 0;
if (curInst && window.SonicSF.selectInstrument) {
try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {} try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {}
} }
// Re-check token after the async await stale playMidiPreview (older file) // Re-check token after the async await stale playMidiPreview (older file)
// must not schedule notes over the newly selected file. // must not schedule notes over the newly selected file.
if (selectTokenRef.current !== (token || selectTokenRef.current)) return; if (selectTokenRef.current !== (token || selectTokenRef.current)) return;
const prog = (synthInst && synthInst.program !== undefined) ? synthInst.program : undefined; const prog = (curInst && curInst.program !== undefined) ? curInst.program : undefined;
const eng = synthInst ? { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: prog !== undefined ? prog : 0 } : 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 => { midiResult.forEach(track => {
(track.notes || []).forEach(note => { (track.notes || []).forEach(note => {
const startSec = (note.start_beat || 0) * secondsPerBeat; 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); 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); 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 // 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 }; playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: totalSec };
setMidiNotes(midiResult[0].notes || []); setMidiNotes(allNotes);
setMidiTotal(midiResult[0].duration || 4); setMidiTotal(totalSec);
setMidiBars(midiResult[0].bars || 1);
setMidiTotalBeats(midiResult[0].totalBeats || 16);
setMidiFileBpm(midiResult[0].bpm || 120);
setIsPlaying(true); setIsPlaying(true);
setIsPaused(false); setIsPaused(false);
startCanvasClock(); startCanvasClock();
@@ -9523,6 +9597,7 @@ const MediaExplorerPanel = ({ height }) => {
} }
if (rafRef.current) cancelAnimationFrame(rafRef.current); if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null; rafRef.current = null;
if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; }
if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') {
try { window.SonicSF.stopAll(); } catch (e) {} try { window.SonicSF.stopAll(); } catch (e) {}
} }
@@ -9616,6 +9691,8 @@ const MediaExplorerPanel = ({ height }) => {
const curAudioBuffer = audioBufferRef.current; const curAudioBuffer = audioBufferRef.current;
const curMidiNotes = midiNotesRef.current; const curMidiNotes = midiNotesRef.current;
const curMidiTotal = midiTotalRef.current; const curMidiTotal = midiTotalRef.current;
const curMidiBars = midiBarsRef.current;
const curMidiTotalBeats = midiTotalBeatsRef.current;
const curTempo = tempoRef.current; const curTempo = tempoRef.current;
const playing = isPlayingRef.current; const playing = isPlayingRef.current;
if (!f) { if (!f) {
@@ -9631,14 +9708,17 @@ const MediaExplorerPanel = ({ height }) => {
// Content scrolls when wider than frame: playhead stays at frame center // Content scrolls when wider than frame: playhead stays at frame center
const bpmV = curTempo || 120; const bpmV = curTempo || 120;
const pxPerBeat = 42; const pxPerBeat = 42;
const totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * bpmV / 60, 4) : (f.lengthQn || 16); const totalBeats = (curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0)
const beats = totalBeats; ? curMidiTotalBeats
: Math.max(curMidiTotal * bpmV / 60, 4);
const beats = Math.max(totalBeats, 4);
const contentW = Math.max(w, beats * pxPerBeat); const contentW = Math.max(w, beats * pxPerBeat);
const pxPerSec = pxPerBeat * bpmV / 60; const pxPerSec = pxPerBeat * bpmV / 60;
let offset = 0; let offset = 0;
if (playing && contentW > w && dur > 0) { if (playing && contentW > w && dur > 0) {
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2)); 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) { for (let b = 0; b <= beats; b += 4) {
const x = b * pxPerBeat - offset; const x = b * pxPerBeat - offset;
if (x < -10 || x > w + 10) continue; if (x < -10 || x > w + 10) continue;
@@ -9702,9 +9782,11 @@ const MediaExplorerPanel = ({ height }) => {
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace'; ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
const rulerBpm = curTempo || 120; const rulerBpm = curTempo || 120;
const rulerPxPerBeat = isMidiFile(f) ? 42 : (50 * 60 / rulerBpm); const rulerPxPerBeat = isMidiFile(f) ? 42 : (50 * 60 / rulerBpm);
const rulerContentW = Math.max(w, dur * rulerPxPerBeat * rulerBpm / 60); const isRealMidi = isMidiFile(f) && curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
const rulerTotalBeats = Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16); const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * (rulerPxPerBeat * rulerBpm / 60) - w / 2)) : 0; const rulerContentW = isRealMidi ? Math.max(w, rulerTotalBeats * rulerPxPerBeat) : Math.max(w, dur * rulerPxPerBeat * rulerBpm / 60);
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) { for (let b = 0; b <= rulerTotalBeats; b += 4) {
const x = b * rulerPxPerBeat - rulerOffset; const x = b * rulerPxPerBeat - rulerOffset;
if (x < -20 || x > w + 20) continue; if (x < -20 || x > w + 20) continue;
@@ -9755,6 +9837,16 @@ const MediaExplorerPanel = ({ height }) => {
setIsLooping(prev => { setIsLooping(prev => {
const next = !prev; const next = !prev;
if (playStateRef.current && playStateRef.current.source) playStateRef.current.source.loop = next; 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; return next;
}); });
}; };
@@ -9972,9 +10064,20 @@ const MediaExplorerPanel = ({ height }) => {
) : ( ) : (
<React.Fragment> <React.Fragment>
<div>Size: {selected.size_mb != null ? selected.size_mb.toFixed(2) + ' MB' : '-'}</div> <div>Size: {selected.size_mb != null ? selected.size_mb.toFixed(2) + ' MB' : '-'}</div>
{selIsMidi ? (
<React.Fragment>
<div>Bars: {midiBars || 1}</div>
<div>Beats: {Math.round(midiTotalBeats || 16)}</div>
<div>BPM: {midiFileBpm || 120}</div>
<div>Duration: {selDur.toFixed(2)}s</div>
</React.Fragment>
) : (
<React.Fragment>
<div>Duration: {selDur.toFixed(2)}s</div> <div>Duration: {selDur.toFixed(2)}s</div>
<div>Sample Rate: 44100 Hz</div> <div>Sample Rate: 44100 Hz</div>
<div>Type: {selected.path ? (selIsMidi ? 'Local MIDI' : (selected.kind === 'other' ? 'Local File' : 'Local Audio')) : (selected.type || 'Audio')}</div> <div>Type: {selected.path ? (selected.kind === 'other' ? 'Local File' : 'Local Audio') : (selected.type || 'Audio')}</div>
</React.Fragment>
)}
</React.Fragment> </React.Fragment>
) )
) : <div>No file selected</div>} ) : <div>No file selected</div>}
@@ -9984,7 +10087,14 @@ const MediaExplorerPanel = ({ height }) => {
{/* FOOTER STATUS BAR */} {/* FOOTER STATUS BAR */}
<div className="h-5 bg-[#c0c0c0] border-t border-white flex items-center justify-between text-[11px] font-mono px-1 shrink-0"> <div className="h-5 bg-[#c0c0c0] border-t border-white flex items-center justify-between text-[11px] font-mono px-1 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{selIsMidi && midiNotes && midiNotes.length ? (
<div className="bg-white border border-[#808080] px-1.5 text-slate-900 font-bold">
Bar {Math.max(1, Math.floor(currentTime / (4 * 60 / (tempo || 120))) + 1)} / {midiBars || 1}
<span className="text-slate-500 ml-1">| {formatTime(currentTime)} / {formatTime(selDur)}</span>
</div>
) : (
<div className="bg-white border border-[#808080] px-1.5 text-slate-900 font-bold">{formatTime(currentTime)} / {formatTime(selDur)}</div> <div className="bg-white border border-[#808080] px-1.5 text-slate-900 font-bold">{formatTime(currentTime)} / {formatTime(selDur)}</div>
)}
</div> </div>
<div className="text-slate-800 font-bold truncate max-w-[40%]">{selected ? (selected.name || selected.original_name) : 'No file selected'}</div> <div className="text-slate-800 font-bold truncate max-w-[40%]">{selected ? (selected.name || selected.original_name) : 'No file selected'}</div>
<div className="text-slate-700">{selBpm} bpm x{rate.toFixed(2)}</div> <div className="text-slate-700">{selBpm} bpm x{rate.toFixed(2)}</div>
@@ -16738,9 +16848,21 @@ const App = () => {
midiNotes.push({ id: 'mn_' + t + '_' + p + '_' + pn.tick, pitch: p, start_beat: pn.tick / ticksPerBeat, duration_beats: dur / ticksPerBeat, velocity: Math.min(1, pn.vel / 127) }); midiNotes.push({ id: 'mn_' + t + '_' + p + '_' + pn.tick, pitch: p, start_beat: pn.tick / ticksPerBeat, duration_beats: dur / ticksPerBeat, velocity: Math.min(1, pn.vel / 127) });
}); });
if (midiNotes.length > 0) { if (midiNotes.length > 0) {
var lastEnd = 0; var lastEnd = 0; var maxEndBeat = 0;
midiNotes.forEach(function(n) { var e = (n.start_beat + n.duration_beats) * 60 / bpm; if (e > lastEnd) lastEnd = e; }); midiNotes.forEach(function(n) {
result.push({ name: trackName, notes: midiNotes, duration: lastEnd || 4, startTime: 0, id: 'midi_' + t + '_' + Date.now() }); var e = (n.start_beat + n.duration_beats) * 60 / bpm;
if (e > lastEnd) lastEnd = e;
var eb = n.start_beat + n.duration_beats;
if (eb > maxEndBeat) maxEndBeat = eb;
});
result.push({
name: trackName, notes: midiNotes, duration: lastEnd || 4, startTime: 0,
id: 'midi_' + t + '_' + Date.now(),
totalBeats: maxEndBeat || 16,
bars: Math.max(1, Math.ceil((maxEndBeat || 16) / 4)),
bpm: bpm,
ticksPerBeat: ticksPerBeat
});
} }
pos = endPos; pos = endPos;
} }
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -8,6 +8,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script> <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script> <script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
@@ -23,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <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/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608021805" defer></script> <script src="/static/js/app.precompiled.js?v=202608021850" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+5
View File
@@ -1129,3 +1129,8 @@
- **Tóm tắt thay đổi:** (1) `drawCanvas` scroll khi content rộng hơn khung: pxPerBeat 42 / pxPerSec 50, playhead di chuyển tới giữa khung (`min(w/2, t*pxPerSec)`) rồi dừng, content scroll trái với offset `max(0, min(contentW-w, t*pxPerSec - w/2))`; ruler scroll đồng bộ. (2) Panel giờ **luôn mounted** (display:none khi ẩn) thay vì `showMediaExplorer &&` → toggle F6 giữ nguyên folder/files/tree/selection. (3) Session persistence `localStorage['studio_media_explorer_session_v1']` (folder, mode, path, roots, tree, files, selected — bỏ handle) + lưu `FileSystemDirectoryHandle` vào IndexedDB `sonicforge_media_explorer/root_handle` để client mode restore được handle thật sau reload; `restoreSession` + re-browse lại folder đã load → cây tree + file list hiển thị đúng thư mục cũ, tên thư mục loaded luôn hiện trong tree. - **Tóm tắt thay đổi:** (1) `drawCanvas` scroll khi content rộng hơn khung: pxPerBeat 42 / pxPerSec 50, playhead di chuyển tới giữa khung (`min(w/2, t*pxPerSec)`) rồi dừng, content scroll trái với offset `max(0, min(contentW-w, t*pxPerSec - w/2))`; ruler scroll đồng bộ. (2) Panel giờ **luôn mounted** (display:none khi ẩn) thay vì `showMediaExplorer &&` → toggle F6 giữ nguyên folder/files/tree/selection. (3) Session persistence `localStorage['studio_media_explorer_session_v1']` (folder, mode, path, roots, tree, files, selected — bỏ handle) + lưu `FileSystemDirectoryHandle` vào IndexedDB `sonicforge_media_explorer/root_handle` để client mode restore được handle thật sau reload; `restoreSession` + re-browse lại folder đã load → cây tree + file list hiển thị đúng thư mục cũ, tên thư mục loaded luôn hiện trong tree.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html` - **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: toggle F6 off/on → tree+files+selection giữ nguyên; reload (fresh DOM + IndexedDB stub) → folder MyMusic, files a.mid/b.mid, selection restore. Scroll math: t=10s → offset 350, playhead 150 (center), t=29s clamp 1200. Hard reload. - **Ghi chú/Test (nếu có):** Smoke jsdom: toggle F6 off/on → tree+files+selection giữ nguyên; reload (fresh DOM + IndexedDB stub) → folder MyMusic, files a.mid/b.mid, selection restore. Scroll math: t=10s → offset 350, playhead 150 (center), t=29s clamp 1200. Hard reload.
### [2026-08-02 18:50] Task: Media Explorer — bars MIDI, synth realtime, loop, icons, lưu folder path
- **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.