feat: canvas scroll preview, giữ state khi toggle, restore session folder/files

This commit is contained in:
2026-08-02 17:47:44 +07:00
parent b65188fcc8
commit 249e2afea2
4 changed files with 194 additions and 23 deletions
+177 -17
View File
@@ -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 => {