feat: Media Explorer panel (F6) với mutual exclusion Mixer (F7)

This commit is contained in:
2026-07-31 11:59:20 +07:00
parent 548ab1258f
commit fdaabe3a08
4 changed files with 554 additions and 58 deletions
+488 -35
View File
@@ -6090,6 +6090,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
e.stopPropagation(); e.stopPropagation();
var toggleMixer = window.__toggleMixerRef; var toggleMixer = window.__toggleMixerRef;
if (toggleMixer) toggleMixer(); if (toggleMixer) toggleMixer();
} else if (e.key === 'F6') {
e.preventDefault();
e.stopPropagation();
var toggleMediaExplorer = window.__toggleMediaExplorerRef;
if (toggleMediaExplorer) toggleMediaExplorer();
} }
}; };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
@@ -8994,6 +8999,406 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
); );
}; };
// Media Explorer Panel (from md/51_MEDIA_EXPLORER.md)
const MEDIA_LIBRARY_SAMPLES = [
{ name: "MIDI_Loop_01.mid", events: 95, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" },
{ name: "MIDI_Loop_02_Bass.mid", events: 48, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" },
{ name: "MIDI_Loop_03_Lead.mid", events: 110, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" },
{ name: "MIDI_Loop_04.mid", events: 76, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" },
{ name: "MIDI_Loop_05_Bass.mid", events: 52, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" },
{ name: "MIDI_Loop_06.mid", events: 88, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" }
];
const MediaExplorerPanel = ({ height }) => {
const [userFiles, setUserFiles] = React.useState([]);
const [folder, setFolder] = React.useState('library');
const [selected, setSelected] = React.useState(null);
const [filterText, setFilterText] = React.useState('');
const [viewMode, setViewMode] = React.useState('details');
const [isPlaying, setIsPlaying] = React.useState(false);
const [isPaused, setIsPaused] = React.useState(false);
const [isLooping, setIsLooping] = React.useState(false);
const [autoPlay, setAutoPlay] = React.useState(true);
const [pitch, setPitch] = React.useState(0.0);
const [rate, setRate] = React.useState(1.0);
const [volumeDb, setVolumeDb] = React.useState(0.0);
const [currentTime, setCurrentTime] = React.useState(0);
const [peaks, setPeaks] = React.useState(null);
const [audioBuffer, setAudioBuffer] = React.useState(null);
const canvasRef = React.useRef(null);
const playStateRef = React.useRef(null);
const rafRef = React.useRef(null);
React.useEffect(() => {
if (window.SonicAPI && window.SonicAPI.listMyFiles) {
window.SonicAPI.listMyFiles([]).then(data => setUserFiles(data || [])).catch(() => {});
}
return () => { stopMediaPlayback(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isMidiFile = f => f && (f.kind === 'midi' || /\.(mid|midi)$/i.test(f.name || f.original_name || ''));
const fileDuration = f => {
if (!f) return 0;
if (isMidiFile(f)) return (f.lengthQn || 16) * 60 / (f.bpm || 120);
return f.duration || (audioBuffer && selected && (selected.file_id || selected.fileId) === (f.file_id || f.fileId) ? audioBuffer.duration : 0) || 0;
};
const folderFiles = React.useMemo(() => {
if (folder === 'library') return MEDIA_LIBRARY_SAMPLES;
return userFiles.filter(f => (folder === 'uploads' ? (f.type || 'Upload') === 'Upload' : (f.type || 'Processed') === 'Processed'));
}, [folder, userFiles]);
const visibleFiles = React.useMemo(() => {
const q = filterText.toLowerCase().trim();
if (!q) return folderFiles;
return folderFiles.filter(f => (f.name || f.original_name || '').toLowerCase().includes(q));
}, [folderFiles, filterText]);
const loadWaveform = async (f) => {
const fid = f.file_id || f.fileId;
if (!fid) { setPeaks(null); return; }
try {
const resp = await fetch(`${API_BASE_URL}/api/v1/audio/waveform/${fid}?num_peaks=600`);
const data = await resp.json();
setPeaks(data.peaks || []);
} catch (e) { setPeaks(null); }
};
const stopMediaPlayback = React.useCallback(() => {
if (playStateRef.current) {
try { if (playStateRef.current.source) playStateRef.current.source.stop(); } catch (e) {}
try { if (playStateRef.current.source) playStateRef.current.source.disconnect(); } catch (e) {}
playStateRef.current = null;
}
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
setIsPlaying(false);
setIsPaused(false);
}, []);
const playSelected = async (f) => {
if (!f) return;
stopMediaPlayback();
if (isMidiFile(f)) {
playStateRef.current = { source: null, ctx: null, fakeStart: performance.now() / 1000 };
setIsPlaying(true);
setIsPaused(false);
startCanvasClock();
return;
}
const fid = f.file_id || f.fileId;
if (!fid) return;
try {
const ctx = getAudioContext();
const resp = await fetch(`${API_BASE_URL}/api/v1/audio/download/${fid}`);
const buf = await resp.arrayBuffer();
const decoded = await ctx.decodeAudioData(buf);
setAudioBuffer(decoded);
const src = ctx.createBufferSource();
src.buffer = decoded;
src.loop = isLooping;
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;
playStateRef.current = { source: src, ctx, startedAt };
setIsPlaying(true);
setIsPaused(false);
startCanvasClock();
} catch (e) { console.error('Preview failed', e); }
};
const togglePause = () => {
const st = playStateRef.current;
if (!st) return;
if (isPaused) {
if (st.ctx) st.ctx.resume();
setIsPaused(false);
} else {
if (st.ctx) st.ctx.suspend();
setIsPaused(true);
}
};
const startCanvasClock = () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const tick = () => {
rafRef.current = requestAnimationFrame(tick);
let t = currentTime;
const st = playStateRef.current;
if (st && !isPaused) {
t = st.ctx ? st.ctx.currentTime - st.startedAt : performance.now() / 1000 - st.fakeStart;
}
setCurrentTime(t);
drawCanvas(t);
};
tick();
};
const drawCanvas = (t) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
canvas.width = w * 2; canvas.height = h * 2;
ctx.scale(2, 2);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#181818'; ctx.fillRect(0, 0, w, h);
const f = selected;
if (!f) {
ctx.fillStyle = '#555'; ctx.font = '12px JetBrains Mono, monospace';
ctx.fillText('No file selected', 10, h / 2);
return;
}
const dur = fileDuration(f);
if (isMidiFile(f)) {
ctx.strokeStyle = '#333';
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
ctx.strokeStyle = '#444';
const beats = (f.lengthQn || 16);
for (let b = 0; b <= beats; b += 4) {
const x = (b / beats) * w;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
}
ctx.fillStyle = '#9ca3af';
const events = f.events || 76;
for (let i = 0; i < events; i++) {
const nx = ((i * 17 + 76) % 95) / 100 * (w - 20);
const ny = ((i * 13 + 76) % (h - 30)) + 4;
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
}
} else {
const pk = peaks && peaks.length > 0 ? peaks : null;
if (pk) {
const mid = h / 2;
ctx.fillStyle = '#22c55e';
for (let i = 0; i < pk.length; i++) {
const x = (i / pk.length) * w;
const ph = Math.max(2, pk[i] * (h / 2 - 4));
ctx.fillRect(x, mid - ph, Math.max(1, w / pk.length), ph * 2);
}
} else {
ctx.fillStyle = '#666'; ctx.font = '11px monospace';
ctx.fillText('Waveform unavailable', 10, h / 2);
}
}
// ruler
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
const totalBeats = Math.max(4, Math.ceil(dur * (f.bpm || 120) / 60) || 16);
for (let b = 0; b <= totalBeats; b += 4) {
const x = (b / totalBeats) * w;
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
}
// playhead
if (isPlaying && dur > 0) {
const px = (t / dur) * w;
ctx.fillStyle = '#ef4444';
ctx.fillRect(px, 0, 2, h - 14);
}
};
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, selected, folder, isPlaying]);
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
const handleSelect = (f) => {
setSelected(f);
setCurrentTime(0);
if (autoPlay) { playSelected(f); } else { stopMediaPlayback(); }
if (!isMidiFile(f) && (f.file_id || f.fileId)) loadWaveform(f);
};
const toggleLoop = () => {
setIsLooping(prev => {
const next = !prev;
if (playStateRef.current && playStateRef.current.source) playStateRef.current.source.loop = next;
return next;
});
};
const toggleRate = (dir) => setRate(prev => Math.max(0.25, Math.min(4.0, Math.round((prev + dir * 0.1) * 100) / 100)));
const togglePitch = (dir) => setPitch(prev => Math.max(-24, Math.min(24, prev + dir * 0.5)));
const selIsMidi = isMidiFile(selected);
const selDur = fileDuration(selected);
const selBpm = selected && (selected.bpm || 120);
return (
<div 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">
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back" onClick={() => setFolder('library')}>
<i className="fa-solid fa-arrow-left"></i>
</button>
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Forward" onClick={() => setFolder('uploads')}>
<i className="fa-solid fa-arrow-right"></i>
</button>
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory" onClick={() => setFolder('library')}>
<i className="fa-solid fa-arrow-up"></i>
</button>
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh" onClick={() => { if (window.SonicAPI && window.SonicAPI.listMyFiles) window.SonicAPI.listMyFiles([]).then(d => setUserFiles(d || [])); }}>
<i className="fa-solid fa-rotate-right"></i>
</button>
<div className="flex-1 flex items-center bg-white border border-[#808080] h-5 px-1">
<i className="fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"></i>
<span className="flex-1 text-xs text-slate-800 truncate">Root:\{folder === 'library' ? 'Media Library' : folder === 'uploads' ? 'Uploads' : 'Processed'}</span>
<i className="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center bg-white border border-[#808080] h-5 px-1 w-40">
<input type="text" placeholder="Filter/Search..." value={filterText} onChange={e => setFilterText(e.target.value)} className="w-full text-xs outline-none bg-transparent font-sans text-slate-800" />
<i className="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
</div>
<button className="px-2 py-0.5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold" onClick={() => setViewMode(viewMode === 'details' ? 'list' : 'details')}>
<span>{viewMode === 'details' ? 'Details' : 'List'}</span> <i className="fa-solid fa-caret-down text-[9px]"></i>
</button>
</div>
</div>
{/* 2. MIDDLE SPLIT VIEW */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* DIRECTORY TREE */}
<div className="w-44 bg-white border border-[#808080] m-1 mr-0 overflow-y-auto p-1 text-xs select-none shrink-0">
<div className="space-y-0.5 font-sans">
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Track Templates&gt;</div>
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Project Directory&gt;</div>
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><i className="fa-solid fa-plus text-[9px] text-slate-500"></i> My Computer</div>
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer font-semibold rounded-sm ${folder === 'library' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => setFolder('library')}>
<i className="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
</div>
<div className="pl-3 space-y-0.5">
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer pl-4 rounded-sm ${folder === 'uploads' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => setFolder('uploads')}>
<i className="fa-solid fa-folder text-[#d9a752]"></i> Uploads
</div>
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer pl-4 rounded-sm ${folder === 'processed' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => setFolder('processed')}>
<i className="fa-solid fa-folder text-[#d9a752]"></i> Processed
</div>
</div>
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><i className="fa-solid fa-plus text-[9px] text-slate-500"></i> Desktop</div>
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><i className="fa-solid fa-plus text-[9px] text-slate-500"></i> My Documents</div>
</div>
</div>
{/* FILE LIST */}
<div className="flex-1 bg-white border border-[#808080] m-1 overflow-y-auto relative">
<table className="w-full text-xs text-left border-collapse">
<thead className="sticky top-0 bg-[#e0e0e0] border-b border-[#a0a0a0] text-slate-800 font-semibold select-none shadow-sm z-10">
<tr>
<th className="py-1 px-2 border-r border-[#b0b0b0]">File</th>
{viewMode === 'details' && <th className="py-1 px-2 border-r border-[#b0b0b0]">Size</th>}
</tr>
</thead>
<tbody className="font-sans text-slate-800">
{visibleFiles.map((f, i) => {
const isSel = selected && (selected.name || selected.file_id) === (f.name || f.file_id);
const isMidi = isMidiFile(f);
return (
<tr key={(f.file_id || f.name) + i} className={`cursor-pointer hover:bg-blue-100 ${isSel ? 'file-row-selected' : ''}`} onClick={() => handleSelect(f)}>
<td className="py-1 px-2"><i className={`fa-solid ${isMidi ? 'fa-music text-purple-600' : 'fa-file-audio text-emerald-600'} mr-2`}></i>{f.name || f.original_name}</td>
{viewMode === 'details' && <td className="py-1 px-2">{f.size_mb != null ? f.size_mb.toFixed(2) + ' MB' : (isMidi ? f.tpqn + ' TPQN' : '-')}</td>}
</tr>
);
})}
{visibleFiles.length === 0 && (
<tr><td className="py-3 px-2 text-slate-400 italic" colSpan={viewMode === 'details' ? 2 : 1}>No files</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* 3. BOTTOM PREVIEW & TRANSPORT */}
<div className="h-[38%] min-h-[110px] bg-[#d4d0c8] border-t border-[#808080] p-1.5 flex flex-col justify-between text-xs shrink-0 select-none">
{/* CONTROLS ROW */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<button id="btnStop" className="w-6 h-6 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-slate-800" title="Stop" onClick={stopMediaPlayback}>
<i className="fa-solid fa-square text-[10px]"></i>
</button>
<button id="btnPlay" className="w-6 h-6 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-emerald-700 font-bold" title="Play" onClick={() => isPlaying ? togglePause() : playSelected(selected)}>
<i className={`fa-solid ${isPlaying && !isPaused ? 'fa-play' : 'fa-play'} text-xs`}></i>
</button>
<button id="btnPause" className="w-6 h-6 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-amber-700" title="Pause" onClick={togglePause}>
<i className="fa-solid fa-pause text-xs"></i>
</button>
<button id="btnLoop" className={`w-6 h-6 border rounded-sm flex items-center justify-center text-xs ${isLooping ? 'bg-cyan-600 text-white border-cyan-700' : 'bg-[#e0e0e0] hover:bg-white text-slate-700 border-[#707070]'}`} title="Loop / Repeat" onClick={toggleLoop}>
<i className="fa-solid fa-rotate-right"></i>
</button>
<button id="btnAutoPlay" className={`h-6 px-2 border rounded-sm font-bold text-[10px] flex items-center gap-1 ${autoPlay ? 'bg-gradient-to-r from-cyan-600 to-emerald-600 text-white border-slate-700' : 'bg-[#e0e0e0] text-slate-600 border-[#707070]'}`} onClick={() => setAutoPlay(p => !p)}>
<i className="fa-solid fa-bolt text-[9px]"></i> <span>Auto-Play</span>
</button>
</div>
<div className="flex items-center gap-3 font-mono text-[11px]">
<div className="flex items-center gap-1">
<span>Pitch:</span>
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => togglePitch(-0.5)}>-</button>
<div className="bg-white border border-[#808080] px-1 h-5 flex items-center w-14"><input type="number" value={pitch.toFixed(1)} step="0.5" onChange={e => setPitch(parseFloat(e.target.value) || 0)} className="w-full text-xs text-right outline-none bg-transparent" /></div>
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => togglePitch(0.5)}>+</button>
</div>
<div className="flex items-center gap-1">
<span>Rate:</span>
<div className="bg-white border border-[#808080] px-1 h-5 flex items-center w-12"><input type="number" value={rate.toFixed(2)} step="0.1" onChange={e => setRate(Math.max(0.25, Math.min(4, parseFloat(e.target.value) || 1)))} className="w-full text-xs text-right outline-none bg-transparent" /></div>
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[9px]" onClick={() => toggleRate(-1)}>-</button>
<button className="w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[9px]" onClick={() => toggleRate(1)}>+</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-sans text-slate-700">Volume:</span>
<input type="range" min="-60" max="12" step="0.5" value={volumeDb} onChange={e => setVolumeDb(parseFloat(e.target.value))} className="me-fader-slider w-24" />
<div className="bg-white border border-[#808080] px-1 h-5 flex items-center justify-center w-14 font-mono text-[11px]">{volumeDb <= -50 ? '-inf' : volumeDb.toFixed(1)} dB</div>
</div>
<div className={`px-2 py-0.5 border font-mono font-bold text-[10px] rounded-sm ${selIsMidi ? 'bg-purple-950 text-purple-300 border-purple-800' : 'bg-emerald-950 text-emerald-300 border-emerald-800'}`}>
{selIsMidi ? 'MIDI' : 'Audio'}
</div>
</div>
{/* 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">
<canvas ref={canvasRef} className="w-full h-full block cursor-pointer"></canvas>
</div>
<div className="w-48 bg-[#181818] border border-[#3a3a3a] p-1.5 font-mono text-[10px] text-slate-300 leading-tight overflow-y-auto shrink-0">
{selected ? (
selIsMidi ? (
<React.Fragment>
<div>{selected.events} MIDI events</div>
<div>Length: {selected.lengthQn} quarter notes</div>
<div>Length: {selected.time} (est)</div>
<div>Ticks per quarter note: {selected.tpqn}</div>
</React.Fragment>
) : (
<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.type || 'Audio'}</div>
</React.Fragment>
)
) : <div>No file selected</div>}
</div>
</div>
{/* 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>
</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>
</div>
</div>
);
};
const App = () => { const App = () => {
// State Definitions // State Definitions
const [tracks, setTracks] = useState([{ const [tracks, setTracks] = useState([{
@@ -9540,11 +9945,31 @@ const App = () => {
const [showMixer, setShowMixer] = useState(false); const [showMixer, setShowMixer] = useState(false);
const setShowMixerRef = useRef(setShowMixer); const setShowMixerRef = useRef(setShowMixer);
setShowMixerRef.current = setShowMixer; setShowMixerRef.current = setShowMixer;
window.__toggleMixerRef = function() { setShowMixer(function(p) { return !p; }); }; const setShowMediaExplorerRef = useRef(setShowMediaExplorer);
setShowMediaExplorerRef.current = setShowMediaExplorer;
// F6/F7 mutual exclusion: enabling one panel disables the other
window.__toggleMixerRef = function() {
setShowMixer(function(p) {
const next = !p;
if (next) setShowMediaExplorerRef.current(false);
return next;
});
};
window.__toggleMediaExplorerRef = function() {
setShowMediaExplorer(function(p) {
const next = !p;
if (next) setShowMixerRef.current(false);
return next;
});
};
const [mixerHeight, setMixerHeight] = useState(function() { const [mixerHeight, setMixerHeight] = useState(function() {
var saved = localStorage.getItem('studio_mixer_height'); var saved = localStorage.getItem('studio_mixer_height');
return saved ? parseInt(saved) : 200; return saved ? parseInt(saved) : 200;
}()); }());
const [mediaExplorerPanelHeight, setMediaExplorerPanelHeight] = useState(function() {
var saved = localStorage.getItem('studio_media_explorer_height');
return saved ? parseInt(saved) : 240;
}());
const [masterVolume, setMasterVolume] = useState(0); // dB const [masterVolume, setMasterVolume] = useState(0); // dB
const [masterVU, setMasterVU] = useState(0); // 0-1 const [masterVU, setMasterVU] = useState(0); // 0-1
const [masterMeterPeak, setMasterMeterPeak] = useState(0); const [masterMeterPeak, setMasterMeterPeak] = useState(0);
@@ -11238,7 +11663,14 @@ const App = () => {
} }
if (e.key === 'F7') { if (e.key === 'F7') {
e.preventDefault(); e.preventDefault();
setShowMixerRef.current(p => !p); e.stopPropagation();
window.__toggleMixerRef();
return;
}
if (e.key === 'F6') {
e.preventDefault();
e.stopPropagation();
window.__toggleMediaExplorerRef();
return; return;
} }
}; };
@@ -18744,7 +19176,7 @@ const App = () => {
}, { }, {
label: 'Mixer', label: 'Mixer',
icon: 'sliders', icon: 'sliders',
action: () => setShowMixer(p => !p) action: () => window.__toggleMixerRef()
}, { }, {
label: 'Tempo Track', label: 'Tempo Track',
icon: 'timer', icon: 'timer',
@@ -18756,7 +19188,7 @@ const App = () => {
}, { }, {
label: 'Media Explorer', label: 'Media Explorer',
icon: 'folder-search', icon: 'folder-search',
action: () => showToast('Media explorer', 'info') action: () => window.__toggleMediaExplorerRef()
}] }]
}, { }, {
label: 'Tools', label: 'Tools',
@@ -19440,7 +19872,6 @@ const App = () => {
addPanel('ai', panelPositions.ai, showAIPanel); addPanel('ai', panelPositions.ai, showAIPanel);
addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel);
addPanel('selection', panelPositions.selection, showSelectionPanel); addPanel('selection', panelPositions.selection, showSelectionPanel);
addPanel('media_explorer', 'bottom', showMediaExplorer);
addPanel('fx_rack', panelPositions.fx_rack || 'bottom', showFxRack); addPanel('fx_rack', panelPositions.fx_rack || 'bottom', showFxRack);
addPanel('midi_events', panelPositions.midi_events || 'bottom', showMidiEvents); addPanel('midi_events', panelPositions.midi_events || 'bottom', showMidiEvents);
const closePanel = id => { const closePanel = id => {
@@ -19448,7 +19879,6 @@ const App = () => {
else if (id === 'ai') setShowAIPanel(false); else if (id === 'ai') setShowAIPanel(false);
else if (id === 'python_tools') setShowPythonToolsPanel(false); else if (id === 'python_tools') setShowPythonToolsPanel(false);
else if (id === 'selection') setShowSelectionPanel(false); else if (id === 'selection') setShowSelectionPanel(false);
else if (id === 'media_explorer') setShowMediaExplorer(false);
else if (id === 'fx_rack') setShowFxRack(false); else if (id === 'fx_rack') setShowFxRack(false);
else if (id === 'midi_events') setShowMidiEvents(false); else if (id === 'midi_events') setShowMidiEvents(false);
}; };
@@ -19933,34 +20363,6 @@ const App = () => {
}, "# Bars"), /*#__PURE__*/React.createElement("span", { }, "# Bars"), /*#__PURE__*/React.createElement("span", {
className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5" className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5"
}, numberBar)))); }, numberBar))));
if (panelId === 'media_explorer') return /*#__PURE__*/React.createElement("div", {
className: "flex flex-col h-full gap-1.5"
}, /*#__PURE__*/React.createElement("div", {
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
onMouseDown: e => startPanelDrag('media_explorer', e)
}, /*#__PURE__*/React.createElement("h3", {
className: "font-bold text-xs text-emerald-300 flex items-center gap-1"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "grip-vertical",
className: "w-3 h-3 text-zinc-500"
})), /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "folder-open",
className: "w-3.5 h-3.5 text-emerald-400"
})), " Media Explorer"), /*#__PURE__*/React.createElement("button", {
onClick: () => closePanel('media_explorer'),
className: "text-zinc-600 hover:text-zinc-300"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
})))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 overflow-y-auto text-xs text-zinc-400 font-mono p-2"
}, "// Placeholder: Media files browser"));
if (panelId === 'fx_rack') return /*#__PURE__*/React.createElement("div", { if (panelId === 'fx_rack') return /*#__PURE__*/React.createElement("div", {
className: "flex flex-col h-full gap-1.5" className: "flex flex-col h-full gap-1.5"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
@@ -21257,6 +21659,48 @@ const App = () => {
onUpdateTrack: updateTrackProp, onUpdateTrack: updateTrackProp,
trackVuRefs: trackVuRefs trackVuRefs: trackVuRefs
}); });
}))), showMediaExplorer && /*#__PURE__*/React.createElement("div", {
className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",
style: { height: mediaExplorerPanelHeight + 'px' }
}, /*#__PURE__*/React.createElement("div", {
className: "flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize",
onMouseDown: e => {
e.preventDefault();
var startY = e.clientY;
var startH = mediaExplorerPanelHeight;
var onMove = function(ev) {
var newH = Math.max(120, Math.min(520, startH - (ev.clientY - startY)));
setMediaExplorerPanelHeight(newH);
localStorage.setItem('studio_media_explorer_height', newH.toString());
};
var onUp = function() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
}, /*#__PURE__*/React.createElement("div", {
className: "flex items-center gap-2"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "grip-horizontal",
className: "w-3 h-3 text-zinc-600"
})), /*#__PURE__*/React.createElement("span", {
className: "text-[10px] font-bold text-emerald-400 uppercase tracking-wider"
}, "Media Explorer (F6)")), /*#__PURE__*/React.createElement("button", {
onClick: () => setShowMediaExplorer(false),
className: "p-0.5 rounded text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700 transition"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
})))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 min-h-0 overflow-hidden bg-[#262626]"
}, /*#__PURE__*/React.createElement(MediaExplorerPanel, {
height: mediaExplorerPanelHeight
})))); }))));
})(), /*#__PURE__*/React.createElement("div", { })(), /*#__PURE__*/React.createElement("div", {
className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0" className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0"
@@ -21334,7 +21778,7 @@ const App = () => {
})), /*#__PURE__*/React.createElement("span", { })), /*#__PURE__*/React.createElement("span", {
className: "text-[7px] opacity-60" className: "text-[7px] opacity-60"
}, showMidiEvents ? (panelPositions.midi_events || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { }, showMidiEvents ? (panelPositions.midi_events || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", {
onClick: () => setShowMixer(p => !p), onClick: () => window.__toggleMixerRef(),
className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMixer ? 'bg-indigo-900 text-indigo-300' : 'text-zinc-500 hover:text-zinc-300'}`, className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMixer ? 'bg-indigo-900 text-indigo-300' : 'text-zinc-500 hover:text-zinc-300'}`,
title: "Mixer Panel (F7)" title: "Mixer Panel (F7)"
}, /*#__PURE__*/React.createElement("span", { }, /*#__PURE__*/React.createElement("span", {
@@ -21342,6 +21786,15 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", { }, /*#__PURE__*/React.createElement("i", {
"data-lucide": "sliders-horizontal", "data-lucide": "sliders-horizontal",
className: "w-3 h-3" className: "w-3 h-3"
}))), /*#__PURE__*/React.createElement("button", {
onClick: () => window.__toggleMediaExplorerRef(),
className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMediaExplorer ? 'bg-emerald-900 text-emerald-300' : 'text-zinc-500 hover:text-zinc-300'}`,
title: "Media Explorer Panel (F6)"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "folder-open",
className: "w-3 h-3"
}))), /*#__PURE__*/React.createElement("span", { }))), /*#__PURE__*/React.createElement("span", {
className: "w-[1px] h-3 bg-zinc-800 mx-1" className: "w-[1px] h-3 bg-zinc-800 mx-1"
}), /*#__PURE__*/React.createElement("span", { }), /*#__PURE__*/React.createElement("span", {
File diff suppressed because one or more lines are too long
+27 -1
View File
@@ -23,7 +23,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=202607310809" defer></script> <script src="/static/js/app.precompiled.js?v=202607311123" 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 {
@@ -380,6 +380,32 @@
border-radius: 2px; border-radius: 2px;
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9); box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
} }
/* Media Explorer: horizontal volume slider + selected file row */
input[type=range].me-fader-slider {
-webkit-appearance: none;
appearance: none;
background: #111;
height: 6px;
border-radius: 3px;
border: 1px solid #555;
cursor: pointer;
}
input[type=range].me-fader-slider::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 12px;
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #1e293b 100%);
border: 1px solid #000;
border-radius: 2px;
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
cursor: pointer;
}
.file-row-selected {
background-color: #3399ff !important;
color: #ffffff !important;
}
.file-row-selected .file-icon { color: #ffffff !important; }
</style> </style>
</head> </head>
+5
View File
@@ -1064,3 +1064,8 @@
- **Tóm tắt thay đổi:** `soundfontPlayer.js`: (1) render loop dùng wall-clock time-based queue accounting thay vì async CONSUMED message (hết race gây underrun); (2) `QUEUE_TARGET` 8→16 (~170ms buffer chống jank main thread); (3) bỏ 400ms release hack → `noteoff` đúng duration (FluidSynth tự release mượt, hết chồng voice); (4) `synth.verbose=0` + `synth.gain=1.0`. `fluidsynth-bridge.js`: bỏ post CONSUMED không còn dùng. - **Tóm tắt thay đổi:** `soundfontPlayer.js`: (1) render loop dùng wall-clock time-based queue accounting thay vì async CONSUMED message (hết race gây underrun); (2) `QUEUE_TARGET` 8→16 (~170ms buffer chống jank main thread); (3) bỏ 400ms release hack → `noteoff` đúng duration (FluidSynth tự release mượt, hết chồng voice); (4) `synth.verbose=0` + `synth.gain=1.0`. `fluidsynth-bridge.js`: bỏ post CONSUMED không còn dùng.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/worklets/fluidsynth-bridge.js` - **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/worklets/fluidsynth-bridge.js`
- **Ghi chú/Test (nếu có):** Hard reload. Play MIDI với instrument → hết lụp bụp. - **Ghi chú/Test (nếu có):** Hard reload. Play MIDI với instrument → hết lụp bụp.
### [2026-07-31 11:25] Task: Media Explorer panel (F6) + mutual exclusion with Mixer (F7)
- **Tóm tắt thay đổi:** Tạo `MediaExplorerPanel` component theo spec `md/51_MEDIA_EXPLORER.md` (REAPER-style: toolbar, directory tree + file list, transport/preview với canvas visualizer + metadata + status bar), render ở hàng dưới trên status bar (giống Mixer panel, resize được, lưu `studio_media_explorer_height`). Gán F6 toggle panel với `preventDefault`+`stopPropagation` ở cả keydown capture của App (MAIN SESSION/SECTION-TAB) và `PianoRollTabEditor`. F6/F7 mutual exclusion: bật panel này tự tắt panel kia (`window.__toggleMediaExplorerRef` / `__toggleMixerRef`). Bỏ dock placeholder `media_explorer` cũ. Thêm button status bar "Media Explorer Panel (F6)". Thêm CSS `.me-fader-slider` + `.file-row-selected` vào `index.html`, bump version precompiled.
- **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ó):** `npm run build` + `node --check` OK; smoke test jsdom: F6 hiện Media Explorer & tắt Mixer, F7 ngược lại. Hard reload để test thủ công.