diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 2ad3703..5bec07b 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -3190,6 +3190,7 @@ const App = () => { try { const profile = await window.SonicAPI.getProfile(); setCurrentUser(profile); + localStorage.setItem('sonic_user', JSON.stringify(profile)); if (profile.must_change_password) { setIsMandatoryLogin(true); setAuthMode('force_change'); @@ -3199,12 +3200,19 @@ const App = () => { setAuthModalOpen(false); } } catch (err) { - localStorage.removeItem('sonic_token'); - localStorage.removeItem('sonic_user'); - setCurrentUser(null); - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); + const cached = localStorage.getItem('sonic_user'); + if (cached) { + try { setCurrentUser(JSON.parse(cached)); } catch (_) {} + setIsMandatoryLogin(false); + setAuthModalOpen(false); + } else { + localStorage.removeItem('sonic_token'); + localStorage.removeItem('sonic_user'); + setCurrentUser(null); + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + } } }; checkAuthStatus(); @@ -7470,6 +7478,45 @@ const App = () => { handlePlayheadSet(time); return { success: true, time: parseFloat(time.toFixed(3)) }; }, + selectItem: (args) => { + if (args.select_all) { + setSelectedTrackId(null); + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(0); + const maxDur = tracks.reduce((max, t) => { + const dur = t.buffer ? t.buffer.duration : 0; + const clips = t.clips || []; + const clipMax = clips.reduce((m, c) => Math.max(m, (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0)), 0); + return Math.max(max, dur, clipMax); + }, 0); + setSelectionEnd(Math.max(maxDur, currentTime + 10)); + return { success: true, selection: 'all', duration: parseFloat(Math.max(maxDur, currentTime + 10).toFixed(3)) }; + } + const tid = args.track_id || selectedTrackId; + const track = tracks.find(t => t.id === tid); + if (!track) return { success: false, error: `Track ${tid} not found` }; + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ id: 'default_' + track.id, name: track.name, buffer: track.buffer, startTime: track.startTime || 0 }] : []); + if (args.item_name) { + const match = clips.find(c => c.name && c.name.toLowerCase().includes(args.item_name.toLowerCase())); + if (!match) return { success: false, error: `No clip matching "${args.item_name}" on track ${track.name}` }; + setSelectedTrackId(tid); + clearLocalSelection(); + setSelectionMode('global'); + const start = match.startTime || 0; + const end = start + (match.buffer ? match.buffer.duration : 2); + setSelectionStart(start); + setSelectionEnd(end); + return { success: true, trackId: tid, trackName: track.name, clipId: match.id, clipName: match.name, start: parseFloat(start.toFixed(3)), end: parseFloat(end.toFixed(3)) }; + } + setSelectedTrackId(tid); + clearLocalSelection(); + setSelectionMode('global'); + const trackEnd = track.buffer ? track.buffer.duration : (clips.length > 0 ? Math.max(...clips.map(c => (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0))) : 4); + setSelectionStart(0); + setSelectionEnd(trackEnd); + return { success: true, trackId: tid, trackName: track.name, duration: parseFloat(trackEnd.toFixed(3)) }; + }, addMarker: (args) => { const trackId = args.track_id || selectedTrackId; const time = args.time ?? currentTime; @@ -7741,7 +7788,7 @@ const App = () => { onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label), className: `px-3 py-1 text-xs font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}` }, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", { - className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-52 z-50", + className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 z-50", onClick: () => setMenuOpen(null) }, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", { key: i, @@ -8892,6 +8939,23 @@ const App = () => { setEditNameInput(track.name); } }, track.name)), /*#__PURE__*/React.createElement("div", { + className: "flex flex-wrap gap-0.5 max-w-[100px] mb-0.5" + }, (track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default', name: track.name, startTime: track.startTime }] : []).slice(0, 3).map(c => /*#__PURE__*/React.createElement("span", { + key: c.id, + className: "text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700", + title: c.name || track.name, + onClick: e => { + e.stopPropagation(); + setSelectedTrackId(track.id); + clearLocalSelection(); + setSelectionMode('global'); + const start = c.startTime || 0; + const end = start + (c.buffer ? c.buffer.duration : 2); + setSelectionStart(start); + setSelectionEnd(end); + showToast(`Selected: ${c.name || track.name}`, 'info'); + } + }, c.name || track.name))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("button", { onClick: e => { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 68d55f8..c6b98ab 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -3190,6 +3190,7 @@ const App = () => { try { const profile = await window.SonicAPI.getProfile(); setCurrentUser(profile); + localStorage.setItem('sonic_user', JSON.stringify(profile)); if (profile.must_change_password) { setIsMandatoryLogin(true); setAuthMode('force_change'); @@ -3199,12 +3200,21 @@ const App = () => { setAuthModalOpen(false); } } catch (err) { - localStorage.removeItem('sonic_token'); - localStorage.removeItem('sonic_user'); - setCurrentUser(null); - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); + const cached = localStorage.getItem('sonic_user'); + if (cached) { + try { + setCurrentUser(JSON.parse(cached)); + } catch (_) {} + setIsMandatoryLogin(false); + setAuthModalOpen(false); + } else { + localStorage.removeItem('sonic_token'); + localStorage.removeItem('sonic_user'); + setCurrentUser(null); + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + } } }; checkAuthStatus(); @@ -7635,6 +7645,73 @@ const App = () => { time: parseFloat(time.toFixed(3)) }; }, + selectItem: args => { + if (args.select_all) { + setSelectedTrackId(null); + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(0); + const maxDur = tracks.reduce((max, t) => { + const dur = t.buffer ? t.buffer.duration : 0; + const clips = t.clips || []; + const clipMax = clips.reduce((m, c) => Math.max(m, (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0)), 0); + return Math.max(max, dur, clipMax); + }, 0); + setSelectionEnd(Math.max(maxDur, currentTime + 10)); + return { + success: true, + selection: 'all', + duration: parseFloat(Math.max(maxDur, currentTime + 10).toFixed(3)) + }; + } + const tid = args.track_id || selectedTrackId; + const track = tracks.find(t => t.id === tid); + if (!track) return { + success: false, + error: `Track ${tid} not found` + }; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default_' + track.id, + name: track.name, + buffer: track.buffer, + startTime: track.startTime || 0 + }] : []; + if (args.item_name) { + const match = clips.find(c => c.name && c.name.toLowerCase().includes(args.item_name.toLowerCase())); + if (!match) return { + success: false, + error: `No clip matching "${args.item_name}" on track ${track.name}` + }; + setSelectedTrackId(tid); + clearLocalSelection(); + setSelectionMode('global'); + const start = match.startTime || 0; + const end = start + (match.buffer ? match.buffer.duration : 2); + setSelectionStart(start); + setSelectionEnd(end); + return { + success: true, + trackId: tid, + trackName: track.name, + clipId: match.id, + clipName: match.name, + start: parseFloat(start.toFixed(3)), + end: parseFloat(end.toFixed(3)) + }; + } + setSelectedTrackId(tid); + clearLocalSelection(); + setSelectionMode('global'); + const trackEnd = track.buffer ? track.buffer.duration : clips.length > 0 ? Math.max(...clips.map(c => (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0))) : 4; + setSelectionStart(0); + setSelectionEnd(trackEnd); + return { + success: true, + trackId: tid, + trackName: track.name, + duration: parseFloat(trackEnd.toFixed(3)) + }; + }, addMarker: args => { const trackId = args.track_id || selectedTrackId; const time = args.time ?? currentTime; @@ -7928,7 +8005,7 @@ const App = () => { onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label), className: `px-3 py-1 text-xs font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}` }, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", { - className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-52 z-50", + className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64 z-50", onClick: () => setMenuOpen(null) }, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", { key: i, @@ -9081,6 +9158,27 @@ const App = () => { setEditNameInput(track.name); } }, track.name)), /*#__PURE__*/React.createElement("div", { + className: "flex flex-wrap gap-0.5 max-w-[100px] mb-0.5" + }, (track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + name: track.name, + startTime: track.startTime + }] : []).slice(0, 3).map(c => /*#__PURE__*/React.createElement("span", { + key: c.id, + className: "text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700", + title: c.name || track.name, + onClick: e => { + e.stopPropagation(); + setSelectedTrackId(track.id); + clearLocalSelection(); + setSelectionMode('global'); + const start = c.startTime || 0; + const end = start + (c.buffer ? c.buffer.duration : 2); + setSelectionStart(start); + setSelectionEnd(end); + showToast(`Selected: ${c.name || track.name}`, 'info'); + } + }, c.name || track.name))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("button", { onClick: e => { diff --git a/app/static/js/services/aiGateway.js b/app/static/js/services/aiGateway.js index 043bf1d..3ea9321 100644 --- a/app/static/js/services/aiGateway.js +++ b/app/static/js/services/aiGateway.js @@ -166,6 +166,17 @@ const AIGateway = (function() { length_bars: { type: 'number', description: 'Độ dài vùng chọn (bar). Dùng cùng start_bar thay cho end_bar.' } } } + }, { + name: 'select_item', + description: 'Chọn một item/clip/sample cụ thể trên track. Nếu không có track_id và item_name thì chọn toàn bộ timeline.', + parameters: { + type: 'object', + properties: { + track_id: { type: 'string', description: 'ID của track. Nếu không có thì dùng track đang chọn.' }, + item_name: { type: 'string', description: 'Tên của clip/sample cần chọn. VD: "Cut_Cartoon Capers Loop.mp3". Nếu không có thì chọn toàn bộ track.' }, + select_all: { type: 'boolean', description: 'Chọn toàn bộ timeline (bỏ qua track_id và item_name)' } + } + } }, { name: 'cut_audio', description: 'Cắt đoạn audio đang được chọn từ track nguồn, snap zero-crossing, tạo track mới chứa đoạn cắt', diff --git a/app/static/js/services/dawCommandDispatcher.js b/app/static/js/services/dawCommandDispatcher.js index e32671f..72a9e37 100644 --- a/app/static/js/services/dawCommandDispatcher.js +++ b/app/static/js/services/dawCommandDispatcher.js @@ -63,6 +63,7 @@ const DAWCommandDispatcher = (function() { register('SET_SELECTION', (args) => api.setSelection(args)); register('SET_BPM', (args) => api.setBpm(args)); register('SET_PLAYHEAD', (args) => api.setPlayhead(args)); + register('SELECT_ITEM', (args) => api.selectItem(args)); register('ADD_MARKER', (args) => api.addMarker(args)); register('CREATE_MIDI_ITEM', (args) => AIGateway.createMidiItem(args)); register('MODIFY_MIDI_NOTES', (args) => AIGateway.modifyMidiNotes(args)); diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index bf8521e..0fa4385 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ