fix(ui): persist transport loop + panel mixer + redesign export modal (folder picker + default dir)

This commit is contained in:
2026-08-24 17:47:40 +07:00
parent 09bc762218
commit 51e62a1158
3 changed files with 180 additions and 46 deletions
+158 -34
View File
@@ -11784,7 +11784,10 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
time_signature_numerator: 4,
time_signature_denominator: 4,
sample_rate: 44100,
zoom: window.__sfTimelineZoom || window.__currentZoom || 1.0
zoom: window.__sfTimelineZoom || window.__currentZoom || 1.0,
loop_selection: !!window.__sfLoopSelection,
selection_start: (window.__sfLoopSelection && window.__sfSelectionStart != null) ? window.__sfSelectionStart : null,
selection_end: (window.__sfLoopSelection && window.__sfSelectionEnd != null) ? window.__sfSelectionEnd : null
},
main_session: {
id: "main",
@@ -11969,7 +11972,10 @@ const deserializeProjectFromSchema = (schemaObj) => {
masterMuted: !!(_masterBus.muted || _masterBus.mute),
masterAutoVol: (_masterBus.automation && Array.isArray(_masterBus.automation.volume_db)) ? _masterBus.automation.volume_db.filter(p => p && typeof p.time === 'number' && typeof p.db === 'number') : [],
masterAutoOn: !!(_masterBus.automation && Array.isArray(_masterBus.automation.volume_db) && _masterBus.automation.volume_db.length),
zoom: schemaObj.metadata && schemaObj.metadata.zoom ? parseFloat(schemaObj.metadata.zoom) : 1.0
zoom: schemaObj.metadata && schemaObj.metadata.zoom ? parseFloat(schemaObj.metadata.zoom) : 1.0,
loopSelection: !!(schemaObj.metadata && schemaObj.metadata.loop_selection),
selectionStart: (schemaObj.metadata && schemaObj.metadata.selection_start != null) ? schemaObj.metadata.selection_start : null,
selectionEnd: (schemaObj.metadata && schemaObj.metadata.selection_end != null) ? schemaObj.metadata.selection_end : null
};
};
@@ -12426,6 +12432,15 @@ const InteractiveEqPro = ({ track, params, onChange, getModule, applyTo, spectru
const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExporting, onExport, onBounce, onRenderMastering }) => {
if (!open) return null;
const pickDir = async () => {
try {
const r = await fetch(`${API_BASE_URL}/api/v1/plugins/pick-dir`, { method: 'POST' });
if (r.ok) {
const d = await r.json();
if (d && d.path) setExportSettings(p => ({ ...p, exportDir: d.path }));
}
} catch (e) { }
};
return (
<div className="fixed inset-0 z-[300] bg-black/70 backdrop-blur-sm flex items-center justify-center p-6" onClick={onClose}>
<div className="w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()}>
@@ -12436,6 +12451,38 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
<button onClick={onClose} className="text-zinc-400 hover:text-white"><i data-lucide="x" className="w-4 h-4" /></button>
</div>
<div className="p-4 max-h-[72vh] overflow-y-auto space-y-3">
<div className="bg-[#141414] border border-zinc-800 rounded px-2 py-1.5 space-y-1.5">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Tên file export</label>
<input type="text" value={exportSettings.fileName}
onChange={e => setExportSettings(p => ({ ...p, fileName: e.target.value }))}
placeholder="Để trống = dùng tên mặc định"
className="w-full bg-[#0d0d0d] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" />
</div>
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Thư mục export</label>
<div className="flex items-center gap-1.5 bg-[#0d0d0d] border border-zinc-800 rounded px-1.5 py-0.5 min-h-[22px]">
<i className="fa-solid fa-folder text-cyan-400 text-[10px] shrink-0"></i>
{exportSettings.exportDir ? (
<a className="text-cyan-400 underline truncate cursor-pointer" title={exportSettings.exportDir} onClick={pickDir}>{exportSettings.exportDir}</a>
) : (
<span className="text-zinc-600 truncate">Chưa chọn — xuất sẽ mở Save-As</span>
)}
</div>
</div>
<button onClick={pickDir}
className="shrink-0 px-2 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-[10px] font-bold flex items-center gap-1">
<i className="fa-solid fa-folder-open text-[10px]"></i> Chọn thư mục
</button>
</div>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!exportSettings.rememberExportDir}
onChange={e => setExportSettings(p => ({ ...p, rememberExportDir: e.target.checked }))}
className="accent-cyan-500 w-3.5 h-3.5" />
<span className="text-xs text-zinc-300">Lưu thư mục export làm mặc định</span>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Nguồn</label>
@@ -12459,7 +12506,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
</div>
</div>
{exportSettings.format === 'wav' ? (
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">SR (Hz)</label>
<select value={exportSettings.sampleRate} onChange={e => setExportSettings(p => ({ ...p, sampleRate: e.target.value }))}
@@ -12478,6 +12525,14 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
<option value="float">32 float</option>
</select>
</div>
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Kênh</label>
<select value={exportSettings.channels} onChange={e => setExportSettings(p => ({ ...p, channels: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="mono">Mono</option>
<option value="stereo">Stereo</option>
</select>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
@@ -12489,20 +12544,16 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
<option value="lossless">Lossless</option>
</select>
</div>
<div></div>
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Kênh</label>
<select value={exportSettings.channels} onChange={e => setExportSettings(p => ({ ...p, channels: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="mono">Mono</option>
<option value="stereo">Stereo</option>
</select>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Kênh</label>
<select value={exportSettings.channels} onChange={e => setExportSettings(p => ({ ...p, channels: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="mono">Mono</option>
<option value="stereo">Stereo</option>
</select>
</div>
<div></div>
</div>
<div className="grid grid-cols-2 gap-2 bg-[#141414] border border-zinc-800 rounded px-2 py-1.5">
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!exportSettings.dither}
@@ -17733,6 +17784,14 @@ const App = () => {
// timeline thu nhỏ ở góc — user phải zoom in mới về đúng).
window.__sfTimelineZoom = zoom;
}, [zoom]);
// Persist transport loop selection qua save/reload — serialize đọc từ window
// refs (module-level không truy cập state). Lưu cả vùng chọn (giây) để loop
// vùng chọn hoạt động lại đúng sau khi mở project.
React.useEffect(() => { window.__sfLoopSelection = isLoopingSelection; }, [isLoopingSelection]);
React.useEffect(() => {
window.__sfSelectionStart = selectionStart;
window.__sfSelectionEnd = selectionEnd;
}, [selectionStart, selectionEnd]);
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [beginBar, setBeginBar] = useState(0);
const [endBar, setEndBar] = useState(0);
@@ -18321,24 +18380,44 @@ const App = () => {
const aiPromptMgrRef = useRef(null);
const aiTypeaheadRef = useRef(null);
const [selectedProviderId, setSelectedProviderId] = useState('');
const [exportSettings, setExportSettings] = useState({
sampleRate: '44100',
bitDepth: 'float',
format: 'wav',
source: 'project',
quality: '44khz',
channels: 'stereo',
loop: false,
loopBars: 4,
dither: false,
normalize: true,
normalizeTarget: '-14lufs',
monoCheck: true,
// Gap 13: BWF/INFO metadata cho WAV mastering export
metaTitle: '',
metaArtist: '',
metaIsrc: ''
const [exportSettings, setExportSettings] = useState(() => {
let expDir = '', remExpDir = false;
try { expDir = localStorage.getItem('sonic_export_dir') || ''; } catch (e) { }
try { remExpDir = localStorage.getItem('sonic_remember_export_dir') === '1'; } catch (e) { }
return {
sampleRate: '44100',
bitDepth: 'float',
format: 'wav',
source: 'project',
quality: '44khz',
channels: 'stereo',
loop: false,
loopBars: 4,
dither: false,
normalize: true,
normalizeTarget: '-14lufs',
monoCheck: true,
// Gap 13: BWF/INFO metadata cho WAV mastering export
metaTitle: '',
metaArtist: '',
metaIsrc: '',
// Export file + thư mục (ExportModal) — dir lưu localStorage nếu remember
fileName: '',
exportDir: expDir,
rememberExportDir: remExpDir
};
});
// Persist export dir mặc định (chỉ khi user bật remember) — ExportModal
useEffect(() => {
try {
if (exportSettings.rememberExportDir && exportSettings.exportDir) {
localStorage.setItem('sonic_export_dir', exportSettings.exportDir);
} else {
localStorage.removeItem('sonic_export_dir');
}
localStorage.setItem('sonic_remember_export_dir', exportSettings.rememberExportDir ? '1' : '0');
} catch (e) { }
}, [exportSettings.exportDir, exportSettings.rememberExportDir]);
const [serverStatus, setServerStatus] = useState('checking...');
const _lan = window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.lan;
const serverAddr = _lan && _lan.urls && _lan.urls[0] ? (function () { try { return new URL(_lan.urls[0]).host; } catch (e) { return null; } })() : window.location.host;
@@ -19444,6 +19523,14 @@ const App = () => {
// Bỏ qua zoom ≤ 1 (data cũ lưu 1.0 — lỗi __currentZoom của Media
// Explorer) — áp dụng mới giữ zoom hiện tại/localStorage — hết thu nhỏ.
if (result.zoom > 1 && setZoom) setZoom(result.zoom);
// Restore transport loop selection (loop vùng chọn sau save/reload)
if (result.loopSelection !== undefined && result.loopSelection) {
setIsLoopingSelection(true);
if (result.selectionStart != null && result.selectionEnd != null) {
setSelectionStart(result.selectionStart);
setSelectionEnd(result.selectionEnd);
}
}
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
if (result.masterVolL !== undefined && result.masterVolL !== null) setMasterVolL(result.masterVolL);
@@ -19705,6 +19792,7 @@ const App = () => {
if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel);
if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel);
if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer);
if (p.showMixer !== undefined) setShowMixer(p.showMixer);
if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack);
if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents);
if (p.panelPositions) setPanelPositions(p.panelPositions);
@@ -27481,6 +27569,34 @@ const App = () => {
// /api/v1/files/save-dialog (PowerShell hoặc Tauri bridge) → ghi bytes qua
// /api/v1/files/write. Trả path đã ghi; null nếu user hủy / không có dialog.
async function saveExportViaDialog(defaultName, blob) {
// Export dir đã chọn trong ExportModal → ghi thẳng file, không mở Save-As.
// fileName từ modal nếu user nhập (thêm đuôi theo defaultName nếu thiếu),
// ngược lại dùng tên mặc định của flow export.
const dir = (exportSettings.exportDir || '').trim();
if (dir) {
try {
const ext = defaultName.includes('.') ? defaultName.split('.').pop() : 'bin';
let fname = (exportSettings.fileName || '').trim();
if (fname) {
if (!fname.includes('.')) fname = fname + '.' + ext;
else if (!fname.toLowerCase().endsWith('.' + ext.toLowerCase())) fname = fname + '.' + ext;
} else {
fname = defaultName;
}
const path = dir.replace(/[\\/]+$/, '') + '\\' + fname;
const fd = new FormData();
fd.append('path', path);
fd.append('file', new File([blob], fname, { type: blob.type || 'application/octet-stream' }));
const wr = await fetch(`${API_BASE_URL}/api/v1/files/write`, { method: 'POST', body: fd });
if (wr.ok) {
const w = await wr.json();
return w.path;
}
return null;
} catch (e) {
return null;
}
}
try {
const dlg = await fetch(`${API_BASE_URL}/api/v1/files/save-dialog`, {
method: 'POST',
@@ -30693,7 +30809,7 @@ STRICT CONSTRAINTS:
const prefsRef = useRef({});
prefsRef.current = {
showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel,
showMediaExplorer, showFxRack, showMidiEvents,
showMediaExplorer, showMixer, showFxRack, showMidiEvents,
panelPositions, rightSidebarWidth, mediaExplorerHeight, selectedProviderId
};
useEffect(() => {
@@ -30705,7 +30821,7 @@ STRICT CONSTRAINTS:
}, 2000);
return () => clearTimeout(timer);
}, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel,
showMediaExplorer, showFxRack, showMidiEvents, panelPositions,
showMediaExplorer, showMixer, showFxRack, showMidiEvents, panelPositions,
rightSidebarWidth, mediaExplorerHeight, selectedProviderId, currentUser]);
const drawMixerVuMeter = (canvas, peak) => {
@@ -30929,6 +31045,14 @@ STRICT CONSTRAINTS:
// Bỏ qua zoom ≤ 1 (data cũ lưu 1.0 — lỗi __currentZoom của Media
// Explorer) — áp dụng mới giữ zoom hiện tại/localStorage.
if (result.zoom > 1 && setZoom) setZoom(result.zoom);
// Restore transport loop selection (loop vùng chọn sau save/reload)
if (result.loopSelection !== undefined && result.loopSelection) {
setIsLoopingSelection(true);
if (result.selectionStart != null && result.selectionEnd != null) {
setSelectionStart(result.selectionStart);
setSelectionEnd(result.selectionEnd);
}
}
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
if (result.masterVolL !== undefined && result.masterVolL !== null) setMasterVolL(result.masterVolL);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -51,7 +51,7 @@
<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/chordTheory.js?v=202608211300"></script>
<script src="/static/js/app.precompiled.js?v=202608241800" defer></script>
<script src="/static/js/app.precompiled.js?v=202608231900" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {