diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 5bec07b..9390eb2 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -3074,6 +3074,7 @@ const App = () => { const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited + const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId } const [editNameInput, setEditNameInput] = useState(''); // ── Context Menu & Clipboard ── @@ -3248,24 +3249,26 @@ const App = () => { setIsMandatoryLogin(false); setAuthModalOpen(false); loadPendingSfsProject(); + const loadPrefs = (p) => { + if (!p) return; + if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); + if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); + if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); + if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); + if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); + if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); + if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); + if (p.panelPositions) setPanelPositions(p.panelPositions); + if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); + if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); + if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); + }; (async () => { try { const data = await window.SonicAPI.getPreferences(); - if (data && data.preferences) { - const p = data.preferences; - if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); - if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); - if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); - if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); - if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); - if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); - if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); - if (p.panelPositions) setPanelPositions(p.panelPositions); - if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); - if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); - if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); - } - } catch (e) { /* preferences not available */ } + if (data && data.preferences) loadPrefs(data.preferences); + else { const cached = localStorage.getItem('sonic_preferences'); if (cached) loadPrefs(JSON.parse(cached)); } + } catch (e) { const cached = localStorage.getItem('sonic_preferences'); if (cached) loadPrefs(JSON.parse(cached)); } })(); } }; @@ -4638,10 +4641,10 @@ const App = () => { const ctx = getAudioContext(); const targetTrack = tracks.find(t => t.id === targetTrackId); const newClip = { - id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000), + id: nextClipId(), startTime: pasteTime, buffer: clipBuffer, - name: name || 'Pasted Clip' + name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)' }; if (targetTrack) { setTracks(p => p.map(t => { @@ -5218,6 +5221,16 @@ const App = () => { } } if (updatedTime >= maxDurationRef.current) { + if (isLoopingSelection) { + stopAllPlayback(); + startOffsetTimeRef.current = 0; + startAudioTimeRef.current = context.currentTime; + startTrackPlayback(0); + setCurrentTime(0); + setIsPlaying(true); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } stopAllPlayback(); setCurrentTime(0); return; @@ -5559,6 +5572,9 @@ const App = () => { hoveredTrackIdRef.current = hoveredTrackId; const captureTrackSnapshotRef = useRef(null); captureTrackSnapshotRef.current = captureTrackSnapshot; + let clipSeqCounter = 0; + const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; + const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { const track = tracks.find(t => t.id === trackId); if (!track) return; @@ -5571,48 +5587,27 @@ const App = () => { const clip = existingClips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + track.id); if (!clip) return; const beforeSnap = captureTrackSnapshot(trackId); - let targetClipId = clip.id; if (isDuplicate) { - const cloneId = clip.id + '_copy_' + Date.now(); - const clone = { - ...clip, - id: cloneId, - name: clip.name + ' (Copy)' - }; + setDraggedClip({ + trackId, clipId: clip.id, clickOffset, + buffer: clip.buffer, name: clip.name, beforeSnap, + isDuplicate: true, + origTrackId: trackId, origClipId: clip.id + }); + return; + } + if (!track.clips || track.clips.length === 0) { setTracks(prev => prev.map(t => { if (t.id === trackId) { - const newClips = [...existingClips, clone]; - return { - ...t, - clips: newClips, - buffer: newClips[0].buffer, - startTime: newClips[0].startTime, - name: newClips[0].name - }; + return { ...t, clips: existingClips }; } return t; })); - targetClipId = cloneId; - } else { - if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - return { - ...t, - clips: existingClips - }; - } - return t; - })); - } } setDraggedClip({ - trackId: trackId, - clipId: targetClipId, - clickOffset: clickOffset, - buffer: clip.buffer, - name: clip.name, - beforeSnap: beforeSnap + trackId, clipId: clip.id, clickOffset, + buffer: clip.buffer, name: clip.name, beforeSnap, + isDuplicate: false }); }; const stretchedClipRef = useRef(null); @@ -5769,10 +5764,22 @@ const App = () => { const handleMouseUp = () => { const drag = draggedClipRef.current; if (!drag) return; + if (drag.isDuplicate) { + const cloneId = nextClipId(); + const clone = { id: cloneId, buffer: drag.buffer, startTime: 0, name: drag.name.replace(/\.\w+$/, '') + ' (Copy)' }; + setTracks(prev => prev.map(t => { + if (t.id === drag.trackId) { + const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ id: 'default_' + t.id, buffer: t.buffer, startTime: t.startTime || 0, name: t.name }] : []); + return { ...t, clips: [...clips, clone], buffer: clips.length > 0 ? clips[0].buffer : drag.buffer }; + } + return t; + })); + setDraggedClip({ ...drag, clipId: cloneId, isDuplicate: false }); + } const afterSnap = captureTrackSnapshotRef.current(drag.trackId); - pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); + pushAction(drag.isDuplicate ? 'DUPLICATE_CLIP' : 'MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); setDraggedClip(null); - showToast('Đã di chuyển clip.', 'success'); + showToast(drag.isDuplicate ? 'Đã sao chép clip.' : 'Đã di chuyển clip.', 'success'); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); @@ -6074,6 +6081,13 @@ const App = () => { return next; }); }; + const updateClipName = (trackId, clipId, newName) => { + setTracks(prev => prev.map(t => { + if (t.id !== trackId) return t; + const clips = t.clips && t.clips.length > 0 ? t.clips : []; + return { ...t, clips: clips.map(c => c.id === clipId ? { ...c, name: newName } : c) }; + })); + }; // ── Load File on Track (with server upload) ── const loadFileOnTrack = async (trackId, file) => { @@ -7238,7 +7252,7 @@ const App = () => { const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = 0; - const clipId = 'clip_' + Date.now(); + const clipId = nextClipId(); setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ id: 'default_' + t.id, buffer: t.buffer, startTime: t.startTime || 0, name: t.name }] : []); @@ -7547,9 +7561,11 @@ const App = () => { panelPositions, rightSidebarWidth, mediaExplorerHeight, selectedProviderId }; useEffect(() => { - if (!currentUser) return; + const prefs = prefsRef.current; + localStorage.setItem('sonic_preferences', JSON.stringify(prefs)); + if (!currentUser || currentUser === 'cached') return; const timer = setTimeout(async () => { - try { await window.SonicAPI.savePreferences(prefsRef.current); } catch (e) {} + try { await window.SonicAPI.savePreferences(prefs); } catch (e) {} }, 2000); return () => clearTimeout(timer); }, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, @@ -8165,7 +8181,7 @@ const App = () => { }), /*#__PURE__*/React.createElement("button", { onClick: () => setIsLoopingSelection(prev => !prev), className: `w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, - title: "Bật/Tắt Lặp vùng chọn" + title: isLoopingSelection ? (selLeft !== null && selRight !== null ? "Loop vùng chọn" : "Loop timeline") : "Bật loop" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { @@ -8955,7 +8971,33 @@ const App = () => { setSelectionEnd(end); showToast(`Selected: ${c.name || track.name}`, 'info'); } - }, c.name || track.name))), /*#__PURE__*/React.createElement("div", { + }, c.name || track.name), editingClipName && editingClipName.trackId === track.id && editingClipName.clipId === c.id ? /*#__PURE__*/React.createElement("input", { + type: "text", + value: editNameInput, + autoFocus: true, + onChange: e => setEditNameInput(e.target.value), + onBlur: () => { + if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); + setEditingClipName(null); + }, + onKeyDown: e => { + if (e.key === 'Enter') { if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); setEditingClipName(null); } + if (e.key === 'Escape') setEditingClipName(null); + }, + onClick: e => e.stopPropagation(), + className: "w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none" + }) : /*#__PURE__*/React.createElement("button", { + className: "text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0", + title: "Sửa tên clip", + onClick: e => { + e.stopPropagation(); + setEditingClipName({ trackId: track.id, clipId: c.id }); + setEditNameInput(c.name || track.name); + } + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "pencil", + className: "w-2.5 h-2.5" + })))), /*#__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 c6b98ab..2153860 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -3074,6 +3074,7 @@ const App = () => { const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited + const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId } const [editNameInput, setEditNameInput] = useState(''); // ── Context Menu & Clipboard ── @@ -3250,24 +3251,31 @@ const App = () => { setIsMandatoryLogin(false); setAuthModalOpen(false); loadPendingSfsProject(); + const loadPrefs = p => { + if (!p) return; + if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); + if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); + if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); + if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); + if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); + if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); + if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); + if (p.panelPositions) setPanelPositions(p.panelPositions); + if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); + if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); + if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); + }; (async () => { try { const data = await window.SonicAPI.getPreferences(); - if (data && data.preferences) { - const p = data.preferences; - if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); - if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); - if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); - if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); - if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); - if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); - if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); - if (p.panelPositions) setPanelPositions(p.panelPositions); - if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); - if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); - if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); + if (data && data.preferences) loadPrefs(data.preferences);else { + const cached = localStorage.getItem('sonic_preferences'); + if (cached) loadPrefs(JSON.parse(cached)); } - } catch (e) {/* preferences not available */} + } catch (e) { + const cached = localStorage.getItem('sonic_preferences'); + if (cached) loadPrefs(JSON.parse(cached)); + } })(); } }; @@ -4639,10 +4647,10 @@ const App = () => { const ctx = getAudioContext(); const targetTrack = tracks.find(t => t.id === targetTrackId); const newClip = { - id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000), + id: nextClipId(), startTime: pasteTime, buffer: clipBuffer, - name: name || 'Pasted Clip' + name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)' }; if (targetTrack) { setTracks(p => p.map(t => { @@ -5219,6 +5227,16 @@ const App = () => { } } if (updatedTime >= maxDurationRef.current) { + if (isLoopingSelection) { + stopAllPlayback(); + startOffsetTimeRef.current = 0; + startAudioTimeRef.current = context.currentTime; + startTrackPlayback(0); + setCurrentTime(0); + setIsPlaying(true); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } stopAllPlayback(); setCurrentTime(0); return; @@ -5560,6 +5578,8 @@ const App = () => { hoveredTrackIdRef.current = hoveredTrackId; const captureTrackSnapshotRef = useRef(null); captureTrackSnapshotRef.current = captureTrackSnapshot; + let clipSeqCounter = 0; + const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { const track = tracks.find(t => t.id === trackId); if (!track) return; @@ -5572,48 +5592,39 @@ const App = () => { const clip = existingClips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + track.id); if (!clip) return; const beforeSnap = captureTrackSnapshot(trackId); - let targetClipId = clip.id; if (isDuplicate) { - const cloneId = clip.id + '_copy_' + Date.now(); - const clone = { - ...clip, - id: cloneId, - name: clip.name + ' (Copy)' - }; + setDraggedClip({ + trackId, + clipId: clip.id, + clickOffset, + buffer: clip.buffer, + name: clip.name, + beforeSnap, + isDuplicate: true, + origTrackId: trackId, + origClipId: clip.id + }); + return; + } + if (!track.clips || track.clips.length === 0) { setTracks(prev => prev.map(t => { if (t.id === trackId) { - const newClips = [...existingClips, clone]; return { ...t, - clips: newClips, - buffer: newClips[0].buffer, - startTime: newClips[0].startTime, - name: newClips[0].name + clips: existingClips }; } return t; })); - targetClipId = cloneId; - } else { - if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - return { - ...t, - clips: existingClips - }; - } - return t; - })); - } } setDraggedClip({ - trackId: trackId, - clipId: targetClipId, - clickOffset: clickOffset, + trackId, + clipId: clip.id, + clickOffset, buffer: clip.buffer, name: clip.name, - beforeSnap: beforeSnap + beforeSnap, + isDuplicate: false }); }; const stretchedClipRef = useRef(null); @@ -5770,10 +5781,40 @@ const App = () => { const handleMouseUp = () => { const drag = draggedClipRef.current; if (!drag) return; + if (drag.isDuplicate) { + const cloneId = nextClipId(); + const clone = { + id: cloneId, + buffer: drag.buffer, + startTime: 0, + name: drag.name.replace(/\.\w+$/, '') + ' (Copy)' + }; + setTracks(prev => prev.map(t => { + if (t.id === drag.trackId) { + const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default_' + t.id, + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name + }] : []; + return { + ...t, + clips: [...clips, clone], + buffer: clips.length > 0 ? clips[0].buffer : drag.buffer + }; + } + return t; + })); + setDraggedClip({ + ...drag, + clipId: cloneId, + isDuplicate: false + }); + } const afterSnap = captureTrackSnapshotRef.current(drag.trackId); - pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); + pushAction(drag.isDuplicate ? 'DUPLICATE_CLIP' : 'MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); setDraggedClip(null); - showToast('Đã di chuyển clip.', 'success'); + showToast(drag.isDuplicate ? 'Đã sao chép clip.' : 'Đã di chuyển clip.', 'success'); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); @@ -6075,6 +6116,19 @@ const App = () => { return next; }); }; + const updateClipName = (trackId, clipId, newName) => { + setTracks(prev => prev.map(t => { + if (t.id !== trackId) return t; + const clips = t.clips && t.clips.length > 0 ? t.clips : []; + return { + ...t, + clips: clips.map(c => c.id === clipId ? { + ...c, + name: newName + } : c) + }; + })); + }; // ── Load File on Track (with server upload) ── const loadFileOnTrack = async (trackId, file) => { @@ -7296,7 +7350,7 @@ const App = () => { const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = 0; - const clipId = 'clip_' + Date.now(); + const clipId = nextClipId(); setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ @@ -7764,10 +7818,12 @@ const App = () => { selectedProviderId }; useEffect(() => { - if (!currentUser) return; + const prefs = prefsRef.current; + localStorage.setItem('sonic_preferences', JSON.stringify(prefs)); + if (!currentUser || currentUser === 'cached') return; const timer = setTimeout(async () => { try { - await window.SonicAPI.savePreferences(prefsRef.current); + await window.SonicAPI.savePreferences(prefs); } catch (e) {} }, 2000); return () => clearTimeout(timer); @@ -8382,7 +8438,7 @@ const App = () => { }), /*#__PURE__*/React.createElement("button", { onClick: () => setIsLoopingSelection(prev => !prev), className: `w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, - title: "Bật/Tắt Lặp vùng chọn" + title: isLoopingSelection ? selLeft !== null && selRight !== null ? "Loop vùng chọn" : "Loop timeline" : "Bật loop" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { @@ -9178,7 +9234,39 @@ const App = () => { setSelectionEnd(end); showToast(`Selected: ${c.name || track.name}`, 'info'); } - }, c.name || track.name))), /*#__PURE__*/React.createElement("div", { + }, c.name || track.name), editingClipName && editingClipName.trackId === track.id && editingClipName.clipId === c.id ? /*#__PURE__*/React.createElement("input", { + type: "text", + value: editNameInput, + autoFocus: true, + onChange: e => setEditNameInput(e.target.value), + onBlur: () => { + if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); + setEditingClipName(null); + }, + onKeyDown: e => { + if (e.key === 'Enter') { + if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); + setEditingClipName(null); + } + if (e.key === 'Escape') setEditingClipName(null); + }, + onClick: e => e.stopPropagation(), + className: "w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none" + }) : /*#__PURE__*/React.createElement("button", { + className: "text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0", + title: "Sửa tên clip", + onClick: e => { + e.stopPropagation(); + setEditingClipName({ + trackId: track.id, + clipId: c.id + }); + setEditNameInput(c.name || track.name); + } + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "pencil", + className: "w-2.5 h-2.5" + })))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("button", { onClick: e => { diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 0fa4385..43baa1b 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ