FEAT: Media Explorer preview loop liên tục vùng chọn + margin 2px canvas

This commit is contained in:
2026-08-03 07:56:19 +07:00
parent adccf6430a
commit 4d10b9485b
3 changed files with 167 additions and 35 deletions
+141 -20
View File
@@ -9044,6 +9044,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
const [selEnd, setSelEnd] = React.useState(null);
const [isDragging, setIsDragging] = React.useState(false);
const [previewCtxMenu, setPreviewCtxMenu] = React.useState(null); // { x, y }
const containerRef = React.useRef(null);
// Refs mirror latest state so drawCanvas (also called from rAF clock with a
// stale closure) always draws the currently selected file, not the old one.
const selectedRef = React.useRef(null);
@@ -9060,6 +9061,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
const folderRef = React.useRef('library');
const tempoRef = React.useRef(120);
const currentTimeRef = React.useRef(0);
const selStartRef = React.useRef(null);
const selEndRef = React.useRef(null);
const isLoopingRef = React.useRef(false);
selectedRef.current = selected;
peaksRef.current = peaks;
audioBufferRef.current = audioBuffer;
@@ -9074,13 +9078,26 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
folderRef.current = folder;
tempoRef.current = tempo;
currentTimeRef.current = currentTime;
selStartRef.current = selStart;
selEndRef.current = selEnd;
isLoopingRef.current = isLooping;
React.useEffect(() => {
setScrollOffset(0);
}, [selected]);
React.useEffect(() => {
window.mediaExplorerActive = true;
const handleDocumentClick = (e) => {
if (containerRef.current && containerRef.current.contains(e.target)) {
window.mediaExplorerActive = true;
} else {
window.mediaExplorerActive = false;
}
};
document.addEventListener('mousedown', handleDocumentClick, { capture: true });
return () => {
document.removeEventListener('mousedown', handleDocumentClick, { capture: true });
window.mediaExplorerActive = false;
};
}, []);
@@ -9324,6 +9341,19 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
}
}
}
if (e.key === 'c' && (e.ctrlKey || e.metaKey)) {
if (window.mediaExplorerActive) {
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
if (selStartRef.current !== null && selEndRef.current !== null && Math.abs(selStartRef.current - selEndRef.current) > 0.01) {
e.preventDefault();
e.stopPropagation();
handleCopySelection();
}
}
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => {
@@ -9968,6 +9998,14 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
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 totalBeats = midiResult[0].totalBeats || 16;
const hasSelection = selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01;
const loopStartBeats = hasSelection ? Math.min(selStart, selEnd) : 0;
const loopEndBeats = hasSelection ? Math.max(selStart, selEnd) : totalBeats;
const loopDurationBeats = loopEndBeats - loopStartBeats;
const loopDurationSec = loopDurationBeats * secondsPerBeat;
const loopStartSec = loopStartBeats * secondsPerBeat;
const allNotes = [];
midiResult.forEach(track => {
(track.notes || []).forEach(note => {
@@ -9976,25 +10014,32 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
});
const schedulePass = (passStartTime) => {
allNotes.forEach(note => {
const startSec = (note.start_beat || 0) * secondsPerBeat + (note.trackOffset || 0);
const noteStartBeat = note.start_beat || 0;
if (hasSelection) {
if (noteStartBeat < loopStartBeats || noteStartBeat >= loopEndBeats) return;
}
const shiftedStartBeat = hasSelection ? (noteStartBeat - loopStartBeats) : noteStartBeat;
const startSec = shiftedStartBeat * 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
// Loop scheduling: keep looping continuously until Stop is pressed.
if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; }
if (isLooping) {
if (isLoopingRef.current) {
loopTimerRef.current = setInterval(() => {
if (selectTokenRef.current !== (token || selectTokenRef.current)) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; }
if (!isPlayingRef.current || isPausedRef.current) return;
if (!isLoopingRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; }
const passStart = ctx.currentTime + 0.05;
schedulePass(passStart);
playStateRef.current = Object.assign({}, playStateRef.current, { startedAt: passStart, fakeStart: passStart });
}, Math.max(200, totalSec * 1000));
}, Math.max(200, loopDurationSec * 1000));
}
// Keep a fake clock so canvas playhead animates; loop uses playStateRef
playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: totalSec };
const startedAtTime = startWallTime - loopStartSec;
playStateRef.current = { source: null, ctx, startedAt: startedAtTime, fakeStart: startedAtTime, midiTotal: totalSec };
setMidiNotes(allNotes);
setMidiTotal(totalSec);
setMidiBars(midiResult[0].bars || 1);
@@ -10049,15 +10094,31 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
setAudioDuration(decoded.duration);
const src = ctx.createBufferSource();
src.buffer = decoded;
src.loop = isLooping;
const sStart = selStartRef.current;
const sEnd = selEndRef.current;
const hasSelection = sStart !== null && sEnd !== null && Math.abs(sStart - sEnd) > 0.01;
let startOffset = 0;
if (hasSelection) {
startOffset = Math.min(sStart, sEnd);
}
src.loop = isLoopingRef.current;
if (isLoopingRef.current) {
if (hasSelection) {
src.loopStart = Math.min(sStart, sEnd);
src.loopEnd = Math.max(sStart, sEnd);
} else {
src.loopStart = 0;
src.loopEnd = decoded.duration;
}
}
src.playbackRate.value = rate;
const gain = ctx.createGain();
const linear = volumeDb <= -50 ? 0 : Math.pow(10, volumeDb / 20);
gain.gain.value = linear;
src.connect(gain);
gain.connect(ctx.destination);
src.start();
const startedAt = ctx.currentTime;
src.start(0, startOffset);
const startedAt = ctx.currentTime - startOffset;
playStateRef.current = { source: src, ctx, startedAt };
setIsPlaying(true);
setIsPaused(false);
@@ -10084,7 +10145,42 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
let t = currentTimeRef.current;
const st = playStateRef.current;
if (st && !isPausedRef.current) {
t = st.ctx ? st.ctx.currentTime - st.startedAt : performance.now() / 1000 - st.fakeStart;
const rawElapsed = st.ctx ? st.ctx.currentTime - st.startedAt : performance.now() / 1000 - st.fakeStart;
const f = selectedRef.current;
const dur = fileDuration(f);
const isMidi = isMidiFile(f);
const bpmVal = tempoRef.current || 120;
const secondsPerBeat = 60.0 / bpmVal;
const sStart = selStartRef.current;
const sEnd = selEndRef.current;
const hasSelection = sStart !== null && sEnd !== null && Math.abs(sStart - sEnd) > 0.01;
// Loop plays continuously (forever) until Stop is pressed.
if (isLoopingRef.current) {
if (isMidi) {
const totalBeats = midiTotalBeatsRef.current || 16;
const loopStartBeats = hasSelection ? Math.min(sStart, sEnd) : 0;
const loopEndBeats = hasSelection ? Math.max(sStart, sEnd) : totalBeats;
const loopDurationBeats = loopEndBeats - loopStartBeats;
const loopDurationSec = loopDurationBeats * secondsPerBeat;
const loopStartSec = loopStartBeats * secondsPerBeat;
t = loopStartSec + Math.max(0, (rawElapsed - loopStartSec) % Math.max(0.1, loopDurationSec));
} else {
const loopStartSec = hasSelection ? Math.min(sStart, sEnd) : 0;
const loopEndSec = hasSelection ? Math.max(sStart, sEnd) : dur;
const loopDurationSec = loopEndSec - loopStartSec;
t = loopStartSec + Math.max(0, (rawElapsed - loopStartSec) % Math.max(0.1, loopDurationSec));
}
} else {
t = rawElapsed;
const endLimit = hasSelection ? (isMidi ? (Math.max(sStart, sEnd) * secondsPerBeat) : Math.max(sStart, sEnd)) : dur;
if (t >= endLimit) {
t = endLimit;
stopMediaPlayback();
}
}
}
setCurrentTime(t);
currentTimeRef.current = t;
@@ -10120,6 +10216,11 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
}
const dur = fileDuration(f);
// Read selection from refs so rAF-driven redraws always show current selection
const drawSelStart = selStartRef.current;
const drawSelEnd = selEndRef.current;
const drawHasSel = drawSelStart !== null && drawSelEnd !== null && Math.abs(drawSelStart - drawSelEnd) > 0.01;
// Scale layout parameters using zoom
const pxPerBeat = 42 * zoom;
const pxPerSec = pxPerBeat * (curTempo || 120) / 60;
@@ -10170,9 +10271,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
}
// Draw selection overlay
if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
const startVal = Math.min(selStart, selEnd);
const endVal = Math.max(selStart, selEnd);
if (drawHasSel) {
const startVal = Math.min(drawSelStart, drawSelEnd);
const endVal = Math.max(drawSelStart, drawSelEnd);
const xStart = startVal * pxPerBeat - offset;
const xEnd = endVal * pxPerBeat - offset;
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
@@ -10212,9 +10313,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
}
// Draw selection overlay
if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
const startVal = Math.min(selStart, selEnd);
const endVal = Math.max(selStart, selEnd);
if (drawHasSel) {
const startVal = Math.min(drawSelStart, drawSelEnd);
const endVal = Math.max(drawSelStart, drawSelEnd);
const xStart = startVal * pxPerSec - offset;
const xEnd = endVal * pxPerSec - offset;
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
@@ -10336,10 +10437,30 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
const toggleLoop = () => {
setIsLooping(prev => {
const next = !prev;
if (playStateRef.current && playStateRef.current.source) playStateRef.current.source.loop = next;
if (next && isMidiFile(selectedRef.current)) {
// Sync ref immediately so playMidiPreview (called below) sees the new value
isLoopingRef.current = next;
const cur = selectedRef.current;
const st = playStateRef.current;
const sStart = selStartRef.current;
const sEnd = selEndRef.current;
const hasSelection = sStart !== null && sEnd !== null && Math.abs(sStart - sEnd) > 0.01;
if (st && st.source) {
st.source.loop = next;
// When enabling loop for a currently playing audio buffer, also update
// the loop points to the current selection so it loops continuously
// over the selected region until Stop is pressed.
if (next && st.source.buffer) {
if (hasSelection) {
st.source.loopStart = Math.min(sStart, sEnd);
st.source.loopEnd = Math.max(sStart, sEnd);
} else {
st.source.loopStart = 0;
st.source.loopEnd = st.source.buffer.duration;
}
}
}
if (next && isMidiFile(cur)) {
// 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;
@@ -10359,7 +10480,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
const selBpm = (selected && selected.bpm) || tempo;
return (
<div className="flex flex-col w-full h-full text-slate-900 overflow-hidden select-none" style={{ fontFamily: "'Inter', sans-serif" }}>
<div ref={containerRef} className="flex flex-col w-full h-full text-slate-900 overflow-hidden select-none" style={{ fontFamily: "'Inter', sans-serif" }}>
{/* 1. TOP NAVIGATION TOOLBAR */}
<div className="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
<div className="flex items-center gap-1 flex-1 mr-2">
@@ -10646,7 +10767,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
{/* CANVAS + METADATA */}
<div className="flex items-stretch gap-2 my-1 flex-1 min-h-0">
<div className="flex-1 bg-[#181818] border border-[#3a3a3a] relative overflow-hidden">
<div className="flex-1 bg-[#181818] border border-[#3a3a3a] relative overflow-hidden p-0.5">
<canvas
ref={canvasRef}
className="w-full h-full block cursor-pointer"