5 Commits

4 changed files with 1144 additions and 94 deletions
+578 -54
View File
@@ -184,6 +184,9 @@ const WaveformLane = ({
onContextMenu,
onClipDragStart,
onClipStretchStart,
onSectionItemDragStart,
onSectionItemResizeStart,
onEditSectionInTab,
onSelectionEdgeDragStart,
setSelectedClipId,
selectedClipId,
@@ -440,6 +443,40 @@ const WaveformLane = ({
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2);
}
// Draw sections
const sections = track.sections || [];
sections.forEach(sec => {
const secStartLocal = sec.start * zoom - scrollLeft;
const secWidth = sec.duration * zoom;
if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return;
ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)';
ctx.fillRect(secStartLocal, 2, secWidth, height - 4);
ctx.strokeStyle = sec.color || '#06b6d4';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(secStartLocal, 2, secWidth, height - 4);
ctx.setLineDash([]);
ctx.fillStyle = '#e4e4e7';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14);
});
// Draw MIDI items
const midiItems = track.midiItems || [];
midiItems.forEach(midi => {
const midiStartLocal = midi.startTime * zoom - scrollLeft;
const midiWidth = midi.duration * zoom;
if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return;
ctx.fillStyle = '#a78bfa33';
ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.strokeStyle = '#a78bfa';
ctx.lineWidth = 1.5;
ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.fillStyle = '#c4b5fd';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14);
});
// Selection highlight - local selection on this track
if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
const hlLeftLocal = localSelLeft * zoom - scrollLeft;
@@ -521,6 +558,49 @@ const WaveformLane = ({
canvasRef.current.style.cursor = 'ew-resize';
return;
}
// Check section/MIDI item hover for resize or drag
const allSections = track.sections || [];
const allMidiItems = track.midiItems || [];
const sectionTolerance = 8 / zoom;
let foundSectionItem = null;
let sectionItemEdge = null;
const checkEdge = (item, startTime, dur) => {
const leftEdge = Math.abs(time - startTime) <= sectionTolerance;
const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance;
if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right';
return null;
};
for (const sec of allSections) {
const edge = checkEdge(sec, sec.start, sec.duration);
if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; }
}
if (!foundSectionItem) {
for (const midi of allMidiItems) {
const edge = checkEdge(midi, midi.startTime, midi.duration);
if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; }
}
}
if (foundSectionItem && sectionItemEdge) {
canvasRef.current.style.cursor = 'ew-resize';
return;
}
// Check body hover for drag
if (!foundSectionItem) {
for (const sec of allSections) {
if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; }
}
}
if (!foundSectionItem) {
for (const midi of allMidiItems) {
if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; }
}
}
if (foundSectionItem) {
canvasRef.current.style.cursor = 'grab';
return;
}
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
const isOverClip = !!hoveredClip;
if (activeTool === 'pen') {
@@ -613,6 +693,46 @@ const WaveformLane = ({
}
return;
}
// Check section/MIDI item edge for resize, then body for drag
const secItems = track.sections || [];
const midiItems = track.midiItems || [];
const secTol = 8 / zoom;
let hitItem = null;
let hitEdge = null;
for (const sec of secItems) {
if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; }
if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; }
}
if (!hitItem) {
for (const midi of midiItems) {
if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; }
if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; }
}
}
if (hitItem && hitEdge) {
e.preventDefault();
e.stopPropagation();
if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time);
return;
}
if (!hitItem) {
for (const sec of secItems) {
if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; }
}
}
if (!hitItem) {
for (const midi of midiItems) {
if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; }
}
}
if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start);
return;
}
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
// Set selected clip ID
@@ -695,6 +815,18 @@ const WaveformLane = ({
name: track.name,
speed: track.speed || 1.0
}] : [];
// Check double-click on section first
const dblSecItems = track.sections || [];
let dblSecHit = null;
for (const sec of dblSecItems) {
if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; }
}
if (dblSecHit) {
e.preventDefault();
e.stopPropagation();
if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id);
return;
}
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
if (clickedClip) {
e.preventDefault();
@@ -711,7 +843,13 @@ const WaveformLane = ({
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
if (onContextMenu) onContextMenu(e, track.id, time);
// Detect section under cursor
const secList = track.sections || [];
let hitSectionId = null;
for (const sec of secList) {
if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; }
}
if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId);
}
}));
};
@@ -3384,7 +3522,9 @@ const App = () => {
color: '#0f766e',
markers: [],
serverFileId: null,
clips: []
clips: [],
sections: [],
midiItems: []
}, {
id: '2',
name: 'Track 02',
@@ -3397,7 +3537,10 @@ const App = () => {
solo: false,
color: '#1d4ed8',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}]);
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
@@ -3521,6 +3664,8 @@ const App = () => {
const [menuOpen, setMenuOpen] = useState(null);
const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId }
const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap }
const [draggedSectionItem, setDraggedSectionItem] = useState(null);
const [resizedSectionItem, setResizedSectionItem] = useState(null);
const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited
const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId }
const [editNameInput, setEditNameInput] = useState('');
@@ -3601,6 +3746,11 @@ const App = () => {
const [subTabGainVal, setSubTabGainVal] = useState(100);
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
const [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...]
const activeTracks = useMemo(() => {
const st = sessionTabs.find(s => s.id === activeTab);
return st ? st.tracks : tracks;
}, [activeTab, sessionTabs, tracks]);
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
@@ -4567,6 +4717,27 @@ const App = () => {
setActiveTab(tabId);
};
// Double-click/Edit Section: open Main Session in new tab
const handleEditSectionInTab = (trackId, sectionId) => {
const track = tracks.find(t => t.id === trackId);
if (!track) return;
const section = (track.sections || []).find(s => s.id === sectionId);
if (!section) return;
const existing = sessionTabs.find(s => s.sectionId === sectionId);
if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; }
const tabId = 'session_' + Date.now();
const tabName = section.name || 'Section';
const clonedTracks = tracks.map(t => ({
...t,
clips: [],
sections: [],
midiItems: [],
markers: []
}));
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]);
setActiveTab(tabId);
};
// Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2)
const applySubTab = tabId => {
const subTab = subTabs.find(s => s.id === tabId);
@@ -4902,6 +5073,10 @@ const App = () => {
setSubTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
};
const closeSessionTab = tabId => {
setSessionTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
};
const updateSubTabEffects = (tabId, effects) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? {
...s,
@@ -4947,18 +5122,26 @@ const App = () => {
};
// Context Menu Handlers
const handleContextMenu = (e, trackId, clickTime) => {
const handleContextMenu = (e, trackId, clickTime, sectionId) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
trackId,
time: clickTime || currentTime
time: clickTime || currentTime,
sectionId: sectionId || null
});
};
const closeContextMenu = () => setContextMenu(null);
const contextMenuEditSection = () => {
if (contextMenu.sectionId) {
handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId);
}
closeContextMenu();
};
// Close context menu on any click outside
useEffect(() => {
const handler = () => {
@@ -4993,15 +5176,27 @@ const App = () => {
};
const contextMenuDelete = () => {
const tid = contextMenu.trackId;
const beforeSnap = captureTrackSnapshot(tid);
setTracks(prev => prev.filter(t => t.id !== tid));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE', tid, beforeSnap, afterSnap);
if (selectedTrackId === tid) {
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
if (selectedTrackId === tid) setSelectedTrackId('1');
closeContextMenu();
showToast('Đã xoá track.', 'info');
} else {
const track = tracks.find(t => t.id === tid);
if (track && track.sections && track.sections.length > 0) {
closeContextMenu();
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
const beforeSnap = captureTrackSnapshot(tid);
setTracks(prev => prev.filter(t => t.id !== tid));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE', tid, beforeSnap, afterSnap);
if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
closeContextMenu();
showToast('Đã xoá track.', 'info');
}
closeContextMenu();
showToast('Đã xoá track.', 'info');
};
const contextMenuCopy = () => {
const track = tracks.find(t => t.id === contextMenu.trackId);
@@ -5375,8 +5570,20 @@ const App = () => {
};
const handleDeleteTrack = () => {
const tid = selectedTrackId;
setTracks(p => p.filter(t => t.id !== tid));
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1');
} else {
const curTracks = tracks;
const track = curTracks.find(t => t.id === tid);
if (track && track.sections && track.sections.length > 0) {
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
setTracks(p => p.filter(t => t.id !== tid));
setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1');
}
showToast('Deleted track.', 'info');
};
@@ -6107,6 +6314,10 @@ const App = () => {
hoveredTrackIdRef.current = hoveredTrackId;
const captureTrackSnapshotRef = useRef(null);
captureTrackSnapshotRef.current = captureTrackSnapshot;
const draggedSectionItemRef = useRef(null);
draggedSectionItemRef.current = draggedSectionItem;
const resizedSectionItemRef = useRef(null);
resizedSectionItemRef.current = resizedSectionItem;
let clipSeqCounter = 0;
const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`;
@@ -6228,14 +6439,26 @@ const App = () => {
document.addEventListener('mouseup', handleMouseUp);
};
const deleteTrack = trackId => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
if (filtered.length > 0) {
setSelectedTrackId(filtered[0].id);
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
updateActiveTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
return filtered;
});
} else {
const track = tracks.find(t => t.id === trackId);
if (track && track.sections && track.sections.length > 0) {
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
return filtered;
});
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
return filtered;
});
}
showToast('Đã xóa track.', 'info');
};
useEffect(() => {
@@ -6369,6 +6592,123 @@ const App = () => {
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom]);
// Section / MIDI Item Drag Start
const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => {
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset });
};
// Section / MIDI Item Resize Start
const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => {
const curTracks = activeTracks;
const track = curTracks.find(t => t.id === trackId);
if (!track) return;
const items = itemType === 'section' ? track.sections : track.midiItems;
const item = (items || []).find(it => it.id === itemId);
if (!item) return;
const start = itemType === 'section' ? item.start : item.startTime;
setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration });
};
// Document-level mousemove/mouseup for Section/MIDI item drag
useEffect(() => {
const handleMouseMove = e => {
const drag = draggedSectionItemRef.current;
if (!drag) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const newStart = Math.max(0, time - drag.clickOffset);
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
updateActiveTracks(prev => prev.map(t => {
const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []);
const updatedItems = items.filter(it => it.id !== drag.itemId);
if (t.id === targetTrackId) {
const movedItem = items.find(it => it.id === drag.itemId);
if (movedItem) {
updatedItems.push(drag.itemType === 'section'
? { ...movedItem, start: newStart }
: { ...movedItem, startTime: newStart });
} else {
if (drag.itemType === 'section') {
updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' });
} else {
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' });
}
}
}
return drag.itemType === 'section'
? { ...t, sections: updatedItems }
: { ...t, midiItems: updatedItems };
}));
if (drag.trackId !== targetTrackId) {
setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId }));
}
};
const handleMouseUp = () => {
const drag = draggedSectionItemRef.current;
if (!drag) return;
setDraggedSectionItem(null);
showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success');
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, activeTab, sessionTabs]);
// Document-level mousemove/mouseup for Section/MIDI item resize
useEffect(() => {
const handleMouseMove = e => {
const resize = resizedSectionItemRef.current;
if (!resize) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
updateActiveTracks(prev => prev.map(t => {
if (t.id !== resize.trackId) return t;
const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])];
const idx = items.findIndex(it => it.id === resize.itemId);
if (idx === -1) return t;
const item = items[idx];
if (resize.side === 'left') {
const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1);
const end = resize.originalStart + resize.originalDuration;
const newDuration = end - newStart;
if (newDuration < 0.1) return t;
items[idx] = resize.itemType === 'section'
? { ...item, start: newStart, duration: newDuration }
: { ...item, startTime: newStart, duration: newDuration };
} else {
const newDuration = Math.max(0.1, time - resize.originalStart);
items[idx] = { ...item, duration: newDuration };
}
return resize.itemType === 'section'
? { ...t, sections: items }
: { ...t, midiItems: items };
}));
};
const handleMouseUp = () => {
const resize = resizedSectionItemRef.current;
if (!resize) return;
setResizedSectionItem(null);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, activeTab, sessionTabs]);
const handleSelectRange = (start, end, reset) => {
const maxLen = maxDuration;
const cleanStart = Math.max(0, Math.min(maxLen, start));
@@ -6678,10 +7018,11 @@ const App = () => {
// Add Track
const addNewTrack = () => {
const newId = (tracks.length + 1).toString();
const curTracks = activeTracks;
const newId = (curTracks.length + 1).toString();
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[tracks.length % colors.length];
setTracks(prev => [...prev, {
const selectColor = colors[curTracks.length % colors.length];
updateActiveTracks(prev => [...prev, {
id: newId,
name: `Track ${newId}`,
buffer: null,
@@ -6693,13 +7034,134 @@ const App = () => {
solo: false,
color: selectColor,
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}]);
showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200);
return newId;
};
// Update tracks in active context (main session or section tab)
const updateActiveTracks = updater => {
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st));
} else {
setTracks(updater);
}
};
// Insert Track Below Selected
const insertTrackBelow = () => {
const curTracks = activeTracks;
const newId = `t${Date.now()}`;
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[(curTracks.length) % colors.length];
const newTrack = {
id: newId,
name: `Track ${newId}`,
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
solo: false,
color: selectColor,
markers: [],
serverFileId: null,
clips: [],
sections: [],
midiItems: []
};
updateActiveTracks(prev => {
const idx = prev.findIndex(t => t.id === selectedTrackId);
if (idx === -1) return [...prev, newTrack];
const copy = [...prev];
copy.splice(idx + 1, 0, newTrack);
return copy;
});
setSelectedTrackId(newId);
showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200);
};
// Insert Section at Playhead
const insertSectionAtPlayhead = () => {
const track = tracks.find(t => t.id === selectedTrackId);
if (!track) { showToast('Chọn track trước', 'warning'); return; }
const section = {
id: `sec_${Date.now()}`,
name: 'Section',
start: currentTime,
duration: 4,
color: track.color || '#06b6d4'
};
setTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
sections: [...(t.sections || []), section]
} : t));
showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`, 'success');
};
// Insert MIDI Item at Playhead
const insertMidiItemAtPlayhead = () => {
const curTracks = activeTracks;
const track = curTracks.find(t => t.id === selectedTrackId);
if (!track) { showToast('Chọn track trước', 'warning'); return; }
const midiItem = {
id: `midi_${Date.now()}`,
name: 'MIDI Item',
startTime: currentTime,
duration: 4,
notes: [],
color: '#a78bfa'
};
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
midiItems: [...(t.midiItems || []), midiItem]
} : t));
showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`, 'success');
};
// Insert Sound Clip at Cursor
const insertSoundClipAtCursor = () => {
const curTracks = activeTracks;
const track = curTracks.find(t => t.id === selectedTrackId);
if (!track) { showToast('Chọn track trước', 'warning'); return; }
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*';
input.multiple = true;
input.onchange = async e => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
let offset = currentTime;
let count = 0;
for (const file of files) {
try {
uploadToServer(file, track.id);
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const newClip = { id: clipId, buffer: decodedBuffer, startTime: offset, name: file.name, speed: 1.0 };
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
clips: [...(t.clips || []), newClip]
} : t));
offset += decodedBuffer.duration;
count++;
} catch (err) {
showToast(`Lỗi nạp ${file.name}`, 'error');
}
}
if (count > 0) showToast(`Đã chèn ${count} file âm thanh`, 'success');
};
input.click();
};
// Server-side Export
const triggerWavExport = async () => {
let exportTracks;
@@ -8570,7 +9032,10 @@ const App = () => {
solo: false,
color: '#0f766e',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}, {
id: '2',
name: 'Track 02',
@@ -8583,7 +9048,10 @@ const App = () => {
solo: false,
color: '#1d4ed8',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}]);
setSelectedTrackId('1');
setProjectName('');
@@ -8728,6 +9196,25 @@ const App = () => {
handleDeleteTrack();
}
}]
}, {
label: 'Insert',
items: [...(!sessionTabs.some(s => s.id === activeTab) ? [{
label: 'Insert Section',
icon: 'folder-plus',
action: insertSectionAtPlayhead
}] : []), {
label: 'Insert MIDI item',
icon: 'music',
action: insertMidiItemAtPlayhead
}, {
label: 'Insert sound clip',
icon: 'file-input',
action: insertSoundClipAtCursor
}, {
label: 'Insert track',
icon: 'plus',
action: insertTrackBelow
}]
}, {
label: 'View',
items: [{
@@ -8780,7 +9267,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-64 z-50",
className: `absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label === 'Edit' ? 'w-72' : 'w-64'}`,
onClick: () => setMenuOpen(null)
}, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", {
key: i,
@@ -8829,7 +9316,29 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "layout-dashboard",
className: "w-3 h-3"
})), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
})), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", {
key: st.id,
className: "flex items-stretch"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => setActiveTab(st.id),
className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "layout-dashboard",
className: "w-3 h-3"
})), /*#__PURE__*/React.createElement("span", {
className: "max-w-[120px] truncate"
}, st.name)), /*#__PURE__*/React.createElement("button", {
onClick: () => closeSessionTab(st.id),
className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs",
title: "Close tab"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
}))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
key: st.id,
className: "flex items-stretch"
}, /*#__PURE__*/React.createElement("button", {
@@ -9818,24 +10327,24 @@ const App = () => {
className: "flex-1 flex flex-col overflow-hidden min-w-0"
}, /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex overflow-hidden"
}, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
ref: tcpContainerRef,
onScroll: handleTCPScroll,
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",
style: {
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}
}, /*#__PURE__*/React.createElement("div", {
className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"
}, /*#__PURE__*/React.createElement("span", {
className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "sliders",
className: "w-3.5 h-3.5 text-cyan-400"
})), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", {
}, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
ref: tcpContainerRef,
onScroll: handleTCPScroll,
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",
style: {
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}
}, /*#__PURE__*/React.createElement("div", {
className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"
}, /*#__PURE__*/React.createElement("span", {
className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "sliders",
className: "w-3.5 h-3.5 text-cyan-400"
})), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", {
onClick: addNewTrack,
className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"
}, /*#__PURE__*/React.createElement("span", {
@@ -9867,7 +10376,7 @@ const App = () => {
className: "text-xs text-zinc-500"
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
}, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
}, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
@@ -9882,9 +10391,9 @@ const App = () => {
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "plus",
"data-lucide": "plus-circle",
className: "w-3.5 h-3.5"
})), " Thêm Track Mới")) : tracks.map((track, idx) => {
})), " Thêm Track Mới")) : activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
return /*#__PURE__*/React.createElement("div", {
key: track.id,
@@ -10152,7 +10661,7 @@ const App = () => {
scrollLeft: scrollLeft
}))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"
}, tracks.map((track, idx) => {
}, activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
return /*#__PURE__*/React.createElement("div", {
key: track.id,
@@ -10180,12 +10689,15 @@ const App = () => {
onContextMenu: handleContextMenu,
onClipDragStart: handleClipDragStart,
onClipStretchStart: handleClipStretchStart,
onSectionItemDragStart: handleSectionItemDragStart,
onSectionItemResizeStart: handleSectionItemResizeStart,
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
setSelectedClipId: setSelectedClipId,
selectedClipId: selectedClipId,
activeTool: activeTool,
onSplitTrackAtTime: handleSplitTrackAtTime,
onEditClipInSubTab: handleEditClipInSubTab,
onEditSectionInTab: handleEditSectionInTab,
snapValue: snapValue,
bpm: bpm,
selectionMode: selectionMode,
@@ -10234,7 +10746,7 @@ const App = () => {
}), /*#__PURE__*/React.createElement("div", {
className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800",
onMouseEnter: () => {
if (draggedClipRef.current) {
if (draggedClipRef.current || draggedSectionItemRef.current) {
setHoveredTrackId(addNewTrack());
}
},
@@ -10914,7 +11426,19 @@ const App = () => {
top: contextMenu.y
},
onClick: e => e.stopPropagation()
}, /*#__PURE__*/React.createElement("button", {
}, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", {
onClick: contextMenuEditSection,
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "file-edit",
className: "w-3.5 h-3.5 text-amber-400 shrink-0"
})), /*#__PURE__*/React.createElement("span", {
className: "flex-1"
}, "Edit Section"), /*#__PURE__*/React.createElement("span", {
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
}, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", {
onClick: contextMenuEdit,
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
}, /*#__PURE__*/React.createElement("span", {
+565 -40
View File
@@ -184,6 +184,9 @@ const WaveformLane = ({
onContextMenu,
onClipDragStart,
onClipStretchStart,
onSectionItemDragStart,
onSectionItemResizeStart,
onEditSectionInTab,
onSelectionEdgeDragStart,
setSelectedClipId,
selectedClipId,
@@ -440,6 +443,40 @@ const WaveformLane = ({
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2);
}
// Draw sections
const sections = track.sections || [];
sections.forEach(sec => {
const secStartLocal = sec.start * zoom - scrollLeft;
const secWidth = sec.duration * zoom;
if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return;
ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)';
ctx.fillRect(secStartLocal, 2, secWidth, height - 4);
ctx.strokeStyle = sec.color || '#06b6d4';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(secStartLocal, 2, secWidth, height - 4);
ctx.setLineDash([]);
ctx.fillStyle = '#e4e4e7';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14);
});
// Draw MIDI items
const midiItems = track.midiItems || [];
midiItems.forEach(midi => {
const midiStartLocal = midi.startTime * zoom - scrollLeft;
const midiWidth = midi.duration * zoom;
if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return;
ctx.fillStyle = '#a78bfa33';
ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.strokeStyle = '#a78bfa';
ctx.lineWidth = 1.5;
ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.fillStyle = '#c4b5fd';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14);
});
// Selection highlight - local selection on this track
if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
const hlLeftLocal = localSelLeft * zoom - scrollLeft;
@@ -521,6 +558,49 @@ const WaveformLane = ({
canvasRef.current.style.cursor = 'ew-resize';
return;
}
// Check section/MIDI item hover for resize or drag
const allSections = track.sections || [];
const allMidiItems = track.midiItems || [];
const sectionTolerance = 8 / zoom;
let foundSectionItem = null;
let sectionItemEdge = null;
const checkEdge = (item, startTime, dur) => {
const leftEdge = Math.abs(time - startTime) <= sectionTolerance;
const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance;
if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right';
return null;
};
for (const sec of allSections) {
const edge = checkEdge(sec, sec.start, sec.duration);
if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; }
}
if (!foundSectionItem) {
for (const midi of allMidiItems) {
const edge = checkEdge(midi, midi.startTime, midi.duration);
if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; }
}
}
if (foundSectionItem && sectionItemEdge) {
canvasRef.current.style.cursor = 'ew-resize';
return;
}
// Check body hover for drag
if (!foundSectionItem) {
for (const sec of allSections) {
if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; }
}
}
if (!foundSectionItem) {
for (const midi of allMidiItems) {
if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; }
}
}
if (foundSectionItem) {
canvasRef.current.style.cursor = 'grab';
return;
}
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
const isOverClip = !!hoveredClip;
if (activeTool === 'pen') {
@@ -613,6 +693,46 @@ const WaveformLane = ({
}
return;
}
// Check section/MIDI item edge for resize, then body for drag
const secItems = track.sections || [];
const midiItemsArray = track.midiItems || [];
const secTol = 8 / zoom;
let hitItem = null;
let hitEdge = null;
for (const sec of secItems) {
if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; }
if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; }
}
if (!hitItem) {
for (const midi of midiItemsArray) {
if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; }
if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; }
}
}
if (hitItem && hitEdge) {
e.preventDefault();
e.stopPropagation();
if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time);
return;
}
if (!hitItem) {
for (const sec of secItems) {
if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; }
}
}
if (!hitItem) {
for (const midi of midiItemsArray) {
if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; }
}
}
if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start);
return;
}
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
// Set selected clip ID
@@ -695,6 +815,18 @@ const WaveformLane = ({
name: track.name,
speed: track.speed || 1.0
}] : [];
// Check double-click on section first
const dblSecItems = track.sections || [];
let dblSecHit = null;
for (const sec of dblSecItems) {
if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; }
}
if (dblSecHit) {
e.preventDefault();
e.stopPropagation();
if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id);
return;
}
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
if (clickedClip) {
e.preventDefault();
@@ -711,7 +843,12 @@ const WaveformLane = ({
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
if (onContextMenu) onContextMenu(e, track.id, time);
const secList = track.sections || [];
let hitSectionId = null;
for (const sec of secList) {
if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; }
}
if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId);
}
}));
};
@@ -3444,7 +3581,10 @@ const App = () => {
solo: false,
color: '#1d4ed8',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}]);
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
@@ -3574,6 +3714,8 @@ const App = () => {
const [menuOpen, setMenuOpen] = useState(null);
const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId }
const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap }
const [draggedSectionItem, setDraggedSectionItem] = useState(null);
const [resizedSectionItem, setResizedSectionItem] = useState(null);
const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited
const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId }
const [editNameInput, setEditNameInput] = useState('');
@@ -3654,6 +3796,11 @@ const App = () => {
const [subTabGainVal, setSubTabGainVal] = useState(100);
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
const [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...]
const activeTracks = useMemo(() => {
const st = sessionTabs.find(s => s.id === activeTab);
return st ? st.tracks : tracks;
}, [activeTab, sessionTabs, tracks]);
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
@@ -4333,7 +4480,10 @@ const App = () => {
solo: false,
color: '#0f766e',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}, {
id: '2',
name: 'Track 02',
@@ -4346,10 +4496,11 @@ const App = () => {
solo: false,
color: '#1d4ed8',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}]);
setSelectedTrackId('1');
showToast('New project created', 'info');
return;
}
if (ctrl && !alt && e.key === 's') {
@@ -4625,6 +4776,27 @@ const App = () => {
setActiveTab(tabId);
};
// ── Double-click/Edit Section: open Main Session in new tab ──
const handleEditSectionInTab = (trackId, sectionId) => {
const track = tracks.find(t => t.id === trackId);
if (!track) return;
const section = (track.sections || []).find(s => s.id === sectionId);
if (!section) return;
const existing = sessionTabs.find(s => s.sectionId === sectionId);
if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; }
const tabId = 'session_' + Date.now();
const tabName = section.name || 'Section';
const clonedTracks = tracks.map(t => ({
...t,
clips: [],
sections: [],
midiItems: [],
markers: []
}));
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]);
setActiveTab(tabId);
};
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
const applySubTab = tabId => {
const subTab = subTabs.find(s => s.id === tabId);
@@ -4960,6 +5132,10 @@ const App = () => {
setSubTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
};
const closeSessionTab = tabId => {
setSessionTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
};
const updateSubTabEffects = (tabId, effects) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? {
...s,
@@ -5005,18 +5181,26 @@ const App = () => {
};
// ── Context Menu Handlers ──
const handleContextMenu = (e, trackId, clickTime) => {
const handleContextMenu = (e, trackId, clickTime, sectionId) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
trackId,
time: clickTime || currentTime
time: clickTime || currentTime,
sectionId: sectionId || null
});
};
const closeContextMenu = () => setContextMenu(null);
const contextMenuEditSection = () => {
if (contextMenu.sectionId) {
handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId);
}
closeContextMenu();
};
// Close context menu on any click outside
useEffect(() => {
const handler = () => {
@@ -5051,15 +5235,27 @@ const App = () => {
};
const contextMenuDelete = () => {
const tid = contextMenu.trackId;
const beforeSnap = captureTrackSnapshot(tid);
setTracks(prev => prev.filter(t => t.id !== tid));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE', tid, beforeSnap, afterSnap);
if (selectedTrackId === tid) {
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
if (selectedTrackId === tid) setSelectedTrackId('1');
closeContextMenu();
showToast('Đã xoá track.', 'info');
} else {
const track = tracks.find(t => t.id === tid);
if (track && track.sections && track.sections.length > 0) {
closeContextMenu();
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
const beforeSnap = captureTrackSnapshot(tid);
setTracks(prev => prev.filter(t => t.id !== tid));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE', tid, beforeSnap, afterSnap);
if (selectedTrackId === tid) setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
closeContextMenu();
showToast('Đã xoá track.', 'info');
}
closeContextMenu();
showToast('Đã xoá track.', 'info');
};
const contextMenuCopy = () => {
const track = tracks.find(t => t.id === contextMenu.trackId);
@@ -5441,8 +5637,20 @@ const App = () => {
};
const handleDeleteTrack = () => {
const tid = selectedTrackId;
setTracks(p => p.filter(t => t.id !== tid));
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1');
} else {
const curTracks = tracks;
const track = curTracks.find(t => t.id === tid);
if (track && track.sections && track.sections.length > 0) {
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
setTracks(p => p.filter(t => t.id !== tid));
setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1');
}
showToast('Deleted track.', 'info');
};
@@ -6172,6 +6380,10 @@ const App = () => {
hoveredTrackIdRef.current = hoveredTrackId;
const captureTrackSnapshotRef = useRef(null);
captureTrackSnapshotRef.current = captureTrackSnapshot;
const draggedSectionItemRef = useRef(null);
draggedSectionItemRef.current = draggedSectionItem;
const resizedSectionItemRef = useRef(null);
resizedSectionItemRef.current = resizedSectionItem;
let clipSeqCounter = 0;
const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`;
const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => {
@@ -6315,14 +6527,26 @@ const App = () => {
document.addEventListener('mouseup', handleMouseUp);
};
const deleteTrack = trackId => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
if (filtered.length > 0) {
setSelectedTrackId(filtered[0].id);
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
updateActiveTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
return filtered;
});
} else {
const track = tracks.find(t => t.id === trackId);
if (track && track.sections && track.sections.length > 0) {
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
return filtered;
});
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
if (filtered.length > 0) setSelectedTrackId(filtered[0].id);
return filtered;
});
}
showToast('Đã xóa track.', 'info');
};
useEffect(() => {
@@ -6456,6 +6680,123 @@ const App = () => {
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom]);
// ── Section / MIDI Item Drag Start ──
const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => {
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset });
};
// ── Section / MIDI Item Resize Start ──
const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => {
const curTracks = activeTracks;
const track = curTracks.find(t => t.id === trackId);
if (!track) return;
const items = itemType === 'section' ? track.sections : track.midiItems;
const item = (items || []).find(it => it.id === itemId);
if (!item) return;
const start = itemType === 'section' ? item.start : item.startTime;
setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration });
};
// ── Document-level mousemove/mouseup for Section/MIDI item drag ──
useEffect(() => {
const handleMouseMove = e => {
const drag = draggedSectionItemRef.current;
if (!drag) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const newStart = Math.max(0, time - drag.clickOffset);
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
updateActiveTracks(prev => prev.map(t => {
const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []);
const updatedItems = items.filter(it => it.id !== drag.itemId);
if (t.id === targetTrackId) {
const movedItem = items.find(it => it.id === drag.itemId);
if (movedItem) {
updatedItems.push(drag.itemType === 'section'
? { ...movedItem, start: newStart }
: { ...movedItem, startTime: newStart });
} else {
if (drag.itemType === 'section') {
updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' });
} else {
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' });
}
}
}
return drag.itemType === 'section'
? { ...t, sections: updatedItems }
: { ...t, midiItems: updatedItems };
}));
if (drag.trackId !== targetTrackId) {
setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId }));
}
};
const handleMouseUp = () => {
const drag = draggedSectionItemRef.current;
if (!drag) return;
setDraggedSectionItem(null);
showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success');
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, activeTab, sessionTabs]);
// ── Document-level mousemove/mouseup for Section/MIDI item resize ──
useEffect(() => {
const handleMouseMove = e => {
const resize = resizedSectionItemRef.current;
if (!resize) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
updateActiveTracks(prev => prev.map(t => {
if (t.id !== resize.trackId) return t;
const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])];
const idx = items.findIndex(it => it.id === resize.itemId);
if (idx === -1) return t;
const item = items[idx];
if (resize.side === 'left') {
const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1);
const end = resize.originalStart + resize.originalDuration;
const newDuration = end - newStart;
if (newDuration < 0.1) return t;
items[idx] = resize.itemType === 'section'
? { ...item, start: newStart, duration: newDuration }
: { ...item, startTime: newStart, duration: newDuration };
} else {
const newDuration = Math.max(0.1, time - resize.originalStart);
items[idx] = { ...item, duration: newDuration };
}
return resize.itemType === 'section'
? { ...t, sections: items }
: { ...t, midiItems: items };
}));
};
const handleMouseUp = () => {
const resize = resizedSectionItemRef.current;
if (!resize) return;
setResizedSectionItem(null);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, activeTab, sessionTabs]);
const handleSelectRange = (start, end, reset) => {
const maxLen = maxDuration;
const cleanStart = Math.max(0, Math.min(maxLen, start));
@@ -6769,12 +7110,23 @@ const App = () => {
showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success');
};
// ── Update tracks in active context (main session or section tab) ──
const updateActiveTracks = updater => {
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st));
} else {
setTracks(updater);
}
};
// ── Add Track ──
const addNewTrack = () => {
const newId = (tracks.length + 1).toString();
const curTracks = activeTracks;
const newId = (curTracks.length + 1).toString();
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[tracks.length % colors.length];
setTracks(prev => [...prev, {
const selectColor = colors[curTracks.length % colors.length];
updateActiveTracks(prev => [...prev, {
id: newId,
name: `Track ${newId}`,
buffer: null,
@@ -6786,13 +7138,124 @@ const App = () => {
solo: false,
color: selectColor,
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}]);
showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200);
return newId;
};
// ── Insert Track Below Selected ──
const insertTrackBelow = () => {
const curTracks = activeTracks;
const newId = `t${Date.now()}`;
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[curTracks.length % colors.length];
const newTrack = {
id: newId,
name: `Track ${newId}`,
buffer: null,
startTime: 0,
height: 128,
volumeDb: 0,
pan: 0,
muted: false,
solo: false,
color: selectColor,
markers: [],
serverFileId: null,
clips: [],
sections: [],
midiItems: []
};
updateActiveTracks(prev => {
const idx = prev.findIndex(t => t.id === selectedTrackId);
if (idx === -1) return [...prev, newTrack];
const copy = [...prev];
copy.splice(idx + 1, 0, newTrack);
return copy;
});
setSelectedTrackId(newId);
showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200);
};
// ── Insert Section at Playhead ──
const insertSectionAtPlayhead = () => {
const track = tracks.find(t => t.id === selectedTrackId);
if (!track) { showToast('Chọn track trước', 'warning'); return; }
const section = {
id: `sec_${Date.now()}`,
name: 'Section',
start: currentTime,
duration: 4,
color: track.color || '#06b6d4'
};
setTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
sections: [...(t.sections || []), section]
} : t));
showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`, 'success');
};
// ── Insert MIDI Item at Playhead ──
const insertMidiItemAtPlayhead = () => {
const curTracks = activeTracks;
const track = curTracks.find(t => t.id === selectedTrackId);
if (!track) { showToast('Chọn track trước', 'warning'); return; }
const midiItem = {
id: `midi_${Date.now()}`,
name: 'MIDI Item',
startTime: currentTime,
duration: 4,
notes: [],
color: '#a78bfa'
};
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
midiItems: [...(t.midiItems || []), midiItem]
} : t));
showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`, 'success');
};
// ── Insert Sound Clip at Cursor ──
const insertSoundClipAtCursor = () => {
const curTracks = activeTracks;
const track = curTracks.find(t => t.id === selectedTrackId);
if (!track) { showToast('Chọn track trước', 'warning'); return; }
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*';
input.multiple = true;
input.onchange = async e => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
let offset = currentTime;
let count = 0;
for (const file of files) {
try {
uploadToServer(file, track.id);
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const newClip = { id: clipId, buffer: decodedBuffer, startTime: offset, name: file.name, speed: 1.0 };
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
clips: [...(t.clips || []), newClip]
} : t));
offset += decodedBuffer.duration;
count++;
} catch (err) {
showToast(`Lỗi nạp ${file.name}`, 'error');
}
}
if (count > 0) showToast(`Đã chèn ${count} file âm thanh`, 'success');
};
input.click();
};
// ── Server-side Export ──
const triggerWavExport = async () => {
let exportTracks;
@@ -9061,7 +9524,10 @@ const App = () => {
solo: false,
color: '#0f766e',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}, {
id: '2',
name: 'Track 02',
@@ -9074,7 +9540,10 @@ const App = () => {
solo: false,
color: '#1d4ed8',
markers: [],
serverFileId: null
serverFileId: null,
clips: [],
sections: [],
midiItems: []
}]);
setSelectedTrackId('1');
setProjectName('');
@@ -9219,6 +9688,25 @@ const App = () => {
handleDeleteTrack();
}
}]
}, {
label: 'Insert',
items: [...(!sessionTabs.some(s => s.id === activeTab) ? [{
label: 'Insert Section',
icon: 'folder-plus',
action: insertSectionAtPlayhead
}] : []), {
label: 'Insert MIDI item',
icon: 'music',
action: insertMidiItemAtPlayhead
}, {
label: 'Insert sound clip',
icon: 'file-input',
action: insertSoundClipAtCursor
}, {
label: 'Insert track',
icon: 'plus',
action: insertTrackBelow
}]
}, {
label: 'View',
items: [{
@@ -9271,7 +9759,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-64 z-50",
className: `absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label === 'Edit' ? 'w-72' : 'w-64'}`,
onClick: () => setMenuOpen(null)
}, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", {
key: i,
@@ -9320,7 +9808,29 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "layout-dashboard",
className: "w-3 h-3"
})), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
})), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", {
key: st.id,
className: "flex items-stretch"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => setActiveTab(st.id),
className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}`
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "layout-dashboard",
className: "w-3 h-3"
})), /*#__PURE__*/React.createElement("span", {
className: "max-w-[120px] truncate"
}, st.name)), /*#__PURE__*/React.createElement("button", {
onClick: () => closeSessionTab(st.id),
className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs",
title: "Close tab"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-3 h-3"
}))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
key: st.id,
className: "flex items-stretch"
}, /*#__PURE__*/React.createElement("button", {
@@ -10367,7 +10877,7 @@ const App = () => {
className: "flex-1 flex flex-col overflow-hidden min-w-0"
}, /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex overflow-hidden"
}, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
}, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
ref: tcpContainerRef,
onScroll: handleTCPScroll,
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",
@@ -10384,7 +10894,7 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "sliders",
className: "w-3.5 h-3.5 text-cyan-400"
})), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", {
})), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", {
onClick: addNewTrack,
className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"
}, /*#__PURE__*/React.createElement("span", {
@@ -10416,7 +10926,7 @@ const App = () => {
className: "text-xs text-zinc-500"
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
}, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
}, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
@@ -10433,7 +10943,7 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "plus",
className: "w-3.5 h-3.5"
})), " Thêm Track Mới")) : tracks.map((track, idx) => {
})), " Thêm Track Mới")) : activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
return /*#__PURE__*/React.createElement("div", {
key: track.id,
@@ -10711,7 +11221,7 @@ const App = () => {
scrollLeft: scrollLeft
}))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"
}, tracks.map((track, idx) => {
}, activeTracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
return /*#__PURE__*/React.createElement("div", {
key: track.id,
@@ -10739,12 +11249,15 @@ const App = () => {
onContextMenu: handleContextMenu,
onClipDragStart: handleClipDragStart,
onClipStretchStart: handleClipStretchStart,
onSectionItemDragStart: handleSectionItemDragStart,
onSectionItemResizeStart: handleSectionItemResizeStart,
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
setSelectedClipId: setSelectedClipId,
selectedClipId: selectedClipId,
activeTool: activeTool,
onSplitTrackAtTime: handleSplitTrackAtTime,
onEditClipInSubTab: handleEditClipInSubTab,
onEditSectionInTab: handleEditSectionInTab,
snapValue: snapValue,
bpm: bpm,
selectionMode: selectionMode,
@@ -10793,7 +11306,7 @@ const App = () => {
}), /*#__PURE__*/React.createElement("div", {
className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800",
onMouseEnter: () => {
if (draggedClipRef.current) {
if (draggedClipRef.current || draggedSectionItemRef.current) {
setHoveredTrackId(addNewTrack());
}
},
@@ -11473,7 +11986,19 @@ const App = () => {
top: contextMenu.y
},
onClick: e => e.stopPropagation()
}, /*#__PURE__*/React.createElement("button", {
}, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", {
onClick: contextMenuEditSection,
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
}, /*#__PURE__*/React.createElement("span", {
className: "inline-flex items-center shrink-0"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "file-edit",
className: "w-3.5 h-3.5 text-amber-400 shrink-0"
})), /*#__PURE__*/React.createElement("span", {
className: "flex-1"
}, "Edit Section"), /*#__PURE__*/React.createElement("span", {
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
}, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", {
onClick: contextMenuEdit,
className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"
}, /*#__PURE__*/React.createElement("span", {
Binary file not shown.
File diff suppressed because one or more lines are too long