diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index c1859be..24898d3 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -4573,7 +4573,71 @@ const ProfileModal = ({ } }; - const handleOpenProject = async (projectId) => { + const loadAudioBuffersForTracks = async (tracksList) => { + let hasLoadedAny = false; + const updatedTracks = await Promise.all(tracksList.map(async t => { + let trackBuffer = t.buffer; + let trackChannelInfo = t.channelInfo; + if (t.serverFileId && !trackBuffer) { + try { + const res = await fetch('/static/audio/uploads/' + t.serverFileId); + if (res.ok) { + const blob = await res.blob(); + const { audioBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(blob); + trackBuffer = audioBuffer; + trackChannelInfo = channelInfo; + hasLoadedAny = true; + } + } catch (e) { + console.warn("Failed to autoload track buffer for " + t.serverFileId, e); + } + } + + let clipsUpdated = false; + const updatedClips = await Promise.all((t.clips || []).map(async c => { + let clipBuffer = c.buffer; + const targetFileId = c.serverFileId || t.serverFileId; + if (targetFileId && !clipBuffer) { + try { + const res = await fetch('/static/audio/uploads/' + targetFileId); + if (res.ok) { + const blob = await res.blob(); + const { audioBuffer } = await window.SonicAudio.decodeAudioFile(blob); + clipBuffer = audioBuffer; + clipsUpdated = true; + hasLoadedAny = true; + } + } catch (e) { + console.warn("Failed to autoload clip buffer for " + targetFileId, e); + } + } + return { ...c, buffer: clipBuffer }; + })); + + if (trackBuffer && updatedClips.length === 0) { + const clipId = `default_${t.id}`; + return { + ...t, + buffer: trackBuffer, + channelInfo: trackChannelInfo, + clips: [{ id: clipId, buffer: trackBuffer, startTime: t.startTime || 0, name: t.name, speed: 1.0 }] + }; + } + + return { + ...t, + buffer: trackBuffer, + channelInfo: trackChannelInfo, + clips: updatedClips + }; + })); + + if (hasLoadedAny) { + setTracks(updatedTracks); + } + }; + + const handleOpenProject = async (projectId, projectName) => { setAppWarningModal({ title: "Mở dự án", message: "Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.", @@ -4630,6 +4694,7 @@ const ProfileModal = ({ }); } setTracks(restoredTracks); + loadAudioBuffersForTracks(restoredTracks); setBpm(restoredBpm.toString()); setSelectedTrackId(restoredTracks[0]?.id || '1'); setProjectName(proj.name); @@ -4741,10 +4806,10 @@ const ProfileModal = ({ }, /*#__PURE__*/React.createElement("button", { onClick: () => setConfirmModal(null), className: "px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold" - }, "Hủy"), /*#__PURE__*/React.createElement("button", { + }, confirmModal.cancelText || "Hủy"), /*#__PURE__*/React.createElement("button", { onClick: () => { const fn = confirmModal.onConfirm; setConfirmModal(null); fn(); }, className: "px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold" - }, "Xác nhận xóa")))), /*#__PURE__*/React.createElement("div", { + }, confirmModal.confirmText || "Xác nhận xóa")))), /*#__PURE__*/React.createElement("div", { key: "dialog", className: "fixed z-50 bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200 flex flex-col max-h-[85vh]", style: modalStyle @@ -4903,59 +4968,183 @@ const ProfileModal = ({ ]) ] : null))); }; - const SaveProjectModal = ({ isOpen, onClose, - onSave + onSaveCloud, + onSaveLocal, + projectName }) => { if (!isOpen) return null; - const [name, setName] = useState(''); + const [name, setName] = useState(projectName || ''); + const [saveType, setSaveType] = useState(localStorage.getItem('sonic_token') ? 'cloud' : 'local'); + const [cloudProjects, setCloudProjects] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedExisting, setSelectedExisting] = useState(null); + const [confirmOverwriteProject, setConfirmOverwriteProject] = useState(null); + React.useEffect(function() { + if (!isOpen) return; + if (saveType === 'cloud' && window.SonicAPI && window.SonicAPI.listCloudProjects) { + setLoading(true); + window.SonicAPI.listCloudProjects().then(function(data) { setCloudProjects(data || []); }).catch(function() { setCloudProjects([]); }).finally(function() { setLoading(false); }); + } + }, [isOpen, saveType]); const handleSubmit = (e) => { e.preventDefault(); if (!name.trim()) return; - onSave(name.trim()); - onClose(); + var matched = null; + for (var i = 0; i < cloudProjects.length; i++) { if (cloudProjects[i].name === name.trim()) { matched = cloudProjects[i]; break; } } + if (matched) { + setConfirmOverwriteProject(matched); + } else { + if (saveType === 'cloud') { onSaveCloud(name.trim(), null); onClose(); } + else { onSaveLocal(name.trim()); onClose(); } + } }; + if (confirmOverwriteProject) { + return React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" + }, React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-lg shadow-2xl p-5 max-w-sm w-full text-slate-200" + }, React.createElement("h4", { + className: "text-sm font-bold text-amber-400 mb-2" + }, "Ghi \u0111\u00E8 d\u1EF1 \u00E1n"), React.createElement("p", { + className: "text-xs text-slate-300 mb-4" + }, "D\u1EF1 \u00E1n \"", confirmOverwriteProject.name, "\" \u0111\u00E3 t\u1ED3n t\u1EA1i tr\u00EAn Cloud. B\u1EA1n c\u00F3 ch\u1EAFc ch\u1EAFn mu\u1ED1n ghi \u0111\u00E8 n\u00F3 kh\u00F4ng?"), React.createElement("div", { + className: "flex justify-end gap-2" + }, React.createElement("button", { + type: "button", onClick: function() { setConfirmOverwriteProject(null); }, + className: "px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold" + }, "Quay l\u1EA1i"), React.createElement("button", { + type: "button", onClick: function() { + setConfirmOverwriteProject(null); + onSaveCloud(name.trim(), confirmOverwriteProject.id); + onClose(); + }, + className: "px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold" + }, "X\u00E1c nh\u1EADn ghi \u0111\u00E8")))); + } + var filtered = cloudProjects.filter(function(p) { return p.name.toLowerCase().includes(name.toLowerCase()); }); return /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" }, /*#__PURE__*/React.createElement("div", { - className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200" }, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-teal-400 mb-4 uppercase" - }, "Đặt tên dự án"), /*#__PURE__*/React.createElement("form", { + }, "L\u01B0u d\u1EF1 \u00E1n"), /*#__PURE__*/React.createElement("form", { onSubmit: handleSubmit, className: "space-y-4" }, [ - /*#__PURE__*/React.createElement("input", { - key: "name-input", - type: "text", - placeholder: "Nhập tên dự án...", - required: true, - value: name, - onChange: e => setName(e.target.value), - className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold", - autoFocus: true - }), - /*#__PURE__*/React.createElement("div", { - key: "actions", - className: "flex justify-end gap-2 text-xs" - }, [ - /*#__PURE__*/React.createElement("button", { - key: "cancel", - type: "button", - onClick: onClose, - className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" - }, "Hủy"), - /*#__PURE__*/React.createElement("button", { - key: "save", - type: "submit", - className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold" - }, "Lưu") - ]) + /*#__PURE__*/React.createElement("div", { key: "type-block" }, [ + /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs" }, [ + /*#__PURE__*/React.createElement("button", { + key: "btn-cloud", type: "button", onClick: function() { setSaveType('cloud'); setSelectedExisting(null); }, + className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (saveType === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400') + }, [/*#__PURE__*/React.createElement("span", { key: "title" }, "\u2601\uFE0F L\u01B0u Cloud"), /*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "L\u01B0u l\u00EAn server")]), + /*#__PURE__*/React.createElement("button", { + key: "btn-local", type: "button", onClick: function() { setSaveType('local'); setSelectedExisting(null); }, + className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (saveType === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400') + }, [/*#__PURE__*/React.createElement("span", { key: "title" }, "\U0001F5C2\uFE0F L\u01B0u Local"), /*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "L\u01B0u xu\u1ED1ng t\u1EC7p .sfs")]) + ]) + ]), + /*#__PURE__*/React.createElement("div", { key: "name-block" }, [ + /*#__PURE__*/React.createElement("label", { className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1" }, "T\u00EAn d\u1EF1 \u00E1n"), + /*#__PURE__*/React.createElement("input", { + key: "name-input", type: "text", placeholder: "Nh\u1EADp t\u00EAn d\u1EF1 \u00E1n...", required: true, + value: name, + onChange: function(e) { setName(e.target.value); setSelectedExisting(null); }, + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-teal-500 font-bold", autoFocus: true + }) + ]), + saveType === 'cloud' && /*#__PURE__*/React.createElement("div", { key: "cloud-list", className: "max-h-40 overflow-y-auto -mx-1" }, + loading ? [/*#__PURE__*/React.createElement("div", { key: "l", className: "text-center py-3 text-xs text-zinc-500" }, "\u0110ang t\u1EA3i danh s\u00E1ch d\u1EF1 \u00E1n...")] + : filtered.length === 0 ? [/*#__PURE__*/React.createElement("div", { key: "e", className: "text-center py-3 text-xs text-zinc-500" }, "Ch\u01B0a c\u00F3 d\u1EF1 \u00E1n n\u00E0o.")] + : filtered.map(function(p) { + var isSelected = selectedExisting && selectedExisting.id === p.id; + var isExactMatch = p.name === name.trim(); + return /*#__PURE__*/React.createElement("div", { + key: p.id, + onClick: function() { setName(p.name); setSelectedExisting(p); }, + className: "flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-xs border transition " + (isSelected ? 'bg-amber-950/60 border-amber-600 text-amber-300' : 'bg-[#1e1e1e] hover:bg-zinc-800 border-transparent text-slate-300') + }, [/*#__PURE__*/React.createElement("span", { key: "n", className: "font-semibold truncate" }, p.name), /*#__PURE__*/React.createElement("span", { key: "t", className: "text-[9px] text-zinc-500 shrink-0 ml-2" }, isExactMatch ? "S\u1EBD ghi \u0111\u00E8" : "Ch\u1ECDn")]); + }) + ), + /*#__PURE__*/React.createElement("div", { key: "actions", className: "flex justify-end gap-2 text-xs" }, [ + /*#__PURE__*/React.createElement("button", { key: "cancel", type: "button", onClick: onClose, className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" }, "H\u1EE7y"), + selectedExisting ? /*#__PURE__*/React.createElement("button", { key: "back", type: "button", onClick: function() { setName(''); setSelectedExisting(null); }, className: "px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold" }, "Quay l\u1EA1i") : null, + /*#__PURE__*/React.createElement("button", { key: "save", type: "submit", className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold" }, selectedExisting ? "Ghi \u0111\u00E8" : "L\u01B0u") + ]) ]))); }; +const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => { + if (!isOpen) return null; + const [tab, setTab] = useState('cloud'); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + React.useEffect(function() { + if (!isOpen) return; + if (tab === 'cloud') { + setLoading(true); + var api = window.SonicAPI; + if (api && api.listCloudProjects) { + api.listCloudProjects().then(function(data) { setProjects(data || []); }).catch(function() { setProjects([]); }).finally(function() { setLoading(false); }); + } else { setLoading(false); } + } + }, [isOpen, tab]); + return React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" + }, React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200" + }, React.createElement("h3", { + className: "text-sm font-bold text-teal-400 mb-4 uppercase" + }, "Mở dự án"), React.createElement("div", { + className: "grid grid-cols-2 gap-2 text-xs mb-4" + }, [ + React.createElement("button", { + key: "cloud-tab", type: "button", onClick: function() { setTab('cloud'); }, + className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400') + }, [React.createElement("span", { key: "t" }, "☁️ Cloud"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Dự án trên server")]), + React.createElement("button", { + key: "local-tab", type: "button", onClick: function() { setTab('local'); }, + className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400') + }, [React.createElement("span", { key: "t" }, "💾 Local"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Tập tin .sfs trên máy")]) + ]), + tab === 'cloud' ? + React.createElement("div", { className: "space-y-1.5 max-h-64 overflow-y-auto" }, + loading ? [React.createElement("div", { key: "l", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách dự án...")] + : projects.length === 0 ? [React.createElement("div", { key: "e", className: "text-center py-8 text-xs text-zinc-500" }, "Bạn chưa có dự án nào trên Cloud.")] + : projects.map(function(p) { + return React.createElement("div", { + key: p.id, + onClick: function() { onOpenCloud(p.id, p.name); }, + className: "flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group" + }, [ + React.createElement("div", { key: "meta" }, [ + React.createElement("div", { className: "font-bold text-slate-200 group-hover:text-teal-400" }, p.name), + React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, "Dung lượng: " + (p.size_mb || 0) + " MB | Cập nhật: " + new Date((p.updated_at || 0) * 1000).toLocaleString()) + ]), + React.createElement("button", { + onClick: function(e) { e.stopPropagation(); onOpenCloud(p.id, p.name); }, + className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]" + }, "MỞ") + ]); + }) + ) + : React.createElement("div", { className: "py-4 text-center text-xs text-zinc-400 space-y-3" }, + [React.createElement("div", { key: "d", className: "text-zinc-500" }, "Chọn tệp .sfs để mở dự án từ Local."), + React.createElement("button", { + key: "b", onClick: function() { onOpenLocal(); }, + className: "px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold text-xs transition" + }, "Chọn tệp .sfs ...")] + ), + React.createElement("div", { className: "flex justify-end gap-2 text-xs mt-4 pt-3 border-t border-zinc-800" }, + React.createElement("button", { + type: "button", onClick: onClose, + className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" + }, "Hủy")))); +}; + const SaveAsModal = ({ isOpen, onClose, @@ -5256,6 +5445,46 @@ const AIPresetModal = ({ isOpen, onClose }) => { setFormTemplate(''); }; + const handleNewStructured = () => { + setEditingPreset('new'); + setFormName(''); + setFormKeywords(''); + setFormCategory('Orchestral / Film Score'); + setFormBars(8); + setFormBpm(120); + setFormScale('C Minor'); + setFormTemplate(`[Mô tả thể loại / phong cách] +Ví dụ: Nhạc phim epic, dàn nhạc giao hưởng, tempo 130 BPM, giọng Cm. + +[Cấu trúc bố cục] +- Intro (2 ô nhịp): ... +- Phát triển (4 ô nhịp): ... +- Climax / Cao trào (2 ô nhịp): ... + +[Yêu cầu về nhạc cụ / Track] +1. Track 1 - Strings: + - Vai trò: ... + - Quãng: ... +2. Track 2 - Brass: + - Vai trò: ... + - Quãng: ... +3. Track 3 - Percussion: + - Vai trò: ... + +[Yêu cầu hòa âm / Giai điệu] +- Tiến trình hợp âm: ... +- Pattern giai điệu: ... + +[Hiệu ứng / Sắc thái] +- Dynamics: ... +- Articulation: ... + +[Lưu ý kỹ thuật MIDI] +- Viết note liên tục trên toàn bộ số ô nhịp. +- Dùng whole note, half note, quarter note. +- Tránh chồng note quá dày.`); + }; + const handleToggleFav = (id) => { mgr.toggleFavorite(id); setPresets([...mgr.getPresets()]); @@ -5498,8 +5727,14 @@ const AIPresetModal = ({ isOpen, onClose }) => { rows: 6, className: "flex-1 bg-zinc-900 border border-zinc-700 rounded p-2.5 text-xs outline-none focus:border-purple-600 text-zinc-200 font-mono resize-none" })), /*#__PURE__*/React.createElement("div", { - className: "flex items-center justify-end gap-2 shrink-0 pt-2 border-t border-zinc-800" + className: "flex items-center gap-2 shrink-0 pt-2 border-t border-zinc-800" }, /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: handleNewStructured, + className: "px-3 py-1.5 bg-emerald-800 hover:bg-emerald-700 text-emerald-300 border border-emerald-700 rounded text-xs transition" + }, "Tạo preset với cấu trúc"), /*#__PURE__*/React.createElement("div", { + className: "flex-1" + }), /*#__PURE__*/React.createElement("button", { type: "button", onClick: () => setEditingPreset(null), className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 border border-zinc-700 rounded text-xs transition" @@ -7266,7 +7501,9 @@ const beatSec = 60.0 / (parseInt(bpm) || 120); const serializeTracksList = (tracksList, secondsPerBar) => { return (tracksList || []).map(t => { let trackType = "AUDIO"; - if (t.sections && t.sections.length > 0) trackType = "SECTION"; + if (t.type === 'MIDI' || t.type === 'soundfont' || t.type === 'vst3') trackType = "MIDI"; + else if (t.type === 'SECTION') trackType = "SECTION"; + else if (t.sections && t.sections.length > 0) trackType = "SECTION"; else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI"; const items = []; @@ -7399,18 +7636,27 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => { return { id: t.id, name: t.name, + type: t.type === 'MIDI' ? 'MIDI' : (t.type === 'SECTION' ? 'SECTION' : 'audio'), volumeDb: t.volume_db || 0.0, pan: t.pan || 0.0, muted: t.mute || false, solo: t.solo || false, - color: t.id === '1' ? '#0f766e' : '#1d4ed8', - markers: [], - serverFileId: t.items && t.items.find(i => i.type === "AUDIO_ITEM")?.source_data?.audio_file_url?.split("/").pop() || null, + color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'), + startTime: t.start_time || 0, + height: t.height || 140, + markers: t.markers || [], + serverFileId: t.server_file_id || (t.items && t.items.find(function(i) { return i.type === 'AUDIO_ITEM'; })?.source_data?.audio_file_url?.split('/').pop()) || null, + channelInfo: t.channel_info || null, + isArmed: t.is_armed || false, + monitoringEnabled: t.monitoring_enabled !== false, + inputSource: t.input_source ? { deviceType: t.input_source.device_type || 'NONE', deviceId: t.input_source.device_id || '' } : { deviceType: 'NONE', deviceId: '' }, + midiChannel: t.midi_channel != null ? t.midi_channel : undefined, + is_percussion: t.is_percussion || false, clips: clips, sections: sections, midiItems: midiItems, - instrumentId: t.instrument_id || null, - instrumentProgram: t.instrument_program !== null ? t.instrument_program : undefined, + instrumentId: t.instrumentId != null ? t.instrumentId : (t.instrument_id || null), + instrumentProgram: t.instrumentProgram !== undefined && t.instrumentProgram !== null ? t.instrumentProgram : (t.instrument_program !== null ? t.instrument_program : undefined), instrumentName: t.instrument_name || null, instrument_source: t.instrument_source || null, soundfont_id: t.soundfont_id || null, @@ -8805,6 +9051,7 @@ const App = () => { const [currentProjectId, setCurrentProjectId] = useState(() => localStorage.getItem('sonic_project_id') || null); const [saveProjectModalOpen, setSaveProjectModalOpen] = useState(false); const [saveAsModalOpen, setSaveAsModalOpen] = useState(false); + const [openProjectModalOpen, setOpenProjectModalOpen] = useState(false); const soloedTrack = tracks.find(t => t.solo); const soloedTrackId = soloedTrack ? soloedTrack.id : null; const [toastMessage, setToastMessage] = useState(null); @@ -9548,8 +9795,69 @@ const App = () => { applyMasteringSettings(masteringSettings); } }, [masteringSettings]); + const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false); const [pluginsData, setPluginsData] = useState(null); + + const restoreLastSessionProject = async () => { + var pendingWasNull = !window.__pendingSfsProject; + loadPendingSfsProject(); + if (!pendingWasNull) return; + + var lastId = localStorage.getItem('sonic_project_id'); + if (!lastId) return; + var lastName = localStorage.getItem('sonic_project_name') || 'Dự án'; + try { + var parsed = null; + if (lastId.startsWith('local_')) { + var localData = localStorage.getItem('sonic_local_project_data'); + if (localData) parsed = JSON.parse(localData); + } else { + var proj = await window.SonicAPI.getCloudProject(lastId); + if (proj) parsed = JSON.parse(proj.data_json); + } + if (!parsed) return; + var restoredBpm = bpm; + var restoredTracks = []; + var restoredSessionTabs = []; + var restoredSubTabs = []; + if (parsed.main_session) { + var result = deserializeProjectFromSchema(parsed); + restoredTracks = result.tracks; + restoredBpm = result.bpm; + restoredSessionTabs = result.sessionTabs; + restoredSubTabs = result.subTabs; + if (result.masteringSettings) setMasteringSettings(result.masteringSettings); + } else { + restoredTracks = (parsed.tracks || []).map(function(t) { + var rest = Object.assign({}, t); delete rest.height; + return Object.assign({}, rest, { buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }); + }); + } + setTracks(restoredTracks); + loadAudioBuffersForTracks(restoredTracks); + setBpm(restoredBpm.toString()); + setSelectedTrackId(restoredTracks[0]?.id || '1'); + setProjectName(lastName); + setCurrentProjectId(lastId); + localStorage.setItem('sonic_project_id', lastId); + setSessionTabs(restoredSessionTabs); + setSubTabs(restoredSubTabs); + var restoredItemCount = 0; + restoredTracks.forEach(function(rt) { + if (rt.clips) restoredItemCount += rt.clips.length; + if (rt.midiItems) restoredItemCount += rt.midiItems.length; + if (rt.sections) restoredItemCount += rt.sections.length; + }); + showToast('Đã khôi phục dự án "' + lastName + '" (' + restoredItemCount + ' items).', 'info'); + } catch(e) { + if (!lastId.startsWith('local_')) { + localStorage.removeItem('sonic_project_id'); + localStorage.removeItem('sonic_project_name'); + } + } + }; + useEffect(() => { const checkAuthStatus = async () => { const savedToken = localStorage.getItem('sonic_token'); @@ -9570,6 +9878,7 @@ const App = () => { } else { setIsMandatoryLogin(false); setAuthModalOpen(false); + restoreLastSessionProject(); } } catch (err) { const cached = localStorage.getItem('sonic_user'); @@ -9577,6 +9886,7 @@ const App = () => { try { setCurrentUser(JSON.parse(cached)); } catch (_) { } setIsMandatoryLogin(false); setAuthModalOpen(false); + restoreLastSessionProject(); } else { localStorage.removeItem('sonic_token'); localStorage.removeItem('sonic_user'); @@ -9589,6 +9899,7 @@ const App = () => { }; checkAuthStatus(); }, []); + const loadPendingSfsProject = () => { const proj = window.__pendingSfsProject; if (!proj) return; @@ -9666,7 +9977,7 @@ const App = () => { } else { setIsMandatoryLogin(false); setAuthModalOpen(false); - loadPendingSfsProject(); + restoreLastSessionProject(); const loadPrefs = (p) => { if (!p) return; if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); @@ -10373,13 +10684,7 @@ const App = () => { } if (ctrl && !alt && e.key === 's') { e.preventDefault(); - var curTab = activeTabRef.current; - if (curTab.startsWith('session_')) { - handleSaveSectionTabRef.current(curTab); - showToast('Đã lưu Section', 'success'); - } else { - handleExportSFS(); - } + handleSaveProjectRef.current(); return; } if (ctrl && alt && e.key === 's' || ctrl && e.shiftKey && e.key === 's') { @@ -10473,6 +10778,8 @@ const App = () => { e.preventDefault(); const curTab = activeTabRef.current; if (curTab === 'main') { + // handled by main handler + } else if (curTab.startsWith('session_')) { // Main session: save project + save all dirty sub-tabs handleSaveProject(); subTabsRef.current.filter(s => s.isDirty).forEach(st => { @@ -15454,49 +15761,79 @@ const App = () => { clientSideExport(exportTracks); } }; - const handleSaveProject = async () => { - if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; } - if (!projectName) { - setSaveProjectModalOpen(true); - return; + const handleSaveLocalProject = (name) => { + const finalName = name || projectName || 'Dự án mới'; + const localId = currentProjectId && currentProjectId.startsWith('local_') ? currentProjectId : 'local_' + Date.now(); + const projectSchemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings); + const dataStr = JSON.stringify(projectSchemaObj); + localStorage.setItem('sonic_local_project_data', dataStr); + localStorage.setItem('sonic_project_id', localId); + localStorage.setItem('sonic_project_name', finalName); + setCurrentProjectId(localId); + setProjectName(finalName); + window.SonicStorage.exportProjectToSFS(projectSchemaObj); + showToast(`Đã lưu dự án local "${finalName}" thành công!`, "success"); + }; + + const handleSaveCloudProject = async (name, existingProjectId) => { + const finalName = name || projectName || 'Dự án mới'; + var useProjectId = existingProjectId || currentProjectId; + const projectSchemaObj = serializeProjectToSchema(useProjectId || 'project_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings); + // Diagnostic: count source vs serialized items + var srcMidi = 0, srcClip = 0, srcSec = 0; + tracks.forEach(function(t) { srcMidi += (t.midiItems||[]).length; srcClip += (t.clips||[]).length; srcSec += (t.sections||[]).length; }); + var serItems = 0; + if (projectSchemaObj.main_session && projectSchemaObj.main_session.tracks) { + projectSchemaObj.main_session.tracks.forEach(function(st) { if (st.items) serItems += st.items.length; }); } - const projectSchemaObj = serializeProjectToSchema(currentProjectId || 'project_' + Date.now(), projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings); + Object.keys(projectSchemaObj.section_store || {}).forEach(function(sk) { + (projectSchemaObj.section_store[sk].tracks || []).forEach(function(st) { if (st.items) serItems += st.items.length; }); + }); const dataJson = JSON.stringify(projectSchemaObj); try { - if (currentProjectId) { - await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson); - showToast(`Đã cập nhật dự án "${projectName}" lên server!`, "success"); + if (existingProjectId) { + await window.SonicAPI.updateCloudProject(existingProjectId, finalName, dataJson); + setProjectName(finalName); + setCurrentProjectId(existingProjectId); + localStorage.setItem('sonic_project_name', finalName); + localStorage.setItem('sonic_project_id', existingProjectId); + showToast('Đã ghi đè Cloud "' + finalName + '" (src midi=' + srcMidi + ' clip=' + srcClip + ' sec=' + srcSec + ' | ser=' + serItems + ').', 'success'); + } else if (currentProjectId && !currentProjectId.startsWith('local_')) { + await window.SonicAPI.updateCloudProject(currentProjectId, finalName, dataJson); + setProjectName(finalName); + localStorage.setItem('sonic_project_name', finalName); + localStorage.setItem('sonic_project_id', currentProjectId); + showToast('Đã lưu Cloud "' + finalName + '" (src midi=' + srcMidi + ' clip=' + srcClip + ' sec=' + srcSec + ' | ser=' + serItems + ' id=' + currentProjectId + ').', serItems === 0 ? 'warning' : 'success'); } else { - const res = await window.SonicAPI.saveCloudProject(projectName, dataJson); + const res = await window.SonicAPI.saveCloudProject(finalName, dataJson); const newProjId = res.project_id; + setProjectName(finalName); + localStorage.setItem('sonic_project_name', finalName); if (newProjId) { setCurrentProjectId(newProjId); localStorage.setItem('sonic_project_id', newProjId); } - showToast(`Đã lưu dự án "${projectName}" mới lên server!`, "success"); + showToast('Đã lưu Cloud mới "' + finalName + '" (src midi=' + srcMidi + ' clip=' + srcClip + ' sec=' + srcSec + ' | ser=' + serItems + ').', serItems === 0 ? 'warning' : 'success'); } } catch (err) { - showToast(err.message || "Lỗi lưu server", "error"); + showToast(err.message || "Lỗi lưu dự án lên Cloud", "error"); } }; - const handleSaveProjectWithName = async (newName) => { - setProjectName(newName); - localStorage.setItem('sonic_project_name', newName); - const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings); - const dataJson = JSON.stringify(projectSchemaObj); - try { - const res = await window.SonicAPI.saveCloudProject(newName, dataJson); - const newProjId = res.project_id; - if (newProjId) { - setCurrentProjectId(newProjId); - localStorage.setItem('sonic_project_id', newProjId); - } - showToast(`Đã lưu dự án "${newName}" mới lên server!`, "success"); - } catch (err) { - showToast(err.message || "Lỗi lưu server", "error"); + const handleSaveProject = async () => { + if (!currentProjectId) { + setSaveProjectModalOpen(true); + return; + } + if (currentProjectId.startsWith('local_')) { + handleSaveLocalProject(projectName); + } else { + if (!currentUser) { showToast('Cần đăng nhập để lưu Cloud. currentUser=' + (currentUser ? 'OK' : 'NULL') + ' token=' + (localStorage.getItem('sonic_token') ? 'exists' : 'missing'), 'warning'); return; } + handleSaveCloudProject(projectName); } }; + const handleSaveProjectRef = useRef(handleSaveProject); + handleSaveProjectRef.current = handleSaveProject; const handleSaveAsCloud = async (newName) => { const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings); @@ -15531,20 +15868,37 @@ const App = () => { if (!e.target.files[0]) return; try { const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]); - const restored = (proj.tracks || []).map(t => ({ - ...t, - buffer: null, - channelInfo: t.channelInfo || null, - clips: t.clips || [], - serverFileId: t.serverFileId || null - })); + let restored = []; + let restoredBpm = bpm; + let restoredSessionTabs = []; + let restoredSubTabs = []; + if (proj.main_session) { + const result = deserializeProjectFromSchema(proj); + restored = result.tracks; + restoredBpm = result.bpm; + restoredSessionTabs = result.sessionTabs; + restoredSubTabs = result.subTabs; + if (result.masteringSettings) setMasteringSettings(result.masteringSettings); + } else { + restored = (proj.tracks || []).map(t => ({ + ...t, + buffer: null, + channelInfo: t.channelInfo || null, + clips: t.clips || [], + serverFileId: t.serverFileId || null + })); + } if (restored.length > 0) { setTracks(restored); + loadAudioBuffersForTracks(restored); + setBpm(restoredBpm.toString()); setProjectName(proj.name || 'Dự án mới'); setCurrentProjectId(null); + if (restoredSessionTabs.length > 0) setSessionTabs(restoredSessionTabs); + if (restoredSubTabs.length > 0) setSubTabs(restoredSubTabs); localStorage.setItem('sonic_project_name', proj.name || 'Dự án mới'); localStorage.removeItem('sonic_project_id'); - showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success"); + showToast(`Đã nạp dự án "${proj.name || 'Dự án mới'}" từ tệp .sfs thành công!`, "success"); } } catch (err) { showToast(err.message || "Lỗi mở tệp .sfs", "error"); @@ -17701,7 +18055,7 @@ const App = () => { label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', - action: () => handleImportSFS() + action: () => setOpenProjectModalOpen(true) }, { label: 'Save Project', icon: 'upload-cloud', @@ -20789,8 +21143,12 @@ const App = () => { }), /*#__PURE__*/React.createElement(SaveProjectModal, { isOpen: saveProjectModalOpen, onClose: () => setSaveProjectModalOpen(false), - onSave: (newName) => { - handleSaveProjectWithName(newName); + projectName: projectName, + onSaveCloud: (newName, existingId) => { + handleSaveCloudProject(newName, existingId); + }, + onSaveLocal: (newName) => { + handleSaveLocalProject(newName); } }), /*#__PURE__*/React.createElement(SaveAsModal, { isOpen: saveAsModalOpen, @@ -20804,6 +21162,17 @@ const App = () => { setProjectName(newName); localStorage.setItem('sonic_project_name', newName); } + }), /*#__PURE__*/React.createElement(OpenProjectModal, { + isOpen: openProjectModalOpen, + onClose: () => setOpenProjectModalOpen(false), + onOpenCloud: (projId, projName) => { + setOpenProjectModalOpen(false); + handleOpenProject(projId, projName); + }, + onOpenLocal: () => { + setOpenProjectModalOpen(false); + handleImportSFS(); + } }), /*#__PURE__*/React.createElement(AIConfigModal, { isOpen: aiConfigModalOpen, onClose: () => setAiConfigModalOpen(false), diff --git a/wiki.md b/wiki.md index 83ed1a8..97fb388 100644 --- a/wiki.md +++ b/wiki.md @@ -848,3 +848,9 @@ - **Tóm tắt thay đổi:** (1) Refine `MasterStripConsole` theo spec `md/47`: top button "MASTERING PANEL", pan knob pointer-drag (L/R/center label), mute/mono buttons toggle `.btn-mute-active`/`.btn-mono-active`, VU meter dùng synthetic post-fader simulation khi muted. (2) Tạo `TrackStripConsole` từ spec `md/48_TRACK_STRIP_COMPONENT.md`: `w-[145px]` với top color accent bar, pan rotary dial, peak dB fader rail + LED VU canvas, button stack (M/S/Route/FX/Power/Auto/Phase Ø), record arm row, track name, color footer index. (3) CSS mới: `.strip-bg-track`, `.fader-track-bg`, `.btn-solo-active`, `.btn-arm-active`, `@keyframes pulse-red`, cập nhật `.btn-daw:active`/`fader-slider` thumb từ spec. (4) Replace `MixerStrip` → `TrackStripConsole` trong mixer panel. - **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx` - **Ghi chú/Test (nếu có):** `npm run build` — build passes. + +### [2026-07-30 20:32] Task: Add "Tạo preset với cấu trúc" button in AI Preset Manager +- **Tóm tắt thay đổi:** Thêm `handleNewStructured` — tạo preset mới với template có cấu trúc (mục: thể loại, bố cục, nhạc cụ, hòa âm, hiệu ứng, MIDI). Button xanh lục "Tạo preset với cấu trúc" bên trái "Quay lại" trong form edit. +- **Các file ảnh hưởng:** `app/static/js/app.jsx` +- **Ghi chú/Test (nếu có):** Nhấn "Tạo mới" → form edit hiện button xanh "Tạo preset với cấu trúc" → click → pre-fill template. +---