FEAT: Media Explorer preview loop liên tục vùng chọn + margin 2px canvas
This commit is contained in:
+141
-20
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user