feat: bars MIDI đúng, synth realtime, loop preview, icons FA, lưu folder path
This commit is contained in:
+147
-25
@@ -9027,6 +9027,9 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const [audioBuffer, setAudioBuffer] = React.useState(null);
|
||||
const [midiNotes, setMidiNotes] = React.useState(null);
|
||||
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() {
|
||||
var saved = localStorage.getItem('studio_media_explorer_tempo');
|
||||
return saved ? parseInt(saved) : 120;
|
||||
@@ -9038,6 +9041,9 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const audioBufferRef = React.useRef(null);
|
||||
const midiNotesRef = React.useRef(null);
|
||||
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 isPausedRef = React.useRef(false);
|
||||
const folderRef = React.useRef('library');
|
||||
@@ -9048,6 +9054,9 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
audioBufferRef.current = audioBuffer;
|
||||
midiNotesRef.current = midiNotes;
|
||||
midiTotalRef.current = midiTotal;
|
||||
midiBarsRef.current = midiBars;
|
||||
midiTotalBeatsRef.current = midiTotalBeats;
|
||||
midiFileBpmRef.current = midiFileBpm;
|
||||
isPlayingRef.current = isPlaying;
|
||||
isPausedRef.current = isPaused;
|
||||
folderRef.current = folder;
|
||||
@@ -9068,6 +9077,8 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const [synthLoading, setSynthLoading] = React.useState(false);
|
||||
const synthListRef = React.useRef(null);
|
||||
synthListRef.current = synthList;
|
||||
const synthInstRef = React.useRef(null);
|
||||
synthInstRef.current = synthInst;
|
||||
const [treeWidth, setTreeWidth] = React.useState(function() {
|
||||
var saved = localStorage.getItem('studio_media_explorer_tree_width');
|
||||
return saved ? parseInt(saved) : 176;
|
||||
@@ -9094,6 +9105,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const canvasRef = React.useRef(null);
|
||||
const playStateRef = React.useRef(null);
|
||||
const rafRef = React.useRef(null);
|
||||
const loopTimerRef = React.useRef(null);
|
||||
const selectTokenRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -9302,6 +9314,36 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
|
||||
const openMyComputer = async () => {
|
||||
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) {
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker({ mode: 'read' });
|
||||
@@ -9470,8 +9512,17 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
|
||||
const selectSynthInst = (inst) => {
|
||||
setSynthInst(inst);
|
||||
synthInstRef.current = inst;
|
||||
setSynthOpen(false);
|
||||
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) => {
|
||||
@@ -9484,31 +9535,54 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
|
||||
if (!midiResult || !midiResult.length) return;
|
||||
const ctx = getAudioContext();
|
||||
const bpmVal = tempoRef.current || parseInt(synthInst && synthInst.bpm) || 120;
|
||||
const bpmVal = tempoRef.current || 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) {
|
||||
const curInst = synthInstRef.current;
|
||||
const program = curInst ? curInst.program : undefined;
|
||||
const sfId = curInst ? curInst.sfId : undefined;
|
||||
const bank = curInst ? curInst.bank : 0;
|
||||
if (curInst && window.SonicSF.selectInstrument) {
|
||||
try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {}
|
||||
}
|
||||
// Re-check token after the async await — stale playMidiPreview (older file)
|
||||
// must not schedule notes over the newly selected file.
|
||||
if (selectTokenRef.current !== (token || selectTokenRef.current)) return;
|
||||
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;
|
||||
const prog = (curInst && curInst.program !== undefined) ? curInst.program : 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 => {
|
||||
(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);
|
||||
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);
|
||||
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
|
||||
playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: midiResult[0].duration || 4 };
|
||||
setMidiNotes(midiResult[0].notes || []);
|
||||
setMidiTotal(midiResult[0].duration || 4);
|
||||
playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: totalSec };
|
||||
setMidiNotes(allNotes);
|
||||
setMidiTotal(totalSec);
|
||||
setMidiBars(midiResult[0].bars || 1);
|
||||
setMidiTotalBeats(midiResult[0].totalBeats || 16);
|
||||
setMidiFileBpm(midiResult[0].bpm || 120);
|
||||
setIsPlaying(true);
|
||||
setIsPaused(false);
|
||||
startCanvasClock();
|
||||
@@ -9523,6 +9597,7 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
}
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; }
|
||||
if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') {
|
||||
try { window.SonicSF.stopAll(); } catch (e) {}
|
||||
}
|
||||
@@ -9616,6 +9691,8 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const curAudioBuffer = audioBufferRef.current;
|
||||
const curMidiNotes = midiNotesRef.current;
|
||||
const curMidiTotal = midiTotalRef.current;
|
||||
const curMidiBars = midiBarsRef.current;
|
||||
const curMidiTotalBeats = midiTotalBeatsRef.current;
|
||||
const curTempo = tempoRef.current;
|
||||
const playing = isPlayingRef.current;
|
||||
if (!f) {
|
||||
@@ -9631,14 +9708,17 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
// Content scrolls when wider than frame: playhead stays at frame center
|
||||
const bpmV = curTempo || 120;
|
||||
const pxPerBeat = 42;
|
||||
const totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * bpmV / 60, 4) : (f.lengthQn || 16);
|
||||
const beats = totalBeats;
|
||||
const totalBeats = (curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0)
|
||||
? curMidiTotalBeats
|
||||
: Math.max(curMidiTotal * bpmV / 60, 4);
|
||||
const beats = Math.max(totalBeats, 4);
|
||||
const contentW = Math.max(w, beats * pxPerBeat);
|
||||
const pxPerSec = pxPerBeat * bpmV / 60;
|
||||
let offset = 0;
|
||||
if (playing && contentW > w && dur > 0) {
|
||||
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) {
|
||||
const x = b * pxPerBeat - offset;
|
||||
if (x < -10 || x > w + 10) continue;
|
||||
@@ -9702,9 +9782,11 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
|
||||
const rulerBpm = curTempo || 120;
|
||||
const rulerPxPerBeat = isMidiFile(f) ? 42 : (50 * 60 / rulerBpm);
|
||||
const rulerContentW = Math.max(w, dur * rulerPxPerBeat * rulerBpm / 60);
|
||||
const rulerTotalBeats = 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 isRealMidi = isMidiFile(f) && curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
|
||||
const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
|
||||
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) {
|
||||
const x = b * rulerPxPerBeat - rulerOffset;
|
||||
if (x < -20 || x > w + 20) continue;
|
||||
@@ -9755,6 +9837,16 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
setIsLooping(prev => {
|
||||
const next = !prev;
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -9972,9 +10064,20 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<div>Size: {selected.size_mb != null ? selected.size_mb.toFixed(2) + ' MB' : '-'}</div>
|
||||
<div>Duration: {selDur.toFixed(2)}s</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>
|
||||
{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>Sample Rate: 44100 Hz</div>
|
||||
<div>Type: {selected.path ? (selected.kind === 'other' ? 'Local File' : 'Local Audio') : (selected.type || 'Audio')}</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
) : <div>No file selected</div>}
|
||||
@@ -9984,7 +10087,14 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
{/* 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="flex items-center gap-3">
|
||||
<div className="bg-white border border-[#808080] px-1.5 text-slate-900 font-bold">{formatTime(currentTime)} / {formatTime(selDur)}</div>
|
||||
{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>
|
||||
<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>
|
||||
@@ -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) });
|
||||
});
|
||||
if (midiNotes.length > 0) {
|
||||
var lastEnd = 0;
|
||||
midiNotes.forEach(function(n) { var e = (n.start_beat + n.duration_beats) * 60 / bpm; if (e > lastEnd) lastEnd = e; });
|
||||
result.push({ name: trackName, notes: midiNotes, duration: lastEnd || 4, startTime: 0, id: 'midi_' + t + '_' + Date.now() });
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user