feat: Media Explorer - auto tempo theo MIDI file + gõ tempo tay (tempoText/commitTempo) + focus folder cha giữa tree + điều hướng tree bằng phím mũi tên
This commit is contained in:
+163
-13
@@ -9170,6 +9170,10 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
var saved = localStorage.getItem('studio_media_explorer_tempo');
|
||||
return saved ? parseInt(saved) : 120;
|
||||
});
|
||||
const [tempoText, setTempoText] = React.useState(String(function() {
|
||||
var saved = localStorage.getItem('studio_media_explorer_tempo');
|
||||
return saved ? parseInt(saved) : 120;
|
||||
}()));
|
||||
|
||||
const [zoom, setZoom] = React.useState(1.0);
|
||||
const [scrollOffset, setScrollOffset] = React.useState(0);
|
||||
@@ -9216,11 +9220,22 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
selStartRef.current = selStart;
|
||||
selEndRef.current = selEnd;
|
||||
isLoopingRef.current = isLooping;
|
||||
const computerPathRef = React.useRef(null);
|
||||
const computerTreeRef = React.useRef({});
|
||||
const computerRootsRef = React.useRef(null);
|
||||
const treePaneRef = React.useRef(null);
|
||||
const browseComputerDirRef = React.useRef(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setScrollOffset(0);
|
||||
}, [selected]);
|
||||
|
||||
// Keep the tempo text field in sync when tempo changes from elsewhere
|
||||
// (e.g. auto-set from a clicked MIDI file).
|
||||
React.useEffect(() => {
|
||||
setTempoText(String(tempo));
|
||||
}, [tempo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.mediaExplorerActive = true;
|
||||
const handleDocumentClick = (e) => {
|
||||
@@ -9476,6 +9491,56 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
if (window.mediaExplorerActive && folderRef.current === 'computer') {
|
||||
const target = e.target;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Build the flat list of visible (expanded) tree nodes.
|
||||
const paths = [];
|
||||
const walk = (entry, depth) => {
|
||||
if (!entry || !entry.path) return;
|
||||
paths.push({ path: entry.path, entry, depth });
|
||||
const n = computerTreeRef.current[entry.path];
|
||||
if (n && n.expanded && n.dirs) (n.dirs || []).forEach(d => walk(d, depth + 1));
|
||||
};
|
||||
(computerRootsRef.current || []).forEach(r => walk(r, 0));
|
||||
if (!paths.length) return;
|
||||
const curPath = computerPathRef.current;
|
||||
let idx = paths.findIndex(p => p.path === curPath);
|
||||
if (e.key === 'ArrowUp') {
|
||||
const ni = idx < 0 ? paths.length - 1 : Math.max(0, idx - 1);
|
||||
navigateTreeTo(paths[ni].path);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
const ni = Math.min(paths.length - 1, (idx < 0 ? -1 : idx) + 1);
|
||||
navigateTreeTo(paths[ni].path);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
const node = computerTreeRef.current[curPath];
|
||||
if (node && !node.expanded) {
|
||||
setComputerTree(prev => ({ ...prev, [curPath]: { ...(prev[curPath] || {}), expanded: true } }));
|
||||
if (!node.dirs || !node.dirs.length) {
|
||||
if (browseComputerDirRef.current) browseComputerDirRef.current({ name: String(curPath).split('/').pop() || curPath, path: curPath, is_dir: true, handle: node.handle });
|
||||
}
|
||||
} else if (idx < 0) {
|
||||
const p0 = paths[0];
|
||||
if (p0) navigateTreeTo(p0.path);
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
const node = computerTreeRef.current[curPath];
|
||||
if (node && node.expanded) {
|
||||
setComputerTree(prev => ({ ...prev, [curPath]: { ...(prev[curPath] || {}), expanded: false } }));
|
||||
} else if (curPath) {
|
||||
const i = curPath.lastIndexOf('/');
|
||||
if (i > 0) {
|
||||
navigateTreeTo(curPath.substring(0, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'c' && (e.ctrlKey || e.metaKey)) {
|
||||
if (window.mediaExplorerActive) {
|
||||
const target = e.target;
|
||||
@@ -9502,6 +9567,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
const [computerFiles, setComputerFiles] = React.useState([]);
|
||||
const [computerMode, setComputerMode] = React.useState('server');
|
||||
const [clientRoot, setClientRoot] = React.useState(null);
|
||||
computerPathRef.current = computerPath;
|
||||
computerTreeRef.current = computerTree;
|
||||
computerRootsRef.current = computerRoots;
|
||||
const [favorites, setFavorites] = React.useState(function() {
|
||||
try { return JSON.parse(localStorage.getItem('studio_media_explorer_favorites_v1') || '[]'); } catch (e) { return []; }
|
||||
}());
|
||||
@@ -9921,6 +9989,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
browseComputerDirRef.current = browseComputerDir;
|
||||
|
||||
const toggleComputerDir = async (entry) => {
|
||||
if (!entry) return;
|
||||
@@ -10116,6 +10185,14 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
if (selectTokenRef.current !== (token || selectTokenRef.current)) return;
|
||||
const midiResult = (typeof parseMidiFile === 'function' ? parseMidiFile : window.parseMidiFile)(buf);
|
||||
if (!midiResult || !midiResult.length) return;
|
||||
// Feature: selecting/playing a MIDI file auto-sets the playback tempo to
|
||||
// the file's own BPM (so the preview plays in time).
|
||||
const fileBpm = (midiResult[0] && midiResult[0].bpm) || 120;
|
||||
const newTempo = Math.max(40, Math.min(300, Math.round(fileBpm)));
|
||||
setTempo(newTempo);
|
||||
setTempoText(String(newTempo));
|
||||
tempoRef.current = newTempo;
|
||||
try { localStorage.setItem('studio_media_explorer_tempo', String(newTempo)); } catch (e) {}
|
||||
const ctx = getAudioContext();
|
||||
const bpmVal = tempoRef.current || 120;
|
||||
const secondsPerBeat = 60.0 / bpmVal;
|
||||
@@ -10491,16 +10568,94 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, audioDuration, midiNotes, selected, folder, isPlaying, zoom, selStart, selEnd, scrollOffset]);
|
||||
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
||||
|
||||
const commitTempo = (v) => {
|
||||
const clamped = Math.max(40, Math.min(300, Math.round(v) || 120));
|
||||
setTempo(clamped);
|
||||
setTempoText(String(clamped));
|
||||
tempoRef.current = clamped;
|
||||
try { localStorage.setItem('studio_media_explorer_tempo', String(clamped)); } catch (e) {}
|
||||
// Re-schedule a currently playing MIDI preview at the new tempo.
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Center a folder node vertically in the tree pane (feature: file click focuses
|
||||
// its parent folder in the middle of the tree).
|
||||
const centerTreeNodeInPane = (path) => {
|
||||
setTimeout(() => {
|
||||
const pane = treePaneRef.current;
|
||||
if (!pane) return;
|
||||
const el = document.querySelector(`[data-tree-path="${path}"]`);
|
||||
if (!el) return;
|
||||
const paneRect = pane.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const target = pane.scrollTop + (elRect.top - paneRect.top) - paneRect.height / 2 + elRect.height / 2;
|
||||
pane.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
|
||||
}, 80);
|
||||
};
|
||||
|
||||
// Flat list of currently visible (expanded) tree nodes, in render order.
|
||||
const getVisibleTreePaths = () => {
|
||||
const out = [];
|
||||
const walk = (entry, depth) => {
|
||||
if (!entry || !entry.path) return;
|
||||
out.push({ path: entry.path, entry, depth });
|
||||
const node = computerTreeRef.current[entry.path];
|
||||
if (node && node.expanded && node.dirs) {
|
||||
(node.dirs || []).forEach(d => walk(d, depth + 1));
|
||||
}
|
||||
};
|
||||
(computerRootsRef.current || []).forEach(r => walk(r, 0));
|
||||
return out;
|
||||
};
|
||||
|
||||
const navigateTreeTo = (path) => {
|
||||
const paths = getVisibleTreePaths();
|
||||
const target = paths.find(p => p.path === path);
|
||||
if (!target) return;
|
||||
// Expand all ancestor nodes so the target is visible in the tree.
|
||||
setComputerTree(prev => {
|
||||
const next = { ...prev };
|
||||
let current = path;
|
||||
while (current) {
|
||||
const n = next[current];
|
||||
if (n) next[current] = { ...n, expanded: true };
|
||||
const idx = current.lastIndexOf('/');
|
||||
if (idx <= 0) break;
|
||||
current = current.substring(0, idx);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setComputerPath(path);
|
||||
if (browseComputerDirRef.current) browseComputerDirRef.current(target.entry);
|
||||
centerTreeNodeInPane(path);
|
||||
};
|
||||
|
||||
const handleSelect = (f) => {
|
||||
if (!f || f.is_dir) return;
|
||||
|
||||
|
||||
// When a MIDI file is clicked, automatically set the play tempo to the
|
||||
// MIDI file's tempo (BPM from metadata, or after parsing in playMidiPreview).
|
||||
if (isMidiFile(f) && f.bpm) {
|
||||
const v = Math.max(40, Math.min(300, Math.round(parseFloat(f.bpm) || 120)));
|
||||
setTempo(v);
|
||||
setTempoText(String(v));
|
||||
tempoRef.current = v;
|
||||
try { localStorage.setItem('studio_media_explorer_tempo', String(v)); } catch (e) {}
|
||||
}
|
||||
|
||||
// Find parent path of selected file and scroll it into view in Tree pane
|
||||
if (f.path) {
|
||||
const lastSlash = f.path.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
const parentPath = f.path.substring(0, lastSlash);
|
||||
setComputerPath(parentPath);
|
||||
|
||||
|
||||
// Expand all parent nodes in computerTree
|
||||
setComputerTree(prev => {
|
||||
const next = { ...prev };
|
||||
@@ -10518,13 +10673,8 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
return next;
|
||||
});
|
||||
|
||||
// Scroll the parent tree element into view
|
||||
setTimeout(() => {
|
||||
const treeNodeEl = document.querySelector(`[data-tree-path="${parentPath}"]`);
|
||||
if (treeNodeEl) {
|
||||
treeNodeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 100);
|
||||
// Center the parent folder node in the middle of the tree pane
|
||||
centerTreeNodeInPane(parentPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10652,7 +10802,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
<div className="flex-1 flex overflow-hidden min-h-0 relative">
|
||||
{/* DIRECTORY TREE */}
|
||||
<div className="flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden">
|
||||
<div className="overflow-y-auto p-1" style={{ width: treeWidth + 'px', minWidth: treeWidth + 'px', maxWidth: treeWidth + 'px' }}>
|
||||
<div ref={treePaneRef} className="overflow-y-auto p-1" style={{ width: treeWidth + 'px', minWidth: treeWidth + 'px', maxWidth: treeWidth + 'px' }}>
|
||||
<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> <Track Templates></div>
|
||||
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> <Project Directory></div>
|
||||
@@ -10870,9 +11020,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
||||
</div>
|
||||
<div className="flex items-center gap-1" title="Tempo preview MIDI">
|
||||
<span className="font-mono text-[10px] text-slate-700">Tempo:</span>
|
||||
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => { const nt = Math.max(40, tempo - 1); setTempo(nt); localStorage.setItem('studio_media_explorer_tempo', nt.toString()); }}>-</button>
|
||||
<div className="bg-white border border-[#808080] px-1 h-5 flex items-center w-11"><input type="number" min="40" max="300" value={tempo} onChange={e => { const v = Math.max(40, Math.min(300, parseInt(e.target.value) || 120)); setTempo(v); localStorage.setItem('studio_media_explorer_tempo', v.toString()); }} 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={() => { const nt = Math.min(300, tempo + 1); setTempo(nt); localStorage.setItem('studio_media_explorer_tempo', nt.toString()); }}>+</button>
|
||||
<button className="px-1 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[10px]" onClick={() => commitTempo((tempo || 120) - 1)}>-</button>
|
||||
<div className="bg-white border border-[#808080] px-1 h-5 flex items-center w-14"><input type="number" min="40" max="300" value={tempoText} onChange={e => { const raw = e.target.value; setTempoText(raw); const n = parseInt(raw); if (n >= 40 && n <= 300) commitTempo(n); }} onBlur={() => { const n = parseInt(tempoText); commitTempo(n); }} onKeyDown={e => { if (e.key === 'Enter') { e.currentTarget.blur(); } }} 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={() => commitTempo((tempo || 120) + 1)}>+</button>
|
||||
<span className="text-slate-600 text-[10px]">BPM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@
|
||||
<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/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608031415" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608031430" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
Reference in New Issue
Block a user