diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index 3ff2bd1..e49775a 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -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 (
-
+
{/* 1. TOP NAVIGATION TOOLBAR */}
@@ -10646,7 +10767,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
{/* CANVAS + METADATA */}
-
+