feat: bổ sung phần section-item và section tab

This commit is contained in:
2026-07-24 17:00:22 +07:00
parent 6a17e7036c
commit 17a284ad17
5 changed files with 536 additions and 188 deletions
+496 -170
View File
@@ -3466,11 +3466,13 @@ const ProfileModal = ({
let restoredTracks = [];
let restoredBpm = bpm;
let restoredSessionTabs = [];
let restoredSubTabs = [];
if (parsed.main_session) {
const result = deserializeProjectFromSchema(parsed);
restoredTracks = result.tracks;
restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs;
restoredSubTabs = result.subTabs;
} else {
restoredTracks = (parsed.tracks || []).map(t => {
const { height: _h, ...rest } = t;
@@ -3491,6 +3493,9 @@ const ProfileModal = ({
if (restoredSessionTabs.length > 0) {
setSessionTabs(restoredSessionTabs);
}
if (restoredSubTabs.length > 0) {
setSubTabs(restoredSubTabs);
}
localStorage.setItem('sonic_project_name', proj.name);
localStorage.setItem('sonic_project_id', proj.id);
showToast(`Đã nạp dự án "${proj.name}" thành công!`, "success");
@@ -4340,7 +4345,7 @@ const AIPresetModal = ({ isOpen, onClose }) => {
}, "Đóng"))));
};
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote }) => {
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast }) => {
const [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity');
@@ -5079,9 +5084,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
})))));
};
const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsList, sessionTabsList) => {
const secondsPerBar = (60.0 / parseFloat(bpmVal || 120)) * 4;
const mainTracks = (tracksList || []).map(t => {
const serializeTracksList = (tracksList, secondsPerBar) => {
return (tracksList || []).map(t => {
let trackType = "AUDIO";
if (t.sections && t.sections.length > 0) trackType = "SECTION";
else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI";
@@ -5151,34 +5155,139 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
pan: t.pan || 0.0,
mute: t.muted || false,
solo: t.solo || false,
instrument_id: t.instrumentId || null,
instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null,
instrument_name: t.instrumentName || null,
items: items
};
});
};
const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
return (schemaTracks || []).map(t => {
const clips = [];
const sections = [];
const midiItems = [];
(t.items || []).forEach(item => {
if (item.type === "AUDIO_ITEM") {
const src = item.source_data || {};
clips.push({
id: item.id,
name: item.name,
startTime: item.start_bar * secondsPerBar,
speed: 1.0
});
} else if (item.type === "MIDI_ITEM") {
const src = item.source_data || {};
midiItems.push({
id: item.id,
name: item.name,
startTime: item.start_bar * secondsPerBar,
duration: item.duration_bars * secondsPerBar,
notes: (src.notes || []).map(n => ({
id: n.id,
pitch: n.pitch || 60,
start_beat: n.start_beat || 0.0,
duration_beats: n.duration_beats || 1.0,
velocity: n.velocity || 0.8,
pan: n.pan || 0.0
}))
});
} else if (item.type === "SECTION_ITEM") {
const src = item.source_data || {};
const secId = src.referenced_section_id;
const secContainer = sectionStore ? sectionStore[secId] : null;
sections.push({
id: item.id,
name: item.name,
start: item.start_bar * secondsPerBar,
duration: item.duration_bars * secondsPerBar,
sectionId: secId,
tracks: secContainer ? deserializeTracksList(secContainer.tracks, secondsPerBar, sectionStore) : null
});
}
});
return {
id: t.id,
name: t.name,
volumeDb: t.volume_db || 0.0,
pan: t.pan || 0.0,
muted: t.mute || false,
solo: t.solo || false,
color: t.id === '1' ? '#0f766e' : '#1d4ed8',
markers: [],
serverFileId: t.items && t.items.find(i => i.type === "AUDIO_ITEM")?.source_data?.audio_file_url?.split("/").pop() || null,
clips: clips,
sections: sections,
midiItems: midiItems,
instrumentId: t.instrument_id || null,
instrumentProgram: t.instrument_program !== null ? t.instrument_program : undefined,
instrumentName: t.instrument_name || null
};
});
};
const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsList, sessionTabsList) => {
const secondsPerBar = (60.0 / parseFloat(bpmVal || 120)) * 4;
const mainTracks = serializeTracksList(tracksList, secondsPerBar);
const sectionStore = {};
// 1. Populate from sessionTabsList (open tabs)
(sessionTabsList || []).forEach(st => {
const secTracks = (st.tracks || []).map(t => {
let trackType = "AUDIO";
if (t.sections && t.sections.length > 0) trackType = "SECTION";
else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI";
return {
id: t.id,
name: t.name,
type: trackType,
volume_db: t.volumeDb || 0.0,
pan: t.pan || 0.0,
mute: t.muted || false,
solo: t.solo || false,
items: []
};
});
sectionStore[st.sectionId] = {
id: st.sectionId,
name: st.name,
is_root: false,
length_bars: st.length_bars || 16.0,
auto_compute_length: st.auto_compute_length !== undefined ? st.auto_compute_length : true,
tracks: secTracks
tracks: serializeTracksList(st.tracks, secondsPerBar),
color: st.color || null
};
});
// 2. Also populate from tracksList (closed tabs saved inside Section items)
const scanForSections = (tracks) => {
(tracks || []).forEach(t => {
if (t.sections) {
t.sections.forEach(s => {
const secId = s.sectionId || s.id;
if (s.tracks && !sectionStore[secId]) {
sectionStore[secId] = {
id: secId,
name: s.name,
is_root: false,
length_bars: s.tracks[0]?.length_bars || 16.0,
auto_compute_length: true,
tracks: serializeTracksList(s.tracks, secondsPerBar),
color: s.color || null
};
}
if (s.tracks) {
scanForSections(s.tracks);
}
});
}
});
};
scanForSections(tracksList);
const subTabs = (subTabsList || []).map(st => {
return {
id: st.id,
label: st.label,
type: st.type,
track_id: st.trackId,
target_id: st.target_id,
parent_tab_id: st.parent_tab_id,
notes: st.notes || [],
duration: st.duration || 4,
instrument_program: st.instrumentProgram,
instrument_name: st.instrumentName,
current_time: st.currentTime || 0,
color: st.color || null
};
});
@@ -5199,6 +5308,7 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
auto_compute_length: true,
tracks: mainTracks
},
sub_tabs: subTabs,
section_store: sectionStore
};
};
@@ -5207,90 +5317,46 @@ const deserializeProjectFromSchema = (schemaObj) => {
const bpmVal = schemaObj.metadata ? schemaObj.metadata.bpm : 120;
const secondsPerBar = (60.0 / bpmVal) * 4;
const restoredTracks = (schemaObj.main_session.tracks || []).map(t => {
const clips = [];
const sections = [];
const midiItems = [];
(t.items || []).forEach(item => {
if (item.type === "AUDIO_ITEM") {
const src = item.source_data || {};
const audioUrl = src.audio_file_url || "";
const serverFileId = audioUrl.split("/").pop() || null;
clips.push({
id: item.id,
name: item.name,
startTime: item.start_bar * secondsPerBar,
speed: 1.0
});
} else if (item.type === "MIDI_ITEM") {
const src = item.source_data || {};
midiItems.push({
id: item.id,
name: item.name,
startTime: item.start_bar * secondsPerBar,
duration: item.duration_bars * secondsPerBar,
notes: src.notes || []
});
} else if (item.type === "SECTION_ITEM") {
const src = item.source_data || {};
sections.push({
id: item.id,
name: item.name,
start: item.start_bar * secondsPerBar,
duration: item.duration_bars * secondsPerBar,
sectionId: src.referenced_section_id
});
}
});
return {
id: t.id,
name: t.name,
volumeDb: t.volume_db || 0.0,
pan: t.pan || 0.0,
muted: t.mute || false,
solo: t.solo || false,
color: t.id === '1' ? '#0f766e' : '#1d4ed8',
markers: [],
serverFileId: t.items && t.items.find(i => i.type === "AUDIO_ITEM")?.source_data?.audio_file_url?.split("/").pop() || null,
clips: clips,
sections: sections,
midiItems: midiItems
};
});
const sectionStore = schemaObj.section_store || {};
const restoredTracks = deserializeTracksList(schemaObj.main_session.tracks, secondsPerBar, sectionStore);
const restoredSessionTabs = [];
const sectionStore = schemaObj.section_store || {};
Object.keys(sectionStore).forEach(secId => {
const secContainer = sectionStore[secId];
const secTracks = (secContainer.tracks || []).map(t => {
return {
id: t.id,
name: t.name,
volumeDb: t.volume_db || 0.0,
pan: t.pan || 0.0,
muted: t.mute || false,
solo: t.solo || false,
clips: [],
sections: [],
midiItems: []
};
});
const secTracks = deserializeTracksList(secContainer.tracks, secondsPerBar, sectionStore);
restoredSessionTabs.push({
id: 'session_' + secId,
name: secContainer.name,
sectionId: secId,
tracks: secTracks,
length_bars: secContainer.length_bars || 16.0,
auto_compute_length: secContainer.auto_compute_length
auto_compute_length: secContainer.auto_compute_length !== undefined ? secContainer.auto_compute_length : true,
color: secContainer.color || null
});
});
const restoredSubTabs = (schemaObj.sub_tabs || []).map(st => {
return {
id: st.id,
label: st.label,
type: st.type,
trackId: st.track_id,
target_id: st.target_id,
parent_tab_id: st.parent_tab_id,
notes: st.notes || [],
duration: st.duration || 4,
instrumentProgram: st.instrument_program,
instrumentName: st.instrument_name,
currentTime: st.current_time || 0,
color: st.color || null
};
});
return {
bpm: bpmVal,
tracks: restoredTracks,
sessionTabs: restoredSessionTabs
sessionTabs: restoredSessionTabs,
subTabs: restoredSubTabs
};
};
@@ -5690,6 +5756,29 @@ const App = () => {
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 [tabContextMenu, setTabContextMenu] = useState(null); // { x, y, tabId, tabType }
const handleSetTabColor = (tabId, tabType, color) => {
if (tabType === 'session') {
setSessionTabs(prev => prev.map(s => s.id === tabId ? { ...s, color: color } : s));
const tab = sessionTabs.find(s => s.id === tabId);
if (tab) {
setTracks(prev => prev.map(t => {
if (!t.sections || t.sections.length === 0) return t;
return {
...t,
sections: t.sections.map(s => {
if (s.sectionId === tab.sectionId) {
return { ...s, color: color };
}
return s;
})
};
}));
}
} else {
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, color: color } : s));
}
};
const activeTracks = useMemo(() => {
const st = sessionTabs.find(s => s.id === activeTab);
return st ? st.tracks : tracks;
@@ -5774,11 +5863,13 @@ const App = () => {
let restoredTracks = [];
let restoredBpm = bpm;
let restoredSessionTabs = [];
let restoredSubTabs = [];
if (proj.main_session) {
const result = deserializeProjectFromSchema(proj);
restoredTracks = result.tracks;
restoredBpm = result.bpm;
restoredSessionTabs = result.sessionTabs;
restoredSubTabs = result.subTabs;
} else {
restoredTracks = (proj.tracks || []).map(t => {
const { height: _h, ...rest } = t;
@@ -5797,6 +5888,9 @@ const App = () => {
if (restoredSessionTabs.length > 0) {
setSessionTabs(restoredSessionTabs);
}
if (restoredSubTabs.length > 0) {
setSubTabs(restoredSubTabs);
}
showToast(`Đã tải dự án "${proj.metadata?.title || proj.name || 'Dự án mới'}" từ liên kết .sfs thành công!`, "success");
}
} catch (e) {
@@ -6671,7 +6765,9 @@ const App = () => {
if (!track) return;
const midiItem = (track.midiItems || []).find(m => m.id === midiItemId);
if (!midiItem) return;
const existing = subTabs.find(s => s.type === 'PIANO_ROLL' && s.target_id === midiItemId);
if (existing) {
setActiveTab(existing.id);
showToast('Piano Roll cho nốt MIDI đã được mở.', 'info');
@@ -6708,14 +6804,15 @@ const App = () => {
};
const handleUpdateMidiNotes = (tabId, notes) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: notes } : s));
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: notes, isDirty: true } : s));
};
const handleSaveMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => {
const isSectionTrack = sessionTabs.some(s => s.tracks.some(t => t.id === trackId));
if (isSectionTrack) {
const pianoRollTab = subTabs.find(s => s.id === tabId);
const parentTabId = pianoRollTab ? pianoRollTab.parent_tab_id : null;
if (parentTabId && parentTabId.startsWith('session_')) {
setSessionTabs(prev => prev.map(s => {
if (!s.tracks.some(t => t.id === trackId)) return s;
if (s.id !== parentTabId) return s;
return {
...s,
tracks: s.tracks.map(t => {
@@ -6742,6 +6839,7 @@ const App = () => {
};
}));
}
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, isDirty: false } : s));
showToast('Đã lưu các chỉnh sửa nốt MIDI vào item cha!', 'success');
};
@@ -6762,11 +6860,16 @@ const App = () => {
return {
...s,
name: tab.name,
duration: durationSec
duration: durationSec,
tracks: tab.tracks
};
})
};
}));
// Clear isDirty flag
setSessionTabs(prev => prev.map(s => s.id === tabId ? { ...s, isDirty: false } : s));
showToast(`Đã lưu nội dung Section "${tab.name}" vào Main Session!`, 'success');
};
@@ -6780,16 +6883,19 @@ const App = () => {
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 => ({
const clonedTracks = section.tracks ? section.tracks : tracks.map(t => ({
...t,
clips: [],
sections: [],
midiItems: [],
markers: [],
isArmed: false,
monitoringEnabled: true
monitoringEnabled: true,
instrumentId: null,
instrumentProgram: undefined,
instrumentName: null
}));
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]);
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks, color: section.color || null }]);
setActiveTab(tabId);
};
@@ -7198,8 +7304,22 @@ const App = () => {
}
};
const closeSubTab = tabId => {
setSubTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
const tab = subTabs.find(s => s.id === tabId);
if (!tab) return;
const performClose = () => {
setSubTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
};
if (tab.isDirty) {
setAppWarningModal({
title: "Đóng Tab?",
message: `Bạn chưa lưu các thay đổi của tab "${tab.label || 'MIDI'}". Bạn có chắc chắn muốn đóng tab này và mất hết thay đổi?`,
isAlert: false,
onConfirm: performClose
});
} else {
performClose();
}
};
const closeSessionTab = tabId => {
const targetTab = sessionTabs.find(s => s.id === tabId);
@@ -7214,8 +7334,20 @@ const App = () => {
});
return;
}
setSessionTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
const performClose = () => {
setSessionTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
};
if (targetTab.isDirty) {
setAppWarningModal({
title: "Đóng Tab Section?",
message: `Bạn chưa lưu các thay đổi của Section "${targetTab.name}". Bạn có chắc chắn muốn đóng tab này và mất hết thay đổi?`,
isAlert: false,
onConfirm: performClose
});
} else {
performClose();
}
};
const updateSubTabEffects = (tabId, effects) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? {
@@ -7961,6 +8093,7 @@ const App = () => {
});
toastTimeoutRef.current = setTimeout(() => setToastMessage(null), actionText ? 8000 : 3500);
};
window.showToast = showToast;
// Server-side upload
const uploadToServer = async (file, trackId) => {
@@ -8043,6 +8176,9 @@ const App = () => {
// Update Playhead
const startSubTabPlayback = (st, offsetWallTime) => {
const context = getAudioContext();
if (!st.buffer) {
st.buffer = context.createBuffer(1, 128, context.sampleRate);
}
const speed = st.speed || 1.0;
const offsetBuffer = offsetWallTime * speed;
const eff = st.buffer.getChannelData(0);
@@ -8375,8 +8511,8 @@ const App = () => {
const startTrackPlayback = offsetTime => {
const context = getAudioContext();
const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null;
tracks.forEach(track => {
const hasSolo = activeTracks.some(t => t.solo) || soloedTrackId !== null;
activeTracks.forEach(track => {
const isPlayable = hasSolo ? track.id === soloedTrackId || track.solo : !track.muted;
if (!isPlayable) return;
@@ -8456,6 +8592,108 @@ const App = () => {
// Play audio normally (the buffer has the audio data)
// This block is intentionally empty - audio clips already play above
}
// SECTION items playback
const sections = track.sections || [];
if (activeTab === 'main' && sections.length > 0) {
const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
const secondsPerBar = secondsPerBeat * 4;
sections.forEach(sec => {
const secStart = sec.start || 0;
const secEnd = secStart + (sec.duration || 0);
const secTracks = sec.tracks || [];
const hasSubSolo = secTracks.some(st => st.solo);
secTracks.forEach(subTrack => {
const isSubPlayable = hasSubSolo ? subTrack.solo : !subTrack.muted;
if (!isSubPlayable) return;
// 1. Play clips in subTrack
const subClips = subTrack.clips || [];
subClips.forEach(clip => {
if (!clip.buffer) return;
const clipStartLocal = clip.startTime || 0;
const clipDurLocal = clip.buffer.duration / (clip.speed || 1.0);
const clipStartMain = secStart + clipStartLocal;
const clipEndMain = clipStartMain + clipDurLocal;
// Only play within the bounds of [secStart, secEnd]
const activeStart = Math.max(clipStartMain, secStart);
const activeEnd = Math.min(clipEndMain, secEnd);
if (activeStart < activeEnd && offsetTime < activeEnd) {
const source = context.createBufferSource();
source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0;
source.connect(gainNode);
if (offsetTime < activeStart) {
const delay = activeStart - offsetTime;
const playOffset = activeStart - clipStartMain;
const playDuration = activeEnd - activeStart;
source.start(context.currentTime + delay, playOffset * (clip.speed || 1.0), playDuration);
activeSourcesRef.current.push(source);
} else {
const playOffset = offsetTime - clipStartMain;
const playDuration = activeEnd - offsetTime;
source.start(context.currentTime, playOffset * (clip.speed || 1.0), playDuration);
activeSourcesRef.current.push(source);
}
}
});
// 2. Play MIDI items in subTrack
const subMidiItems = subTrack.midiItems || [];
if (window.SonicSF && subMidiItems.length > 0) {
subMidiItems.forEach(item => {
const notes = item.notes || [];
notes.forEach(note => {
const noteStartSecLocal = item.startTime + (note.start_beat || 0) * secondsPerBeat;
const noteDurSec = (note.duration_beats || 1) * secondsPerBeat;
const noteStartMain = secStart + noteStartSecLocal;
const noteEndMain = noteStartMain + noteDurSec;
// Only play if the note starts inside the Section item bounds and hasn't finished yet
if (noteStartMain >= secStart && noteStartMain < secEnd && offsetTime < noteEndMain) {
const notePlayEndMain = Math.min(noteEndMain, secEnd);
const program = subTrack.instrumentProgram !== undefined ? subTrack.instrumentProgram : 0;
if (offsetTime < noteStartMain) {
const delay = noteStartMain - offsetTime;
const startTime = context.currentTime + delay;
const playDurMs = (notePlayEndMain - noteStartMain) * 1000;
window.SonicSF.playNote(
note.pitch || 60,
note.velocity || 0.8,
playDurMs,
startTime,
program,
gainNode
);
} else {
const remainingDurMs = (notePlayEndMain - offsetTime) * 1000;
window.SonicSF.playNote(
note.pitch || 60,
note.velocity || 0.8,
remainingDurMs,
context.currentTime,
program,
gainNode
);
}
}
});
});
}
});
});
}
});
};
@@ -8538,7 +8776,7 @@ const App = () => {
}
};
const handlePlayPause = () => {
if (activeTab !== 'main') {
if (activeTab !== 'main' && !activeTab.startsWith('session_')) {
// Sub-tab playback transport
const st = subTabs.find(s => s.id === activeTab);
if (!st || (!st.buffer && st.type !== 'PIANO_ROLL')) return;
@@ -8590,6 +8828,9 @@ const App = () => {
}
}
});
// Call startSubTabPlayback to spin up the silent buffer playhead timing/references
startSubTabPlayback(st, startOffset);
} else {
startSubTabPlayback(st, startOffset);
}
@@ -8639,7 +8880,7 @@ const App = () => {
return;
}
stopAllPlayback();
if (activeTab !== 'main') {
if (activeTab !== 'main' && !activeTab.startsWith('session_')) {
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
...s,
currentTime: 0
@@ -9481,27 +9722,37 @@ const App = () => {
const newStart = Math.max(0, time - drag.clickOffset);
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
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 * secondsPerBar, color: '#06b6d4' });
} else {
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4 * secondsPerBar, notes: [], color: '#a78bfa' });
}
updateActiveTracks(prev => {
let movedItem = null;
for (let track of prev) {
const items = drag.itemType === 'section' ? (track.sections || []) : (track.midiItems || []);
const found = items.find(it => it.id === drag.itemId);
if (found) {
movedItem = found;
break;
}
}
return drag.itemType === 'section'
? { ...t, sections: updatedItems }
: { ...t, midiItems: updatedItems };
}));
return 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) {
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 * secondsPerBar, color: '#06b6d4' });
} else {
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4 * secondsPerBar, notes: [], color: '#a78bfa' });
}
}
}
return drag.itemType === 'section'
? { ...t, sections: updatedItems }
: { ...t, midiItems: updatedItems };
});
});
if (drag.trackId !== targetTrackId) {
setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId }));
}
@@ -9911,7 +10162,7 @@ const App = () => {
const currentActiveTab = activeTabRef.current;
const st = currentSessionTabs.find(s => s.id === currentActiveTab);
if (st) {
setSessionTabs(prev => prev.map(s => s.id === currentActiveTab ? { ...s, tracks: updater(s.tracks) } : s));
setSessionTabs(prev => prev.map(s => s.id === currentActiveTab ? { ...s, tracks: updater(s.tracks), isDirty: true } : s));
} else {
setTracks(updater);
}
@@ -12450,51 +12701,102 @@ const App = () => {
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "layout-dashboard",
className: "w-3 h-3"
})), " 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", {
onClick: () => setActiveTab(st.id),
className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-amber-400 border-amber-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": "file-edit",
className: "w-3 h-3"
})), /*#__PURE__*/React.createElement("span", {
className: "max-w-[100px] truncate"
}, st.label)), /*#__PURE__*/React.createElement("button", {
onClick: () => closeSubTab(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"
})))))), showAIConfig && /*#__PURE__*/React.createElement("div", {
})), " Main Session"), sessionTabs.map(st => {
const isTabActive = activeTab === st.id;
const tabColor = st.color || (isTabActive ? '#06b6d4' : null);
const borderStyle = isTabActive ? { borderBottomColor: tabColor, color: tabColor } : (st.color ? { color: st.color, borderBottomColor: 'transparent' } : {});
return /*#__PURE__*/React.createElement("div", {
key: st.id,
className: "flex items-stretch"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => setActiveTab(st.id),
onAuxClick: e => {
if (e.button === 1) {
e.preventDefault();
closeSessionTab(st.id);
}
},
onContextMenu: e => {
e.preventDefault();
setTabContextMenu({
x: e.clientX,
y: e.clientY,
tabId: st.id,
tabType: 'session'
});
},
style: borderStyle,
className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${isTabActive ? '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": "layers",
className: "w-3 h-3"
})), /*#__PURE__*/React.createElement("span", {
className: "max-w-[120px] truncate"
}, st.name), st.isDirty && /*#__PURE__*/React.createElement("span", {
className: "w-1.5 h-1.5 rounded-full bg-amber-500 ml-1",
title: "Chưa lưu thay đổi"
})), /*#__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 => {
const isTabActive = activeTab === st.id;
const tabColor = st.color || (isTabActive ? '#f59e0b' : null);
const borderStyle = isTabActive ? { borderBottomColor: tabColor, color: tabColor } : (st.color ? { color: st.color, borderBottomColor: 'transparent' } : {});
const iconName = st.type === 'PIANO_ROLL' ? 'music' : 'file-edit';
return /*#__PURE__*/React.createElement("div", {
key: st.id,
className: "flex items-stretch"
}, /*#__PURE__*/React.createElement("button", {
onClick: () => setActiveTab(st.id),
onAuxClick: e => {
if (e.button === 1) {
e.preventDefault();
closeSubTab(st.id);
}
},
onContextMenu: e => {
e.preventDefault();
setTabContextMenu({
x: e.clientX,
y: e.clientY,
tabId: st.id,
tabType: 'sub'
});
},
style: borderStyle,
className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${isTabActive ? '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": iconName,
className: "w-3 h-3"
})), /*#__PURE__*/React.createElement("span", {
className: "max-w-[100px] truncate"
}, st.label), st.isDirty && /*#__PURE__*/React.createElement("span", {
className: "w-1.5 h-1.5 rounded-full bg-amber-500 ml-1",
title: "Chưa lưu thay đổi"
})), /*#__PURE__*/React.createElement("button", {
onClick: () => closeSubTab(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"
}))));
})), showAIConfig && /*#__PURE__*/React.createElement("div", {
className: "bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all"
}, /*#__PURE__*/React.createElement("div", {
className: "text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1"
@@ -14827,7 +15129,31 @@ const App = () => {
if (fn) fn();
},
className: "px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition"
}, "Xác nhận"))))), /*#__PURE__*/React.createElement(AuthModal, {
}, "Xác nhận"))))), tabContextMenu && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
className: "fixed inset-0 z-[190]",
onClick: () => setTabContextMenu(null),
onContextMenu: e => {
e.preventDefault();
setTabContextMenu(null);
}
}), /*#__PURE__*/React.createElement("div", {
className: "fixed bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-2 z-[200] flex gap-1.5",
style: {
top: tabContextMenu.y,
left: tabContextMenu.x
},
onClick: e => e.stopPropagation()
}, ['#f43f5e', '#f59e0b', '#10b981', '#06b6d4', '#8b5cf6', '#64748b'].map(color => /*#__PURE__*/React.createElement("button", {
key: color,
className: "w-5 h-5 rounded-full border border-zinc-700 hover:scale-110 active:scale-95 transition",
style: {
backgroundColor: color
},
onClick: () => {
handleSetTabColor(tabContextMenu.tabId, tabContextMenu.tabType, color);
setTabContextMenu(null);
}
})))), /*#__PURE__*/React.createElement(AuthModal, {
isOpen: authModalOpen,
mode: authMode,
forceMandatory: isMandatoryLogin,
File diff suppressed because one or more lines are too long