feat: canvas scroll preview, giữ state khi toggle, restore session folder/files
This commit is contained in:
+177
-17
@@ -9104,6 +9104,138 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Session persistence: keep loaded folder/files/tree across panel toggles & reloads ──
|
||||
const SESSION_KEY = 'studio_media_explorer_session_v1';
|
||||
const saveClientRootHandle = () => {
|
||||
if (!clientRoot || !window.indexedDB) return;
|
||||
try {
|
||||
const req = indexedDB.open('sonicforge_media_explorer', 1);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('root_handle')) db.createObjectStore('root_handle');
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
const tx = db.transaction('root_handle', 'readwrite');
|
||||
tx.objectStore('root_handle').put(clientRoot, 'client_root');
|
||||
};
|
||||
} catch (e) {}
|
||||
};
|
||||
const loadClientRootHandle = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (!window.indexedDB) { resolve(null); return; }
|
||||
try {
|
||||
const req = indexedDB.open('sonicforge_media_explorer', 1);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('root_handle')) db.createObjectStore('root_handle');
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const db = req.result;
|
||||
try {
|
||||
const tx = db.transaction('root_handle', 'readonly');
|
||||
const g = tx.objectStore('root_handle').get('client_root');
|
||||
g.onsuccess = () => resolve(g.result || null);
|
||||
g.onerror = () => resolve(null);
|
||||
} catch (e2) { resolve(null); }
|
||||
};
|
||||
req.onerror = () => resolve(null);
|
||||
} catch (e) { resolve(null); }
|
||||
});
|
||||
};
|
||||
const saveSession = React.useCallback(() => {
|
||||
try {
|
||||
const stripHandle = (o) => {
|
||||
if (Array.isArray(o)) return o.map(stripHandle);
|
||||
if (o && typeof o === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(o)) {
|
||||
if (k === 'handle') continue;
|
||||
out[k] = stripHandle(o[k]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return o;
|
||||
};
|
||||
const treeSnapshot = {};
|
||||
Object.keys(computerTree).forEach(path => {
|
||||
const node = computerTree[path];
|
||||
treeSnapshot[path] = {
|
||||
dirs: stripHandle(node ? node.dirs : []),
|
||||
expanded: !!(node && node.expanded)
|
||||
};
|
||||
});
|
||||
const snap = {
|
||||
folder,
|
||||
computerMode,
|
||||
computerPath,
|
||||
clientRootName: clientRoot ? clientRoot.name : null,
|
||||
computerRoots: stripHandle(computerRoots || []),
|
||||
computerTree: treeSnapshot,
|
||||
computerFiles: stripHandle(computerFiles || []),
|
||||
selected: selected ? stripHandle({ name: selected.name, path: selected.path, kind: selected.kind, is_dir: selected.is_dir, size_mb: selected.size_mb }) : null,
|
||||
savedAt: Date.now()
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(snap));
|
||||
} catch (e) {}
|
||||
}, [folder, computerMode, computerPath, clientRoot, computerRoots, computerTree, computerFiles, selected]);
|
||||
|
||||
React.useEffect(() => {
|
||||
saveSession();
|
||||
if (computerMode === 'client' && clientRoot && clientRoot.kind === 'directory') saveClientRootHandle();
|
||||
}, [saveSession, computerMode, clientRoot]);
|
||||
|
||||
const restoreSession = React.useCallback(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return;
|
||||
const snap = JSON.parse(raw);
|
||||
if (snap.folder) setFolder(snap.folder);
|
||||
if (snap.computerMode) setComputerMode(snap.computerMode);
|
||||
if (snap.clientRootName) setClientRoot({ name: snap.clientRootName });
|
||||
if (snap.computerRoots && snap.computerRoots.length) setComputerRoots(snap.computerRoots);
|
||||
if (snap.computerPath) setComputerPath(snap.computerPath);
|
||||
if (snap.computerFiles) setComputerFiles(snap.computerFiles);
|
||||
if (snap.selected) setSelected(snap.selected);
|
||||
if (snap.computerTree) {
|
||||
const tree = {};
|
||||
Object.keys(snap.computerTree).forEach(path => {
|
||||
tree[path] = {
|
||||
dirs: snap.computerTree[path].dirs || [],
|
||||
expanded: snap.computerTree[path].expanded
|
||||
};
|
||||
});
|
||||
setComputerTree(tree);
|
||||
}
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
await restoreSession();
|
||||
// Restore the FileSystemDirectoryHandle so client-mode browsing still works
|
||||
const savedHandle = await loadClientRootHandle();
|
||||
if (savedHandle && savedHandle.kind === 'directory') {
|
||||
setClientRoot(savedHandle);
|
||||
const restoredPath = (() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || '{}').computerPath; } catch (e) { return null; } })();
|
||||
if (restoredPath) {
|
||||
setComputerRoots([{ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle }]);
|
||||
// Re-browse current dir to re-attach real handles (metadata-only tree from localStorage)
|
||||
const storedTree = (() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || '{}').computerTree; } catch (e) { return null; } })();
|
||||
if (storedTree) {
|
||||
const rebuilt = {};
|
||||
Object.keys(storedTree).forEach(p => { rebuilt[p] = { dirs: storedTree[p].dirs || [], expanded: storedTree[p].expanded }; });
|
||||
setComputerTree(rebuilt);
|
||||
}
|
||||
setComputerMode('client');
|
||||
// Rebuild handles for the current folder + ancestors by walking from root
|
||||
browseComputerDir({ name: savedHandle.name, path: 'root', is_dir: true, handle: savedHandle });
|
||||
}
|
||||
}
|
||||
})();
|
||||
// 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;
|
||||
@@ -9496,10 +9628,20 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
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 totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * (curTempo || 120) / 60, 4) : (f.lengthQn || 16);
|
||||
// Content scrolls when wider than frame: playhead stays at frame center
|
||||
const bpmV = curTempo || 120;
|
||||
const pxPerBeat = 42;
|
||||
const totalBeats = curMidiNotes && curMidiNotes.length ? Math.max(curMidiTotal * bpmV / 60, 4) : (f.lengthQn || 16);
|
||||
const beats = totalBeats;
|
||||
const contentW = Math.max(w, beats * pxPerBeat);
|
||||
const pxPerSec = pxPerBeat * bpmV / 60;
|
||||
let offset = 0;
|
||||
if (playing && contentW > w && dur > 0) {
|
||||
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
|
||||
}
|
||||
for (let b = 0; b <= beats; b += 4) {
|
||||
const x = (b / beats) * w;
|
||||
const x = b * pxPerBeat - offset;
|
||||
if (x < -10 || x > w + 10) continue;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
@@ -9508,29 +9650,48 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
const pitchMin = 48, pitchMax = 84;
|
||||
const pitchRange = Math.max(1, pitchMax - pitchMin);
|
||||
curMidiNotes.forEach(n => {
|
||||
const x = (n.start_beat / beats) * w;
|
||||
const nw = Math.max(3, (n.duration_beats / beats) * w);
|
||||
const x = (n.start_beat * pxPerBeat) - offset;
|
||||
if (x < -30 || x > w + 30) return;
|
||||
const nw = Math.max(3, (n.duration_beats || 1) * pxPerBeat);
|
||||
const y = h - 14 - 8 - (((Math.min(pitchMax, Math.max(pitchMin, n.pitch)) - pitchMin) / pitchRange) * (h - 30));
|
||||
ctx.fillRect(x, y, nw, 5);
|
||||
});
|
||||
} else {
|
||||
const events = f.events || 76;
|
||||
for (let i = 0; i < events; i++) {
|
||||
const nx = ((i * 17 + 76) % 95) / 100 * (w - 20);
|
||||
const nx = ((i * 17 + 76) % 95) / 100 * contentW - offset;
|
||||
const ny = ((i * 13 + 76) % (h - 30)) + 4;
|
||||
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
|
||||
}
|
||||
}
|
||||
// playhead (drawn over MIDI): moves to frame center, then stops there & content scrolls
|
||||
const midiPlayheadX = contentW > w ? Math.min(w / 2, t * pxPerSec) : Math.min(w, t * pxPerSec);
|
||||
if (playing && dur > 0) {
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillRect(midiPlayheadX - 1, 0, 2, h - 14);
|
||||
}
|
||||
} else {
|
||||
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
|
||||
if (pk) {
|
||||
const pxPerSec = 50;
|
||||
const contentW = Math.max(w, dur * pxPerSec);
|
||||
let offset = 0;
|
||||
if (playing && contentW > w && dur > 0) {
|
||||
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
|
||||
}
|
||||
const mid = h / 2;
|
||||
ctx.fillStyle = '#22c55e';
|
||||
for (let i = 0; i < pk.length; i++) {
|
||||
const x = (i / pk.length) * w;
|
||||
const x = (i / pk.length) * contentW - offset;
|
||||
if (x < -3 || x > w + 3) continue;
|
||||
const ph = Math.max(2, pk[i] * (h / 2 - 4));
|
||||
ctx.fillRect(x, mid - ph, Math.max(1, w / pk.length), ph * 2);
|
||||
}
|
||||
const audioPlayheadX = contentW > w ? Math.min(w / 2, t * pxPerSec) : Math.min(w, t * pxPerSec);
|
||||
if (playing && dur > 0) {
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillRect(audioPlayheadX - 1, 0, 2, h - 14);
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = '#666'; ctx.font = '11px monospace';
|
||||
ctx.fillText('Waveform unavailable', 10, h / 2);
|
||||
@@ -9539,17 +9700,16 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
// ruler
|
||||
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
|
||||
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
|
||||
const rulerBeats = Math.max(4, Math.ceil(dur * (curTempo || 120) / 60) || 16);
|
||||
for (let b = 0; b <= rulerBeats; b += 4) {
|
||||
const x = (b / rulerBeats) * w;
|
||||
const rulerBpm = curTempo || 120;
|
||||
const rulerPxPerBeat = isMidiFile(f) ? 42 : (50 * 60 / rulerBpm);
|
||||
const rulerContentW = Math.max(w, dur * rulerPxPerBeat * rulerBpm / 60);
|
||||
const rulerTotalBeats = Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
|
||||
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * (rulerPxPerBeat * rulerBpm / 60) - w / 2)) : 0;
|
||||
for (let b = 0; b <= rulerTotalBeats; b += 4) {
|
||||
const x = b * rulerPxPerBeat - rulerOffset;
|
||||
if (x < -20 || x > w + 20) continue;
|
||||
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
|
||||
}
|
||||
// playhead
|
||||
if (playing && dur > 0) {
|
||||
const px = (t / dur) * w;
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fillRect(px, 0, 2, h - 14);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, midiNotes, selected, folder, isPlaying]);
|
||||
@@ -22095,9 +22255,9 @@ const App = () => {
|
||||
onUpdateTrack: updateTrackProp,
|
||||
trackVuRefs: trackVuRefs
|
||||
});
|
||||
}))), showMediaExplorer && /*#__PURE__*/React.createElement("div", {
|
||||
}))), /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",
|
||||
style: { height: mediaExplorerPanelHeight + 'px' }
|
||||
style: { height: mediaExplorerPanelHeight + 'px', display: showMediaExplorer ? 'flex' : 'none' }
|
||||
}, /*#__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 => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,7 +23,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=202608021735" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608021805" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -1124,3 +1124,8 @@
|
||||
- **Tóm tắt thay đổi:** (1) Fix canvas hiển thị sai lần đầu khi click file MIDI: `startCanvasClock` rAF tick giữ closure cũ của `drawCanvas` (đọc `selected` cũ = null) và vẽ đè "No file selected" mỗi frame. Chuyển `drawCanvas`/`fileDuration`/clock sang đọc state mới nhất qua refs (`selectedRef`, `peaksRef`, `audioBufferRef`, `midiNotesRef`, `midiTotalRef`, `isPlayingRef`, `isPausedRef`, `currentTimeRef`, `tempoRef`). (2) Thêm **Tempo** control (BPM, +/- input, min 40 max 300) cạnh nút Synth — ảnh hưởng tốc độ preview MIDI (`playMidiPreview` dùng `tempoRef` thay `synthInst.bpm`), ruler canvas, footer status; lưu `localStorage['studio_media_explorer_tempo']`.
|
||||
- **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ó):** Smoke jsdom: click a.mid lần đầu → canvas vẽ ruler (0,1,2..) không còn "No file selected", audio play; Tempo 120 → +1 → localStorage `121`. Hard reload.
|
||||
|
||||
### [2026-08-02 18:05] Task: Canvas scroll + giữ state folder/files khi toggle + restore session
|
||||
- **Tóm tắt thay đổi:** (1) `drawCanvas` scroll khi content rộng hơn khung: pxPerBeat 42 / pxPerSec 50, playhead di chuyển tới giữa khung (`min(w/2, t*pxPerSec)`) rồi dừng, content scroll trái với offset `max(0, min(contentW-w, t*pxPerSec - w/2))`; ruler scroll đồng bộ. (2) Panel giờ **luôn mounted** (display:none khi ẩn) thay vì `showMediaExplorer &&` → toggle F6 giữ nguyên folder/files/tree/selection. (3) Session persistence `localStorage['studio_media_explorer_session_v1']` (folder, mode, path, roots, tree, files, selected — bỏ handle) + lưu `FileSystemDirectoryHandle` vào IndexedDB `sonicforge_media_explorer/root_handle` để client mode restore được handle thật sau reload; `restoreSession` + re-browse lại folder đã load → cây tree + file list hiển thị đúng thư mục cũ, tên thư mục loaded luôn hiện trong tree.
|
||||
- **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ó):** Smoke jsdom: toggle F6 off/on → tree+files+selection giữ nguyên; reload (fresh DOM + IndexedDB stub) → folder MyMusic, files a.mid/b.mid, selection restore. Scroll math: t=10s → offset 350, playhead 150 (center), t=29s clamp 1200. Hard reload.
|
||||
|
||||
Reference in New Issue
Block a user