feat: bars MIDI đúng, synth realtime, loop preview, icons FA, lưu folder path
This commit is contained in:
+144
-22
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user