feat: bổ sung phần section-item và section tab
This commit is contained in:
+496
-170
@@ -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
Reference in New Issue
Block a user