feat: cài đặt menu ngữ cảnh cho track nhạc
This commit is contained in:
+758
-40
@@ -152,6 +152,7 @@
|
||||
localSelLeft,
|
||||
localSelRight,
|
||||
onTrackLaneMouseDown,
|
||||
onContextMenu,
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
@@ -258,6 +259,8 @@
|
||||
ref={canvasRef}
|
||||
className="w-full h-full cursor-crosshair"
|
||||
onMouseDown={(e) => {
|
||||
// Ignore right-click for local selection drag (context menu handles it)
|
||||
if (e.button === 2) return;
|
||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
@@ -270,10 +273,59 @@
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectTrack(track.id);
|
||||
if (onContextMenu) onContextMenu(e, track.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2) ──
|
||||
const SubTabWaveform = ({ buffer }) => {
|
||||
const canvasRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !buffer) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
ctx.fillStyle = '#181818';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const data = buffer.getChannelData(0);
|
||||
const len = data.length;
|
||||
if (len === 0) return;
|
||||
|
||||
ctx.strokeStyle = '#6ee7b7';
|
||||
ctx.lineWidth = 1;
|
||||
for (let px = 0; px < w; px++) {
|
||||
const start = Math.floor((px / w) * len);
|
||||
const end = Math.floor(((px + 1) / w) * len);
|
||||
let maxVal = 0;
|
||||
for (let i = start; i < end && i < len; i++) {
|
||||
const abs = Math.abs(data[i]);
|
||||
if (abs > maxVal) maxVal = abs;
|
||||
}
|
||||
const mid = h / 2;
|
||||
const peakHeight = maxVal * (h * 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, mid - peakHeight);
|
||||
ctx.lineTo(px, mid + peakHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [buffer]);
|
||||
return <canvas ref={canvasRef} className="w-full h-full rounded border border-zinc-800"></canvas>;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
@@ -311,6 +363,91 @@
|
||||
format: 'wav',
|
||||
});
|
||||
const [serverStatus, setServerStatus] = useState('checking...');
|
||||
const [menuOpen, setMenuOpen] = useState(null);
|
||||
|
||||
// ── Context Menu & Clipboard ──
|
||||
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
|
||||
const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste
|
||||
|
||||
// ── Undo/Redo Engine (LOOP_EDITOR.md §4) ──
|
||||
const [undoStack, setUndoStack] = useState([]);
|
||||
const [redoStack, setRedoStack] = useState([]);
|
||||
const MAX_UNDO = 30;
|
||||
|
||||
const pushAction = (actionType, trackId, beforeState, afterState) => {
|
||||
const node = {
|
||||
action_type: actionType,
|
||||
track_id: trackId,
|
||||
timestamp: Date.now(),
|
||||
before_state: beforeState,
|
||||
after_state: afterState,
|
||||
};
|
||||
setUndoStack(prev => {
|
||||
const next = [...prev, node];
|
||||
if (next.length > MAX_UNDO) next.shift();
|
||||
return next;
|
||||
});
|
||||
setRedoStack([]);
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (undoStack.length === 0) return;
|
||||
const last = undoStack[undoStack.length - 1];
|
||||
setUndoStack(prev => prev.slice(0, -1));
|
||||
setRedoStack(prev => [...prev, last]);
|
||||
applyTrackState(last.track_id, last.before_state);
|
||||
showToast(`Undo: ${last.action_type}`, 'info');
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
if (redoStack.length === 0) return;
|
||||
const last = redoStack[redoStack.length - 1];
|
||||
setRedoStack(prev => prev.slice(0, -1));
|
||||
setUndoStack(prev => [...prev, last]);
|
||||
applyTrackState(last.track_id, last.after_state);
|
||||
showToast(`Redo: ${last.action_type}`, 'info');
|
||||
};
|
||||
|
||||
const applyTrackState = (trackId, state) => {
|
||||
setTracks(prev => prev.map(t => {
|
||||
if (t.id !== trackId) return t;
|
||||
return { ...t, ...state };
|
||||
}));
|
||||
};
|
||||
|
||||
const captureTrackSnapshot = (trackId) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track) return null;
|
||||
return {
|
||||
volume: track.volume,
|
||||
muted: track.muted,
|
||||
name: track.name,
|
||||
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
||||
// buffer is captured via reference copy for undo; we store a clone for redo
|
||||
buffer: track.buffer,
|
||||
};
|
||||
};
|
||||
|
||||
// ── Tab System (LOOP_EDITOR_2.md §1) ──
|
||||
const [activeTab, setActiveTab] = useState('main');
|
||||
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer}, ...]
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
|
||||
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
|
||||
const [tempTabActive, setTempTabActive] = useState(false);
|
||||
const [tempTabBuffer, setTempTabBuffer] = useState(null);
|
||||
const [tempTabTrackId, setTempTabTrackId] = useState(null);
|
||||
const [tempTabOrigStart, setTempTabOrigStart] = useState(0);
|
||||
const [tempTabOrigEnd, setTempTabOrigEnd] = useState(0);
|
||||
const tempTabCanvasRef = useRef(null);
|
||||
|
||||
// Effect parameters for temp tab
|
||||
const [tempTabEffects, setTempTabEffects] = useState({
|
||||
reverse: false,
|
||||
gainDb: 0,
|
||||
fadeInMs: 0,
|
||||
fadeOutMs: 0,
|
||||
});
|
||||
|
||||
const timelineWrapperRef = useRef(null);
|
||||
const rulerRef = useRef(null);
|
||||
@@ -321,6 +458,360 @@
|
||||
const toastTimeoutRef = useRef(null);
|
||||
const rulerDragStartRef = useRef(null);
|
||||
const isDraggingRulerRef = useRef(false);
|
||||
const handlePlayPauseRef = useRef(null);
|
||||
|
||||
// ── Keyboard Shortcuts (LOOP_EDITOR.md §4, LOOP_EDITOR_2.md §4.3) ──
|
||||
const handleUndoRef = useRef(handleUndo);
|
||||
const handleRedoRef = useRef(handleRedo);
|
||||
handleUndoRef.current = handleUndo;
|
||||
handleRedoRef.current = handleRedo;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
// Space key: toggle play/pause (LOOP_EDITOR_2.md §4.3)
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
||||
return;
|
||||
}
|
||||
// Space key: toggle play/pause (LOOP_EDITOR_2.md §4.3)
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndoRef.current();
|
||||
} else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
handleRedoRef.current();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []); // empty deps: refs avoid stale closure
|
||||
|
||||
// ── Temp Tab: draw isolated waveform ──
|
||||
useEffect(() => {
|
||||
if (!tempTabActive || !tempTabBuffer || !tempTabCanvasRef.current) return;
|
||||
const canvas = tempTabCanvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
ctx.fillStyle = '#181818';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const data = tempTabBuffer.getChannelData(0);
|
||||
const sr = tempTabBuffer.sampleRate;
|
||||
const totalSamples = data.length;
|
||||
if (totalSamples === 0) return;
|
||||
|
||||
ctx.strokeStyle = '#6ee7b7';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let px = 0; px < w; px++) {
|
||||
const startSample = Math.floor((px / w) * totalSamples);
|
||||
const endSample = Math.floor(((px + 1) / w) * totalSamples);
|
||||
let maxVal = 0;
|
||||
for (let i = startSample; i < endSample && i < totalSamples; i++) {
|
||||
const abs = Math.abs(data[i]);
|
||||
if (abs > maxVal) maxVal = abs;
|
||||
}
|
||||
const mid = h / 2;
|
||||
const peakHeight = maxVal * (h * 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, mid - peakHeight);
|
||||
ctx.lineTo(px, mid + peakHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [tempTabActive, tempTabBuffer]);
|
||||
|
||||
// ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ──
|
||||
const openTempTab = () => {
|
||||
const useLocal = selectionMode === 'local' && localSelectionTrackId;
|
||||
const trackId = useLocal ? localSelectionTrackId : selectedTrackId;
|
||||
const t = tracks.find(x => x.id === trackId);
|
||||
if (!t || !t.buffer) {
|
||||
showToast('Vui lòng chọn track có dữ liệu âm thanh.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (selLeft === null || selRight === null || selRight <= selLeft) {
|
||||
showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning');
|
||||
return;
|
||||
}
|
||||
const sr = t.buffer.sampleRate;
|
||||
const startSample = Math.max(0, Math.floor(selLeft * sr));
|
||||
const endSample = Math.min(t.buffer.length, Math.floor(selRight * sr));
|
||||
const len = endSample - startSample;
|
||||
if (len < 100) {
|
||||
showToast('Khoảng chọn quá ngắn.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = getAudioContext();
|
||||
const subBuffer = ctx.createBuffer(1, len, sr);
|
||||
subBuffer.copyToChannel(t.buffer.getChannelData(0).subarray(startSample, endSample), 0);
|
||||
|
||||
const tabId = 'subtab_' + Date.now();
|
||||
const tabLabel = `Edit_${t.name.replace('.wav','').slice(0,10)}_${selLeft.toFixed(1)}s`;
|
||||
|
||||
setSubTabs(prev => [...prev, {
|
||||
id: tabId,
|
||||
label: tabLabel,
|
||||
trackId: trackId,
|
||||
startTime: selLeft,
|
||||
endTime: selRight,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
}]);
|
||||
setActiveTab(tabId);
|
||||
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
||||
};
|
||||
|
||||
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
|
||||
const applySubTab = (tabId) => {
|
||||
const subTab = subTabs.find(s => s.id === tabId);
|
||||
if (!subTab || !subTab.buffer) return;
|
||||
const track = tracks.find(t => t.id === subTab.trackId);
|
||||
if (!track || !track.buffer) return;
|
||||
|
||||
const beforeSnap = captureTrackSnapshot(subTab.trackId);
|
||||
|
||||
// Clone buffer and apply effects
|
||||
const ctx = getAudioContext();
|
||||
const eff = subTab.buffer.getChannelData(0);
|
||||
const edBuffer = ctx.createBuffer(1, eff.length, subTab.buffer.sampleRate);
|
||||
const edData = edBuffer.getChannelData(0);
|
||||
edData.set(eff);
|
||||
|
||||
// Apply effects inline
|
||||
const fx = subTab.effects || {};
|
||||
// Reverse
|
||||
if (fx.reverse) {
|
||||
const reversed = new Float32Array(edData);
|
||||
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
|
||||
edBuffer.copyToChannel(reversed, 0);
|
||||
}
|
||||
// Gain
|
||||
if (fx.gainDb !== 0) {
|
||||
const gain = Math.pow(10, fx.gainDb / 20);
|
||||
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
|
||||
}
|
||||
// Fade in
|
||||
if (fx.fadeInMs > 0) {
|
||||
const sr = edBuffer.sampleRate;
|
||||
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
|
||||
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
|
||||
}
|
||||
// Fade out
|
||||
if (fx.fadeOutMs > 0) {
|
||||
const sr = edBuffer.sampleRate;
|
||||
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
|
||||
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
|
||||
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
|
||||
}
|
||||
}
|
||||
|
||||
// Crossfade merge into original track (§2.2)
|
||||
const sr = track.buffer.sampleRate;
|
||||
const origData = track.buffer.getChannelData(0);
|
||||
const startSample = Math.floor(subTab.startTime * sr);
|
||||
const endSample = Math.floor(subTab.endTime * sr);
|
||||
const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms
|
||||
|
||||
const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr);
|
||||
const mergedData = mergedBuffer.getChannelData(0);
|
||||
for (let i = 0; i < startSample; i++) mergedData[i] = origData[i];
|
||||
for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i];
|
||||
|
||||
for (let i = 0; i < edData.length; i++) {
|
||||
const globalIdx = startSample + i;
|
||||
let val = edData[i];
|
||||
if (i < crossfadeLen) {
|
||||
const alpha = i / crossfadeLen;
|
||||
val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * edData[i];
|
||||
} else if (i > edData.length - crossfadeLen) {
|
||||
const distFromEnd = edData.length - 1 - i;
|
||||
const alpha = distFromEnd / crossfadeLen;
|
||||
const origEndIdx = endSample - (edData.length - i);
|
||||
val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * edData[i];
|
||||
}
|
||||
mergedData[globalIdx] = val;
|
||||
}
|
||||
|
||||
setTracks(prev => prev.map(t => {
|
||||
if (t.id !== subTab.trackId) return t;
|
||||
return { ...t, buffer: mergedBuffer, name: t.name + ' (edited)' };
|
||||
}));
|
||||
|
||||
const afterSnap = captureTrackSnapshot(subTab.trackId);
|
||||
pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap);
|
||||
|
||||
closeSubTab(tabId);
|
||||
showToast('Đã áp dụng chỉnh sửa vào track chính với crossfade.', 'success');
|
||||
};
|
||||
|
||||
const closeSubTab = (tabId) => {
|
||||
setSubTabs(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, effects: { ...s.effects, ...effects } } : s));
|
||||
};
|
||||
|
||||
// ── Context Menu Handlers ──
|
||||
const handleContextMenu = (e, trackId) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, trackId });
|
||||
};
|
||||
|
||||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
// Close context menu on any click outside
|
||||
useEffect(() => {
|
||||
const handler = () => { if (contextMenu) closeContextMenu(); };
|
||||
if (contextMenu) {
|
||||
window.addEventListener('click', handler);
|
||||
return () => window.removeEventListener('click', handler);
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
const contextMenuEdit = () => {
|
||||
const track = tracks.find(t => t.id === contextMenu.trackId);
|
||||
if (track) setSelectedTrackId(contextMenu.trackId);
|
||||
closeContextMenu();
|
||||
openTempTab();
|
||||
};
|
||||
|
||||
const contextMenuSplit = () => {
|
||||
closeContextMenu();
|
||||
handleSplitTrack(contextMenu.trackId);
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
closeContextMenu();
|
||||
showToast('Đã xoá track.', 'info');
|
||||
};
|
||||
|
||||
const contextMenuCopy = () => {
|
||||
const track = tracks.find(t => t.id === contextMenu.trackId);
|
||||
if (!track) return;
|
||||
clipboardRef.current = {
|
||||
buffer: track.buffer,
|
||||
name: track.name,
|
||||
volume: track.volume,
|
||||
color: track.color,
|
||||
};
|
||||
closeContextMenu();
|
||||
showToast('Đã sao chép track vào clipboard.', 'info');
|
||||
};
|
||||
|
||||
const contextMenuCut = () => {
|
||||
contextMenuCopy();
|
||||
contextMenuDelete();
|
||||
};
|
||||
|
||||
const contextMenuPaste = () => {
|
||||
if (!clipboardRef.current) {
|
||||
showToast('Clipboard trống.', 'warning');
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
const { buffer, name, volume, color } = clipboardRef.current;
|
||||
if (!buffer) {
|
||||
showToast('Clipboard không có dữ liệu âm thanh.', 'warning');
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
// Create a new buffer copy
|
||||
const ctx = getAudioContext();
|
||||
const newBuffer = ctx.createBuffer(1, buffer.length, buffer.sampleRate);
|
||||
newBuffer.copyToChannel(buffer.getChannelData(0), 0);
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const newId = 'track_pasted_' + Date.now();
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId,
|
||||
name: `Pasted_${name || 'track'}`,
|
||||
buffer: newBuffer,
|
||||
volume: volume || 0.8,
|
||||
muted: false,
|
||||
solo: false,
|
||||
color: color || colors[prev.length % colors.length],
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
}]);
|
||||
setSelectedTrackId(newId);
|
||||
closeContextMenu();
|
||||
showToast('Đã dán track từ clipboard.', 'success');
|
||||
};
|
||||
|
||||
const contextMenuMerge = () => {
|
||||
const activeTracks = tracks.filter(t => t.buffer && !t.muted);
|
||||
if (activeTracks.length < 2) {
|
||||
showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning');
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
const maxDur = Math.max(...activeTracks.map(t => t.buffer.duration));
|
||||
const sr = activeTracks[0].buffer.sampleRate;
|
||||
const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
||||
const mergedData = merged.getChannelData(0);
|
||||
activeTracks.forEach(t => {
|
||||
const data = t.buffer.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
mergedData[i] += data[i] * t.volume;
|
||||
}
|
||||
});
|
||||
// Normalize
|
||||
let maxPeak = 0;
|
||||
for (let i = 0; i < mergedData.length; i++) {
|
||||
const abs = Math.abs(mergedData[i]);
|
||||
if (abs > maxPeak) maxPeak = abs;
|
||||
}
|
||||
if (maxPeak > 1.0) {
|
||||
for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak;
|
||||
}
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const newId = 'track_merged_' + Date.now();
|
||||
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
||||
setTracks(prev => {
|
||||
const keep = prev.filter(t => !t.buffer || t.muted || t.id === contextMenu.trackId);
|
||||
return [...keep, {
|
||||
id: newId,
|
||||
name: `Merged_${names}.wav`,
|
||||
buffer: merged,
|
||||
volume: 0.8,
|
||||
muted: false,
|
||||
solo: false,
|
||||
color: colors[prev.length % colors.length],
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
}];
|
||||
});
|
||||
setSelectedTrackId(newId);
|
||||
closeContextMenu();
|
||||
showToast(`Đã merge ${activeTracks.length} tracks.`, 'success');
|
||||
};
|
||||
|
||||
// ── Server Health Check ──
|
||||
useEffect(() => {
|
||||
@@ -460,8 +951,9 @@
|
||||
const elapsed = context.currentTime - startAudioTimeRef.current;
|
||||
const updatedTime = startOffsetTimeRef.current + elapsed;
|
||||
|
||||
// Selection Loop - LOOP_MAKER.md spec: local vs global behavior
|
||||
if (isLoopingSelection && selLeft !== null && selRight !== null) {
|
||||
// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2
|
||||
// If selection cleared by user, play linearly (don't loop)
|
||||
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
|
||||
if (selRight > selLeft && updatedTime >= selRight) {
|
||||
if (selectionMode === 'local') {
|
||||
// Local Solo Loop: only restart the selected track
|
||||
@@ -470,6 +962,7 @@
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
startLocalTrackPlayback(localSelectionTrackId, selLeft);
|
||||
setCurrentTime(selLeft);
|
||||
setIsPlaying(true);
|
||||
} else {
|
||||
// Global Master Loop: restart all tracks
|
||||
stopAllPlayback();
|
||||
@@ -477,6 +970,7 @@
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
startTrackPlayback(selLeft);
|
||||
setCurrentTime(selLeft);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||
return;
|
||||
@@ -500,7 +994,7 @@
|
||||
cancelAnimationFrame(animationFrameIdRef.current);
|
||||
}
|
||||
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
||||
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId]);
|
||||
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared]);
|
||||
|
||||
// ── Playback ──
|
||||
const startTrackPlayback = (offsetTime) => {
|
||||
@@ -564,6 +1058,7 @@
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
handlePlayPauseRef.current = handlePlayPause;
|
||||
|
||||
const handlePause = () => {
|
||||
if (isPlaying) stopAllPlayback();
|
||||
@@ -691,6 +1186,8 @@
|
||||
} else {
|
||||
setSelectionEnd(cleanEnd);
|
||||
}
|
||||
// LOOP_EDITOR_2.md §4.2: new selection = enable looping
|
||||
setSelectionCleared(false);
|
||||
};
|
||||
|
||||
const handleSelectionInputChange = (field, val) => {
|
||||
@@ -825,11 +1322,35 @@
|
||||
};
|
||||
|
||||
const toggleTrackMute = (trackId) => {
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, muted: !t.muted } : t));
|
||||
setUndoStack(prev => {
|
||||
const next = [...prev, {
|
||||
action_type: 'MUTE',
|
||||
track_id: trackId,
|
||||
timestamp: Date.now(),
|
||||
before_state: beforeSnap,
|
||||
after_state: captureTrackSnapshot(trackId),
|
||||
}];
|
||||
if (next.length > MAX_UNDO) next.shift();
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrackVolume = (trackId, val) => {
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volume: val } : t));
|
||||
setUndoStack(prev => {
|
||||
const next = [...prev, {
|
||||
action_type: 'VOLUME_CHANGE',
|
||||
track_id: trackId,
|
||||
timestamp: Date.now(),
|
||||
before_state: beforeSnap,
|
||||
after_state: captureTrackSnapshot(trackId),
|
||||
}];
|
||||
if (next.length > MAX_UNDO) next.shift();
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── Load File on Track (with server upload) ──
|
||||
@@ -1320,43 +1841,122 @@
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-[#1e1e1e]">
|
||||
{/* ── Header ── */}
|
||||
<header className="h-11 bg-[#2e2e2e] border-b border-[#181818] flex items-center justify-between px-4 shrink-0 select-none">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-sm font-bold text-zinc-100 flex items-center gap-2">
|
||||
<i data-lucide="music" className="w-5 h-5 text-cyan-400"></i>
|
||||
SonicForge Studio
|
||||
</h1>
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider border-l border-zinc-700 pl-3">
|
||||
Professional DAW Editor
|
||||
</span>
|
||||
{/* ── Menu Bar ── */}
|
||||
<header className="h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none">
|
||||
{[
|
||||
{ label: 'File', items: [
|
||||
{ label: 'New Project', icon: 'file-plus', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
|
||||
{ label: 'Open Project...', icon: 'folder-open', action: () => showToast('Open project dialog','info') },
|
||||
{ label: 'Save Project', icon: 'save', action: () => showToast('Project saved','success') },
|
||||
{ label: 'Save As...', icon: 'save', action: () => showToast('Save as dialog','info') },
|
||||
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => showToast('Saving to cloud...','info') },
|
||||
{ sep: true },
|
||||
{ label: 'Import Audio...', icon: 'file-input', action: () => { const input = document.createElement('input'); input.type='file'; input.accept='audio/*'; input.onchange=async (e)=>{ if(e.target.files[0]){ addNewTrack(); const newId=(tracks.length+1).toString(); setTimeout(()=>loadFileOnTrack(newId,e.target.files[0]),100); } }; input.click(); showToast('Import audio','info'); } },
|
||||
{ label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() },
|
||||
{ sep: true },
|
||||
{ label: 'Logout', icon: 'log-out', action: () => showToast('Logged out','info') },
|
||||
]},
|
||||
{ label: 'Edit', items: [
|
||||
{ label: 'Insert New Track', icon: 'plus', action: addNewTrack },
|
||||
{ label: 'Insert Music to Track', icon: 'music', action: () => showToast('Select music file to insert','info') },
|
||||
{ sep: true },
|
||||
{ label: 'Edit in New Tab', icon: 'file-edit', action: () => openTempTab() },
|
||||
{ label: 'Split at Playhead', icon: 'scissors', action: () => handleSplitTrack(selectedTrackId) },
|
||||
{ label: 'Merge Tracks', icon: 'combine', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuMerge(); } },
|
||||
{ sep: true },
|
||||
{ label: 'Copy', icon: 'copy', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCopy(); } },
|
||||
{ label: 'Cut', icon: 'scissors', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCut(); } },
|
||||
{ label: 'Paste', icon: 'clipboard', action: contextMenuPaste },
|
||||
{ sep: true },
|
||||
{ label: 'Delete Track', icon: 'trash-2', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuDelete(); } },
|
||||
]},
|
||||
{ label: 'View', items: [
|
||||
{ label: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') },
|
||||
{ label: 'Maker View', icon: 'layout', action: () => showToast('Maker view','info') },
|
||||
{ label: 'Mixer', icon: 'sliders', action: () => showToast('Mixer panel','info') },
|
||||
{ label: 'Tempo Track', icon: 'timer', action: () => showToast('Tempo track','info') },
|
||||
{ label: 'Video', icon: 'film', action: () => showToast('Video panel','info') },
|
||||
{ label: 'Media Explorer', icon: 'folder-search', action: () => showToast('Media explorer','info') },
|
||||
]},
|
||||
{ label: 'Tools', items: [
|
||||
{ label: 'Config', icon: 'settings', action: () => setShowAIConfig(true) },
|
||||
]},
|
||||
{ label: 'Help', items: [
|
||||
{ label: 'About SonicForge', icon: 'info', action: () => showToast('SonicForge Studio v1.0 - Professional DAW','info') },
|
||||
{ label: 'Keyboard Shortcuts', icon: 'keyboard', action: () => showToast('Ctrl+Z: Undo | Ctrl+Y: Redo | Space: Play/Pause','info') },
|
||||
]},
|
||||
].map(menu => (
|
||||
<div key={menu.label} className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(menuOpen === menu.label ? null : menu.label)}
|
||||
className={`px-3 py-1 text-[11px] 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}
|
||||
</button>
|
||||
{menuOpen === menu.label && (
|
||||
<div className="absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-52 z-50"
|
||||
onClick={() => setMenuOpen(null)}>
|
||||
{menu.items.map((item, i) => item.sep ? (
|
||||
<div key={i} className="h-px bg-zinc-700 my-1"></div>
|
||||
) : (
|
||||
<button key={item.label} onClick={(e) => { e.stopPropagation(); item.action(); setMenuOpen(null); }}
|
||||
className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide={item.icon} className="w-3.5 h-3.5 text-zinc-500"></i> {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1"></div>
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<span className={`text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${
|
||||
serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' :
|
||||
serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' :
|
||||
'bg-red-950 text-red-400'
|
||||
}`}>
|
||||
Server: {serverStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={addNewTrack}
|
||||
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[11px] flex items-center gap-1 border border-zinc-700 transition"
|
||||
>
|
||||
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Thêm Track
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAIConfig(!showAIConfig)}
|
||||
className={`px-2 py-1 rounded text-[11px] flex items-center gap-1 border transition ${
|
||||
showAIConfig
|
||||
? 'bg-purple-900 text-purple-200 border-purple-700'
|
||||
: 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<i data-lucide="cpu" className="w-3.5 h-3.5"></i> AI Config
|
||||
}`}>Server: {serverStatus}</span>
|
||||
<button onClick={() => setShowAIConfig(!showAIConfig)}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] border transition ${
|
||||
showAIConfig ? 'bg-purple-900 text-purple-200 border-purple-700' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'
|
||||
}`}>
|
||||
<i data-lucide="cpu" className="w-3 h-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{/* Close menu on outside click */}
|
||||
{menuOpen && <div className="fixed inset-0 z-40" onClick={() => setMenuOpen(null)}></div>}
|
||||
|
||||
{/* ── Tab Bar (LOOP_EDITOR_2.md §1) ── */}
|
||||
<div className="h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto">
|
||||
<button onClick={() => setActiveTab('main')}
|
||||
className={`px-3 text-[10px] font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${
|
||||
activeTab === 'main'
|
||||
? 'text-cyan-400 border-cyan-500 bg-zinc-800/50'
|
||||
: 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'
|
||||
}`}>
|
||||
<i data-lucide="layout-dashboard" className="w-3 h-3"></i> Main Session
|
||||
</button>
|
||||
{subTabs.map(st => (
|
||||
<div key={st.id} className="flex items-stretch">
|
||||
<button onClick={() => setActiveTab(st.id)}
|
||||
className={`px-2 text-[10px] 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'
|
||||
}`}>
|
||||
<i data-lucide="file-edit" className="w-3 h-3"></i>
|
||||
<span className="max-w-[100px] truncate">{st.label}</span>
|
||||
</button>
|
||||
<button onClick={() => closeSubTab(st.id)}
|
||||
className="px-1 text-zinc-600 hover:text-red-400 transition text-[9px]"
|
||||
title="Close tab">
|
||||
<i data-lucide="x" className="w-3 h-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── AI Config Drawer ── */}
|
||||
{showAIConfig && (
|
||||
@@ -1400,7 +2000,7 @@
|
||||
)}
|
||||
|
||||
{/* ── Workspace ── */}
|
||||
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative">
|
||||
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||
{/* TCP Left Column */}
|
||||
<div className="w-[300px] flex flex-col daw-panel border-r border-zinc-900 z-10 select-none shrink-0">
|
||||
<div className="h-8 border-b border-zinc-900 bg-[#242424] flex items-center px-4 justify-between sticky top-0 z-30">
|
||||
@@ -1532,6 +2132,7 @@
|
||||
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
||||
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
selectionMode={selectionMode}
|
||||
localSelectionTrackId={localSelectionTrackId}
|
||||
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
||||
@@ -1550,8 +2151,8 @@
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Selection Overlay */}
|
||||
{selLeft !== null && selRight !== null && selRight > selLeft && (
|
||||
{/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */}
|
||||
{selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
|
||||
<div className="absolute top-8 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing"
|
||||
style={{ left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
||||
onMouseDown={handleSelectionBodyDragStart}
|
||||
@@ -1581,7 +2182,7 @@
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<footer className="h-44 bg-[#1c1c1c] border-t border-zinc-900 p-4 grid grid-cols-1 md:grid-cols-12 gap-4 text-xs shrink-0 select-none">
|
||||
<footer className="h-44 bg-[#1c1c1c] border-t border-zinc-900 p-4 grid grid-cols-1 md:grid-cols-12 gap-4 text-xs shrink-0 select-none" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||
|
||||
{/* Export Section */}
|
||||
<section className="md:col-span-4 bg-[#262626] border border-zinc-800 rounded p-3 flex flex-col justify-between">
|
||||
@@ -1694,11 +2295,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI Analysis Section */}
|
||||
{/* AI Analysis & Edit Section */}
|
||||
<section className="md:col-span-4 bg-[#262626] border border-zinc-800 rounded p-3 flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-zinc-200 flex items-center gap-1.5 mb-1">
|
||||
<i data-lucide="cpu" className="text-purple-400 w-4 h-4"></i> AI Analysis Engine
|
||||
<i data-lucide="cpu" className="text-purple-400 w-4 h-4"></i> Edit & AI Engine
|
||||
</h3>
|
||||
<div className="p-1.5 bg-[#141414] rounded border border-zinc-800 text-[10px] font-mono min-h-[46px] flex flex-col justify-center">
|
||||
<div className="text-zinc-500">// Status: <span className="text-zinc-300">{analysisState.status}</span></div>
|
||||
@@ -1727,11 +2328,29 @@
|
||||
<i data-lucide="sparkles" className="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button onClick={openTempTab}
|
||||
className="flex-1 py-1 px-1 bg-amber-800 hover:bg-amber-700 text-amber-100 font-bold rounded flex items-center justify-center gap-1 border border-amber-700 transition text-[11px]"
|
||||
title={selLeft !== null ? "Edit in Temp Tab (LOOP_EDITOR.md §2)" : "Select a region first"}>
|
||||
<i data-lucide="file-edit" className="w-3.5 h-3.5"></i> Edit in Temp Tab
|
||||
</button>
|
||||
<button onClick={handleUndo} disabled={undoStack.length === 0}
|
||||
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700 transition text-[11px]"
|
||||
title="Undo (Ctrl+Z)">
|
||||
<i data-lucide="undo" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<button onClick={handleRedo} disabled={redoStack.length === 0}
|
||||
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700 transition text-[11px]"
|
||||
title="Redo (Ctrl+Y)">
|
||||
<i data-lucide="redo" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<span className="text-[9px] text-zinc-600 flex items-center font-mono">{undoStack.length}/{MAX_UNDO}</span>
|
||||
</div>
|
||||
</section>
|
||||
</footer>
|
||||
|
||||
{/* ── Status Bar ── */}
|
||||
<div className="h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[10px] text-zinc-500 select-none shrink-0">
|
||||
<div className="h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[10px] text-zinc-500 select-none shrink-0" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Status: {isPlaying ? 'Playing' : 'Stopped'}</span>
|
||||
<span className="text-cyan-400 font-semibold uppercase">Track: ID {selectedTrackId}</span>
|
||||
@@ -1752,6 +2371,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sub-Tab Editor Panel (replaces workspace when active) ── */}
|
||||
{activeTab !== 'main' && (() => {
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
if (!st) return null;
|
||||
const fx = st.effects || {};
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-[#1e1e1e]">
|
||||
<div className="h-8 bg-[#2a2a2a] border-b border-zinc-700 flex items-center px-3 gap-2 shrink-0">
|
||||
<i data-lucide="file-edit" className="w-4 h-4 text-amber-400"></i>
|
||||
<span className="text-xs font-bold text-zinc-200">{st.label}</span>
|
||||
<span className="text-[10px] text-zinc-500 font-mono">
|
||||
({formatTime(st.startTime)} - {formatTime(st.endTime)})
|
||||
| {st.buffer ? formatTime(st.buffer.duration) : '0s'}
|
||||
| {st.buffer ? st.buffer.sampleRate : 0} Hz
|
||||
</span>
|
||||
<div className="flex-1"></div>
|
||||
<button onClick={() => closeSubTab(st.id)}
|
||||
className="px-2 py-0.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-[10px] border border-zinc-700 transition">
|
||||
<i data-lucide="x" className="w-3 h-3"></i> Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto">
|
||||
<SubTabWaveform buffer={st.buffer} />
|
||||
<div className="grid grid-cols-4 gap-3 max-w-2xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase">Reverse</span>
|
||||
<button onClick={() => updateSubTabEffects(st.id, { reverse: !fx.reverse })}
|
||||
className={`py-2 rounded text-xs font-bold border transition ${
|
||||
fx.reverse
|
||||
? 'bg-amber-800 text-amber-100 border-amber-600'
|
||||
: 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'
|
||||
}`}>
|
||||
<i data-lucide="arrow-left-right" className="w-4 h-4 mx-auto"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase">Gain (dB)</span>
|
||||
<input type="number" step="0.5" value={fx.gainDb || 0}
|
||||
onChange={(e) => updateSubTabEffects(st.id, { gainDb: parseFloat(e.target.value) || 0 })}
|
||||
className="bg-zinc-800 text-zinc-200 text-center font-mono text-xs rounded border border-zinc-700 p-1.5 focus:outline-none focus:border-amber-600" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase">Fade In (ms)</span>
|
||||
<input type="number" step="10" min="0" value={fx.fadeInMs || 0}
|
||||
onChange={(e) => updateSubTabEffects(st.id, { fadeInMs: parseInt(e.target.value) || 0 })}
|
||||
className="bg-zinc-800 text-zinc-200 text-center font-mono text-xs rounded border border-zinc-700 p-1.5 focus:outline-none focus:border-amber-600" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase">Fade Out (ms)</span>
|
||||
<input type="number" step="10" min="0" value={fx.fadeOutMs || 0}
|
||||
onChange={(e) => updateSubTabEffects(st.id, { fadeOutMs: parseInt(e.target.value) || 0 })}
|
||||
className="bg-zinc-800 text-zinc-200 text-center font-mono text-xs rounded border border-zinc-700 p-1.5 focus:outline-none focus:border-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-10 bg-[#2a2a2a] border-t border-zinc-700 flex items-center justify-end px-3 gap-2 shrink-0">
|
||||
<button onClick={() => closeSubTab(st.id)}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs font-bold border border-zinc-700 transition">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={() => applySubTab(st.id)}
|
||||
className="px-3 py-1.5 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs font-bold transition shadow-md">
|
||||
<i data-lucide="check" className="w-3.5 h-3.5 inline mr-1"></i> Apply & Merge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── Context Menu ── */}
|
||||
{contextMenu && (
|
||||
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-44" style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<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">
|
||||
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400"></i> Edit
|
||||
</button>
|
||||
<button onClick={contextMenuSplit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400"></i> Split
|
||||
</button>
|
||||
<button onClick={contextMenuMerge} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400"></i> Merge
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuCopy} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400"></i> Copy
|
||||
</button>
|
||||
<button onClick={contextMenuCut} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400"></i> Cut
|
||||
</button>
|
||||
<button onClick={contextMenuPaste} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400"></i> Paste
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuDelete} className="w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2">
|
||||
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Toast ── */}
|
||||
{toastMessage && (
|
||||
<div className="absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800">
|
||||
|
||||
Reference in New Issue
Block a user