2493 lines
138 KiB
HTML
2493 lines
138 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="vi">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SonicForge Studio - Professional DAW Editor</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
<style>
|
|
body {
|
|
background-color: #1a1a1a;
|
|
color: #c0c0c0;
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
.daw-bg { background-color: #1e1e1e; }
|
|
.daw-panel { background-color: #262626; }
|
|
.daw-header { background-color: #2e2e2e; }
|
|
.daw-border { border-color: #181818; }
|
|
.daw-track-active { background-color: #333333; }
|
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
::-webkit-scrollbar-track { background: #141414; }
|
|
::-webkit-scrollbar-thumb { background: #3a3a3a; border: 2px solid #141414; border-radius: 4px; }
|
|
::-webkit-scrollbar-thumb:hover { background: #4a4a4a; }
|
|
.knob-container { position: relative; width: 28px; height: 28px; }
|
|
.knob-dial { transform-origin: center; transition: transform 0.1s ease; }
|
|
.selection-interactive-box {
|
|
min-width: 4px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="h-screen w-screen flex flex-col">
|
|
<div id="root" class="h-full w-full flex flex-col"></div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useRef, useEffect, useMemo } = React;
|
|
|
|
// ── FastAPI Backend Configuration ──
|
|
const API_BASE_URL = window.location.origin;
|
|
const API_AUDIO = `${API_BASE_URL}/api/v1/audio`;
|
|
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
|
|
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
|
|
|
|
// Storage for server-side file IDs mapped to track IDs
|
|
let serverFileIdMap = {};
|
|
|
|
let audioCtx;
|
|
function getAudioContext() {
|
|
if (!audioCtx) {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
if (audioCtx.state === 'suspended') {
|
|
audioCtx.resume();
|
|
}
|
|
return audioCtx;
|
|
}
|
|
|
|
const formatTime = (secs) => {
|
|
if (isNaN(secs) || secs < 0) return "0:00.000";
|
|
const m = Math.floor(secs / 60);
|
|
const s = Math.floor(secs % 60);
|
|
const ms = Math.floor((secs % 1) * 1000).toString().padStart(3, '0');
|
|
return `${m}:${s.toString().padStart(2, '0')}.${ms}`;
|
|
};
|
|
|
|
const findZeroCrossing = (buffer, targetTime) => {
|
|
if (!buffer) return targetTime;
|
|
const sampleRate = buffer.sampleRate;
|
|
const data = buffer.getChannelData(0);
|
|
const targetSample = Math.floor(targetTime * sampleRate);
|
|
const windowSize = Math.floor(0.04 * sampleRate);
|
|
const start = Math.max(0, targetSample - windowSize);
|
|
const end = Math.min(data.length - 2, targetSample + windowSize);
|
|
|
|
let bestSample = targetSample;
|
|
let minDistance = Infinity;
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
if ((data[i] >= 0 && data[i+1] <= 0) || (data[i] <= 0 && data[i+1] >= 0)) {
|
|
const dist = Math.abs(i - targetSample);
|
|
if (dist < minDistance) {
|
|
minDistance = dist;
|
|
bestSample = i;
|
|
}
|
|
}
|
|
}
|
|
return bestSample / sampleRate;
|
|
};
|
|
|
|
const VolumeKnob = ({ value, onChange, min = 0, max = 1 }) => {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const startY = useRef(0);
|
|
const startValue = useRef(0);
|
|
|
|
const rotation = useMemo(() => {
|
|
const percent = (value - min) / (max - min);
|
|
return -135 + percent * 270;
|
|
}, [value, min, max]);
|
|
|
|
const handleMouseDown = (e) => {
|
|
setIsDragging(true);
|
|
startY.current = e.clientY;
|
|
startValue.current = value;
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
const handleMouseMove = (e) => {
|
|
const deltaY = startY.current - e.clientY;
|
|
const sensitivity = 0.005;
|
|
const newValue = Math.max(min, Math.min(max, startValue.current + deltaY * sensitivity));
|
|
onChange(parseFloat(newValue.toFixed(2)));
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="knob-container cursor-ns-resize flex flex-col items-center"
|
|
onMouseDown={handleMouseDown}
|
|
title={`Volume: ${Math.round(value * 100)}%`}
|
|
>
|
|
<svg className="w-7 h-7" viewBox="0 0 40 40">
|
|
<circle cx="20" cy="20" r="16" fill="#141414" stroke="#444" strokeWidth="2" />
|
|
<g transform={`rotate(${rotation} 20 20)`} className="knob-dial">
|
|
<line x1="20" y1="20" x2="20" y2="6" stroke="#ef4444" strokeWidth="3" strokeLinecap="round" />
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const WaveformLane = ({
|
|
track,
|
|
zoom,
|
|
timelineWidth,
|
|
onSelectRange,
|
|
onPlayheadSet,
|
|
isSelected,
|
|
onSelectTrack,
|
|
markers,
|
|
selectionMode,
|
|
localSelectionTrackId,
|
|
localSelLeft,
|
|
localSelRight,
|
|
onTrackLaneMouseDown,
|
|
onContextMenu,
|
|
}) => {
|
|
const canvasRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const width = timelineWidth;
|
|
const height = canvas.parentElement.clientHeight;
|
|
|
|
canvas.width = width * dpr;
|
|
canvas.height = height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.02)';
|
|
ctx.lineWidth = 1;
|
|
const stepSeconds = zoom > 120 ? 0.5 : 1;
|
|
const totalSec = width / zoom;
|
|
|
|
for (let s = 0; s <= totalSec; s += stepSeconds) {
|
|
const x = s * zoom;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, height);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw waveform lane
|
|
if (track.buffer) {
|
|
const data = track.buffer.getChannelData(0);
|
|
const sampleRate = track.buffer.sampleRate;
|
|
const totalSamples = data.length;
|
|
const duration = totalSamples / sampleRate;
|
|
const visibleStart = 0;
|
|
const visibleEnd = Math.max(1, width / zoom);
|
|
const startSample = Math.floor(visibleStart * sampleRate);
|
|
const endSample = Math.min(totalSamples, Math.ceil(visibleEnd * sampleRate));
|
|
const samplesToDraw = endSample - startSample;
|
|
const pixelsPerSample = samplesToDraw > 0 ? width / samplesToDraw : 1;
|
|
|
|
// Draw markers
|
|
if (markers && markers.length > 0) {
|
|
markers.forEach(m => {
|
|
const mx = m.time * zoom;
|
|
ctx.fillStyle = '#fbbf24';
|
|
ctx.fillRect(mx - 1, 0, 2, height);
|
|
ctx.fillStyle = 'rgba(251, 191, 36, 0.1)';
|
|
ctx.fillRect(mx - 1, 0, 2, height);
|
|
});
|
|
}
|
|
|
|
// Peak waveform drawing
|
|
ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let px = 0; px < width; px++) {
|
|
const sampleIdx = startSample + Math.floor(px / pixelsPerSample);
|
|
const chunkSize = Math.max(1, Math.floor(1 / pixelsPerSample));
|
|
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
|
const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize);
|
|
|
|
let maxVal = 0;
|
|
for (let i = chunkStart; i < chunkEnd; i++) {
|
|
const abs = Math.abs(data[i]);
|
|
if (abs > maxVal) maxVal = abs;
|
|
}
|
|
|
|
const mid = height / 2;
|
|
const peakHeight = maxVal * (height * 0.4);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(px, mid - peakHeight);
|
|
ctx.lineTo(px, mid + peakHeight);
|
|
ctx.stroke();
|
|
}
|
|
} else {
|
|
ctx.fillStyle = '#444';
|
|
ctx.font = '12px Inter, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', width / 2, height / 2);
|
|
}
|
|
|
|
// Selection highlight - local selection on this track
|
|
if (selectionMode === 'local' && localSelectionTrackId === track.id &&
|
|
localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
|
|
const hlLeft = localSelLeft * zoom;
|
|
const hlWidth = (localSelRight - localSelLeft) * zoom;
|
|
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
|
|
ctx.fillRect(hlLeft, 0, hlWidth, height);
|
|
ctx.strokeStyle = '#f59e0b';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeRect(hlLeft, 0, hlWidth, height);
|
|
}
|
|
|
|
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight]);
|
|
|
|
return (
|
|
<canvas
|
|
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();
|
|
const x = e.clientX - rect.left + scrollLeft;
|
|
const time = Math.max(0, x / zoom);
|
|
onSelectTrack(track.id);
|
|
onPlayheadSet(time);
|
|
if (onTrackLaneMouseDown) {
|
|
onTrackLaneMouseDown(track.id, time, e);
|
|
}
|
|
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([
|
|
{ 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 },
|
|
]);
|
|
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [selectionStart, setSelectionStart] = useState(null);
|
|
const [selectionEnd, setSelectionEnd] = useState(null);
|
|
const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track)
|
|
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
|
|
const [localSelectionStart, setLocalSelectionStart] = useState(null);
|
|
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
|
|
const [zoom, setZoom] = useState(100);
|
|
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [soloedTrackId, setSoloedTrackId] = useState(null);
|
|
const [toastMessage, setToastMessage] = useState(null);
|
|
const [showAIConfig, setShowAIConfig] = useState(false);
|
|
const [aiConfig, setAiConfig] = useState({
|
|
baseUrl: localStorage.getItem('ai_base_url') || `${API_BASE_URL}`,
|
|
apiKey: localStorage.getItem('ai_api_key') || '',
|
|
model: localStorage.getItem('ai_model') || 'deepseek-chat',
|
|
});
|
|
const [analysisState, setAnalysisState] = useState({
|
|
status: 'Sẵn sàng. Chạy AI để phân tích nhịp.',
|
|
data: null,
|
|
isRunning: false,
|
|
});
|
|
const [exportSettings, setExportSettings] = useState({
|
|
sampleRate: '44100',
|
|
bitDepth: '16',
|
|
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);
|
|
const activeSourcesRef = useRef([]);
|
|
const startOffsetTimeRef = useRef(0);
|
|
const startAudioTimeRef = useRef(0);
|
|
const animationFrameIdRef = useRef(null);
|
|
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(() => {
|
|
fetch(API_BASE_URL)
|
|
.then(r => { if (r.ok) setServerStatus('connected'); else setServerStatus('error'); })
|
|
.catch(() => setServerStatus('offline'));
|
|
}, []);
|
|
|
|
// ── Computed Values ──
|
|
const maxDuration = useMemo(() => {
|
|
let max = 10;
|
|
tracks.forEach(t => {
|
|
if (t.buffer) max = Math.max(max, t.buffer.duration);
|
|
});
|
|
return max;
|
|
}, [tracks]);
|
|
|
|
const timelineWidth = useMemo(() => Math.max(zoom * maxDuration, 1200), [zoom, maxDuration]);
|
|
|
|
const minZoom = useMemo(() => Math.max(20, 1200 / maxDuration), [maxDuration]);
|
|
|
|
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
|
|
|
|
const selLeft = useMemo(() => {
|
|
if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) {
|
|
return Math.min(localSelectionStart, localSelectionEnd);
|
|
}
|
|
if (selectionStart === null || selectionEnd === null) return null;
|
|
return Math.min(selectionStart, selectionEnd);
|
|
}, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]);
|
|
|
|
const selRight = useMemo(() => {
|
|
if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) {
|
|
return Math.max(localSelectionStart, localSelectionEnd);
|
|
}
|
|
if (selectionStart === null || selectionEnd === null) return null;
|
|
return Math.max(selectionStart, selectionEnd);
|
|
}, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]);
|
|
|
|
// ── Toast helper ──
|
|
const showToast = (text, type = 'info') => {
|
|
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
|
|
setToastMessage({ text, type });
|
|
toastTimeoutRef.current = setTimeout(() => setToastMessage(null), 3500);
|
|
};
|
|
|
|
// ── Server-side upload ──
|
|
const uploadToServer = async (file, trackId) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const resp = await fetch(`${API_AUDIO}/upload`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
|
|
const data = await resp.json();
|
|
serverFileIdMap[trackId] = data.file_id;
|
|
return data;
|
|
} catch (err) {
|
|
console.warn('Server upload failed, using client-side only:', err.message);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// ── Server-side waveform loading ──
|
|
const loadServerWaveform = async (fileId) => {
|
|
try {
|
|
const resp = await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`);
|
|
if (!resp.ok) return null;
|
|
return await resp.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// ── Check Celery task result ──
|
|
const pollTaskResult = async (taskId, maxPoll = 10) => {
|
|
for (let i = 0; i < maxPoll; i++) {
|
|
await new Promise(r => setTimeout(r, 1500));
|
|
try {
|
|
const resp = await fetch(`${API_TASKS}/${taskId}`);
|
|
if (!resp.ok) continue;
|
|
const data = await resp.json();
|
|
if (data.status === 'SUCCESS') return data.result;
|
|
if (data.status === 'FAILURE') throw new Error(data.error || 'Task failed');
|
|
} catch (err) {
|
|
throw err;
|
|
}
|
|
}
|
|
throw new Error('Task polling timeout');
|
|
};
|
|
|
|
// ── Wheel Zoom ──
|
|
useEffect(() => {
|
|
const timeline = timelineWrapperRef.current;
|
|
if (!timeline) return;
|
|
|
|
const handleWheel = (e) => {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
const rect = timeline.getBoundingClientRect();
|
|
const mouseXInViewport = e.clientX - rect.left;
|
|
const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft;
|
|
const anchorTime = mouseXInCanvas / zoom;
|
|
|
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
|
|
setZoom(prevZoom => {
|
|
let newZoom = prevZoom * zoomFactor;
|
|
if (newZoom < minZoom) newZoom = minZoom;
|
|
if (newZoom > 2000) newZoom = 2000;
|
|
|
|
const newMouseXInCanvas = anchorTime * newZoom;
|
|
requestAnimationFrame(() => {
|
|
timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport;
|
|
});
|
|
return newZoom;
|
|
});
|
|
} else {
|
|
e.preventDefault();
|
|
// Horizontal scroll
|
|
const dir = e.deltaY > 0 ? 1 : -1;
|
|
timeline.scrollLeft += dir * 60;
|
|
}
|
|
};
|
|
|
|
timeline.addEventListener('wheel', handleWheel, { passive: false });
|
|
return () => timeline.removeEventListener('wheel', handleWheel);
|
|
}, [zoom, minZoom, maxDuration]);
|
|
|
|
// ── Update Playhead ──
|
|
const updatePlayhead = () => {
|
|
if (!isPlaying) return;
|
|
const context = getAudioContext();
|
|
const elapsed = context.currentTime - startAudioTimeRef.current;
|
|
const updatedTime = startOffsetTimeRef.current + elapsed;
|
|
|
|
// 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
|
|
stopAllPlayback();
|
|
startOffsetTimeRef.current = selLeft;
|
|
startAudioTimeRef.current = context.currentTime;
|
|
startLocalTrackPlayback(localSelectionTrackId, selLeft);
|
|
setCurrentTime(selLeft);
|
|
setIsPlaying(true);
|
|
} else {
|
|
// Global Master Loop: restart all tracks
|
|
stopAllPlayback();
|
|
startOffsetTimeRef.current = selLeft;
|
|
startAudioTimeRef.current = context.currentTime;
|
|
startTrackPlayback(selLeft);
|
|
setCurrentTime(selLeft);
|
|
setIsPlaying(true);
|
|
}
|
|
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (updatedTime >= maxDuration) {
|
|
stopAllPlayback();
|
|
setCurrentTime(0);
|
|
return;
|
|
}
|
|
|
|
setCurrentTime(updatedTime);
|
|
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isPlaying) {
|
|
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
|
} else {
|
|
cancelAnimationFrame(animationFrameIdRef.current);
|
|
}
|
|
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
|
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared]);
|
|
|
|
// ── Playback ──
|
|
const startTrackPlayback = (offsetTime) => {
|
|
const context = getAudioContext();
|
|
const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null;
|
|
|
|
tracks.forEach(track => {
|
|
if (!track.buffer) return;
|
|
|
|
const isPlayable = hasSolo
|
|
? (track.id === soloedTrackId || track.solo)
|
|
: !track.muted;
|
|
|
|
if (!isPlayable) return;
|
|
|
|
const source = context.createBufferSource();
|
|
source.buffer = track.buffer;
|
|
|
|
const gainNode = context.createGain();
|
|
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
|
|
|
source.connect(gainNode);
|
|
gainNode.connect(context.destination);
|
|
|
|
if (offsetTime < track.buffer.duration) {
|
|
source.start(0, offsetTime);
|
|
activeSourcesRef.current.push(source);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
|
|
const startLocalTrackPlayback = (trackId, offsetTime) => {
|
|
const context = getAudioContext();
|
|
const track = tracks.find(t => t.id === trackId);
|
|
if (!track || !track.buffer) return;
|
|
|
|
const source = context.createBufferSource();
|
|
source.buffer = track.buffer;
|
|
|
|
const gainNode = context.createGain();
|
|
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
|
|
|
source.connect(gainNode);
|
|
gainNode.connect(context.destination);
|
|
|
|
if (offsetTime < track.buffer.duration) {
|
|
source.start(0, offsetTime);
|
|
activeSourcesRef.current.push(source);
|
|
}
|
|
};
|
|
|
|
const handlePlayPause = () => {
|
|
const context = getAudioContext();
|
|
if (isPlaying) {
|
|
stopAllPlayback();
|
|
} else {
|
|
startOffsetTimeRef.current = currentTime;
|
|
startAudioTimeRef.current = context.currentTime;
|
|
startTrackPlayback(currentTime);
|
|
setIsPlaying(true);
|
|
}
|
|
};
|
|
handlePlayPauseRef.current = handlePlayPause;
|
|
|
|
const handlePause = () => {
|
|
if (isPlaying) stopAllPlayback();
|
|
};
|
|
|
|
const stopAllPlayback = () => {
|
|
activeSourcesRef.current.forEach(src => {
|
|
try { src.stop(); } catch(e) {}
|
|
});
|
|
activeSourcesRef.current = [];
|
|
setIsPlaying(false);
|
|
};
|
|
|
|
const handleStop = () => {
|
|
stopAllPlayback();
|
|
setCurrentTime(0);
|
|
};
|
|
|
|
// ── Selection ──
|
|
const clearLocalSelection = () => {
|
|
setSelectionMode(null);
|
|
setLocalSelectionTrackId(null);
|
|
setLocalSelectionStart(null);
|
|
setLocalSelectionEnd(null);
|
|
};
|
|
|
|
const handleRulerMouseDown = (e) => {
|
|
const ruler = rulerRef.current;
|
|
if (!ruler) return;
|
|
const rect = ruler.getBoundingClientRect();
|
|
const wrapper = timelineWrapperRef.current;
|
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
|
const time = mouseX / zoom;
|
|
|
|
clearLocalSelection();
|
|
setSelectionMode('global');
|
|
rulerDragStartRef.current = time;
|
|
isDraggingRulerRef.current = true;
|
|
setSelectionStart(time);
|
|
setSelectionEnd(time);
|
|
setCurrentTime(time);
|
|
};
|
|
|
|
// Global Ruler mousemove is tracked via document listener set up in useEffect
|
|
useEffect(() => {
|
|
const handleMouseMove = (e) => {
|
|
if (!isDraggingRulerRef.current) return;
|
|
const ruler = rulerRef.current;
|
|
const wrapper = timelineWrapperRef.current;
|
|
if (!ruler || !wrapper) return;
|
|
const rect = ruler.getBoundingClientRect();
|
|
const scrollLeft = wrapper.scrollLeft;
|
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
|
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
|
|
|
|
setSelectionEnd(time);
|
|
};
|
|
const handleMouseUp = () => {
|
|
if (isDraggingRulerRef.current) {
|
|
isDraggingRulerRef.current = false;
|
|
rulerDragStartRef.current = null;
|
|
}
|
|
};
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [zoom, maxDuration]);
|
|
|
|
// ── Track Lane Local Selection Drag ──
|
|
const localDragInProgressRef = useRef(false);
|
|
const localDragTrackRef = useRef(null);
|
|
const localDragStartTimeRef = useRef(0);
|
|
|
|
const handleTrackLaneMouseDown = (trackId, time) => {
|
|
clearLocalSelection();
|
|
setSelectionMode('local');
|
|
setLocalSelectionTrackId(trackId);
|
|
setLocalSelectionStart(time);
|
|
setLocalSelectionEnd(time);
|
|
|
|
localDragInProgressRef.current = true;
|
|
localDragTrackRef.current = trackId;
|
|
localDragStartTimeRef.current = time;
|
|
};
|
|
|
|
// Document-level mousemove/mouseup for local selection drag
|
|
useEffect(() => {
|
|
const handleMouseMove = (e) => {
|
|
if (!localDragInProgressRef.current) return;
|
|
const wrapper = timelineWrapperRef.current;
|
|
if (!wrapper) return;
|
|
const rect = wrapper.getBoundingClientRect();
|
|
const scrollLeft = wrapper.scrollLeft;
|
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
|
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
|
|
setLocalSelectionEnd(time);
|
|
};
|
|
const handleMouseUp = () => {
|
|
if (localDragInProgressRef.current) {
|
|
localDragInProgressRef.current = false;
|
|
localDragTrackRef.current = null;
|
|
localDragStartTimeRef.current = 0;
|
|
}
|
|
};
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [zoom, maxDuration]);
|
|
|
|
const handleSelectRange = (start, end, reset) => {
|
|
const maxLen = maxDuration;
|
|
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
|
const cleanEnd = Math.max(0, Math.min(maxLen, end));
|
|
|
|
if (reset) {
|
|
setSelectionStart(cleanStart);
|
|
setSelectionEnd(cleanEnd);
|
|
} else {
|
|
setSelectionEnd(cleanEnd);
|
|
}
|
|
// LOOP_EDITOR_2.md §4.2: new selection = enable looping
|
|
setSelectionCleared(false);
|
|
};
|
|
|
|
const handleSelectionInputChange = (field, val) => {
|
|
const numericVal = Math.max(0, parseFloat(val) || 0);
|
|
if (selectionMode === 'local') {
|
|
// Editing local selection directly
|
|
if (field === 'start') {
|
|
setLocalSelectionStart(numericVal);
|
|
} else {
|
|
setLocalSelectionEnd(numericVal);
|
|
}
|
|
} else {
|
|
if (field === 'start') {
|
|
setSelectionStart(numericVal);
|
|
} else {
|
|
setSelectionEnd(numericVal);
|
|
}
|
|
}
|
|
};
|
|
|
|
const selectionStats = useMemo(() => {
|
|
if (selLeft === null || selRight === null) {
|
|
return { start: 0, end: 0, length: 0 };
|
|
}
|
|
const s = Math.min(selLeft, selRight);
|
|
const e = Math.max(selLeft, selRight);
|
|
return {
|
|
start: parseFloat(s.toFixed(3)),
|
|
end: parseFloat(e.toFixed(3)),
|
|
length: parseFloat((e - s).toFixed(3))
|
|
};
|
|
}, [selLeft, selRight]);
|
|
|
|
// ── Handle Drag (selection resize) ──
|
|
const handleHandleDragStart = (e, side) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const startX = e.clientX;
|
|
const useLocal = selectionMode === 'local';
|
|
const currentStart = useLocal ? localSelectionStart : selectionStart;
|
|
const currentEnd = useLocal ? localSelectionEnd : selectionEnd;
|
|
const initialLeft = Math.min(currentStart, currentEnd);
|
|
const initialRight = Math.max(currentStart, currentEnd);
|
|
const setStart = useLocal ? setLocalSelectionStart : setSelectionStart;
|
|
const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd;
|
|
|
|
const handleMouseMove = (moveEvent) => {
|
|
const deltaX = moveEvent.clientX - startX;
|
|
const deltaSec = deltaX / zoom;
|
|
if (side === 'left') {
|
|
const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec));
|
|
setStart(newLeft);
|
|
setEnd(initialRight);
|
|
} else {
|
|
const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec));
|
|
setStart(initialLeft);
|
|
setEnd(newRight);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
const handleSelectionBodyDragStart = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const startX = e.clientX;
|
|
const useLocal = selectionMode === 'local';
|
|
const currentStart = useLocal ? localSelectionStart : selectionStart;
|
|
const currentEnd = useLocal ? localSelectionEnd : selectionEnd;
|
|
const initialLeft = Math.min(currentStart, currentEnd);
|
|
const initialRight = Math.max(currentStart, currentEnd);
|
|
const widthSec = initialRight - initialLeft;
|
|
const setStart = useLocal ? setLocalSelectionStart : setSelectionStart;
|
|
const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd;
|
|
|
|
const handleMouseMove = (moveEvent) => {
|
|
const deltaX = moveEvent.clientX - startX;
|
|
const deltaSec = deltaX / zoom;
|
|
let newLeft = initialLeft + deltaSec;
|
|
let newRight = initialRight + deltaSec;
|
|
|
|
if (newLeft < 0) {
|
|
newLeft = 0;
|
|
newRight = widthSec;
|
|
}
|
|
if (newRight > maxDuration) {
|
|
newRight = maxDuration;
|
|
newLeft = maxDuration - widthSec;
|
|
}
|
|
|
|
setStart(newLeft);
|
|
setEnd(newRight);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
};
|
|
|
|
// ── Track Controls ──
|
|
const toggleTrackSoloEvaluate = (trackId) => {
|
|
const wasPlaying = isPlaying;
|
|
stopAllPlayback();
|
|
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id === trackId) {
|
|
return { ...t, solo: !t.solo, muted: false };
|
|
}
|
|
return { ...t, solo: false };
|
|
}));
|
|
|
|
setSoloedTrackId(prev => prev === trackId ? null : trackId);
|
|
|
|
if (wasPlaying) {
|
|
setTimeout(() => {
|
|
startOffsetTimeRef.current = currentTime;
|
|
startAudioTimeRef.current = getAudioContext().currentTime;
|
|
setIsPlaying(true);
|
|
}, 50);
|
|
}
|
|
};
|
|
|
|
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) ──
|
|
const loadFileOnTrack = async (trackId, file) => {
|
|
if (!file) return;
|
|
const context = getAudioContext();
|
|
showToast(`Đang nạp file ${file.name}...`, 'info');
|
|
|
|
try {
|
|
// Upload to server
|
|
uploadToServer(file, trackId);
|
|
|
|
// Decode locally for playback
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const decodedBuffer = await context.decodeAudioData(e.target.result);
|
|
setTracks(prev => prev.map(t => t.id === trackId ? {
|
|
...t,
|
|
name: file.name,
|
|
buffer: decodedBuffer
|
|
} : t));
|
|
showToast(`Nạp file thành công: ${file.name}`, 'success');
|
|
} catch (err) {
|
|
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
|
|
}
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
} catch (err) {
|
|
showToast("Lỗi: " + err.message, 'error');
|
|
}
|
|
};
|
|
|
|
// ── Synth Generators ──
|
|
const generateSynthToTrack = (trackId, type) => {
|
|
const sampleRate = 44100;
|
|
const duration = 12.0;
|
|
const context = getAudioContext();
|
|
const frameCount = sampleRate * duration;
|
|
const newBuffer = context.createBuffer(1, frameCount, sampleRate);
|
|
const channelData = newBuffer.getChannelData(0);
|
|
|
|
if (type === 'kick') {
|
|
for (let i = 0; i < frameCount; i++) {
|
|
const t = i / sampleRate;
|
|
const beatTime = t % 0.5;
|
|
const freq = 120 * Math.exp(-35 * beatTime);
|
|
channelData[i] = Math.sin(2 * Math.PI * freq * beatTime) * Math.exp(-6 * beatTime);
|
|
}
|
|
} else {
|
|
const notes = [220.00, 261.63, 293.66, 329.63, 392.00];
|
|
for (let i = 0; i < frameCount; i++) {
|
|
const t = i / sampleRate;
|
|
const noteIdx = Math.floor(t * 2) % notes.length;
|
|
const freq = notes[noteIdx];
|
|
channelData[i] = Math.sin(2 * Math.PI * freq * t) * 0.25 * (1.0 - (t % 0.5) / 0.5);
|
|
}
|
|
}
|
|
|
|
setTracks(prev => prev.map(t => t.id === trackId ? {
|
|
...t, name: `Demo_${type.toUpperCase()}.wav`, buffer: newBuffer
|
|
} : t));
|
|
showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success');
|
|
};
|
|
|
|
// ── Add Track ──
|
|
const addNewTrack = () => {
|
|
const newId = (tracks.length + 1).toString();
|
|
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
|
const selectColor = colors[tracks.length % colors.length];
|
|
|
|
setTracks(prev => [...prev, {
|
|
id: newId, name: `Track ${newId}`, buffer: null,
|
|
volume: 0.8, muted: false, solo: false,
|
|
color: selectColor, markers: [], serverFileId: null
|
|
}]);
|
|
showToast(`Đã thêm Track ${newId}.`, 'info');
|
|
setTimeout(() => lucide.createIcons(), 200);
|
|
};
|
|
|
|
// ── Server-side Export ──
|
|
const triggerWavExport = async () => {
|
|
const activeTracks = tracks.filter(t => t.buffer && !t.muted);
|
|
if (activeTracks.length === 0) {
|
|
showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning");
|
|
return;
|
|
}
|
|
|
|
// Check if all active tracks have server file IDs
|
|
const allOnServer = activeTracks.every(t => serverFileIdMap[t.id]);
|
|
|
|
if (allOnServer && serverStatus === 'connected') {
|
|
// Use server-side export
|
|
setIsExporting(true);
|
|
showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info");
|
|
|
|
try {
|
|
const sessionId = `session_${Date.now()}`;
|
|
const tracksMeta = activeTracks.map(t => ({
|
|
track_id: t.id,
|
|
file_id: serverFileIdMap[t.id],
|
|
volume: t.volume,
|
|
muted: false,
|
|
clips: [{
|
|
clip_id: `clip_${t.id}`,
|
|
start_time_seconds: 0,
|
|
end_time_seconds: t.buffer.duration,
|
|
loop_count: 1,
|
|
apply_zero_crossing: true,
|
|
fade_in_ms: 0,
|
|
fade_out_ms: 0,
|
|
}]
|
|
}));
|
|
|
|
const resp = await fetch(`${API_MULTITRACK}/mix`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
export_settings: {
|
|
sample_rate: parseInt(exportSettings.sampleRate),
|
|
bit_depth: parseInt(exportSettings.bitDepth),
|
|
format: exportSettings.format,
|
|
},
|
|
tracks: tracksMeta,
|
|
}),
|
|
});
|
|
|
|
if (!resp.ok) throw new Error(`Server export failed: ${resp.status}`);
|
|
const data = await resp.json();
|
|
|
|
showToast("Đang xử lý trên máy chủ...", "info");
|
|
|
|
// Poll for result
|
|
const result = await pollTaskResult(data.task_id, 20);
|
|
|
|
if (result.success) {
|
|
// Get the uploaded file from server
|
|
const fileId = result.output_file_id || result.output_path?.split('/').pop();
|
|
if (fileId) {
|
|
const downloadUrl = `${API_AUDIO}/download/${fileId}`;
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = fileId;
|
|
a.click();
|
|
showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success");
|
|
}
|
|
} else {
|
|
throw new Error(result.error || 'Server processing failed');
|
|
}
|
|
} catch (err) {
|
|
showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning");
|
|
// Fall back to client-side export
|
|
clientSideExport(activeTracks);
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
} else {
|
|
// Client-side export (existing working code)
|
|
clientSideExport(activeTracks);
|
|
}
|
|
};
|
|
|
|
const clientSideExport = async (activeTracks) => {
|
|
setIsExporting(true);
|
|
showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info");
|
|
|
|
try {
|
|
const targetRate = parseInt(exportSettings.sampleRate);
|
|
const bitDepth = parseInt(exportSettings.bitDepth);
|
|
const durationLimit = Math.max(...activeTracks.map(t => t.buffer.duration));
|
|
|
|
const offlineCtx = new OfflineAudioContext(1, targetRate * durationLimit, targetRate);
|
|
|
|
activeTracks.forEach(t => {
|
|
const source = offlineCtx.createBufferSource();
|
|
source.buffer = t.buffer;
|
|
const gain = offlineCtx.createGain();
|
|
gain.gain.setValueAtTime(t.volume, 0);
|
|
source.connect(gain);
|
|
gain.connect(offlineCtx.destination);
|
|
source.start(0);
|
|
});
|
|
|
|
const renderedBuffer = await offlineCtx.startRendering();
|
|
const monoData = renderedBuffer.getChannelData(0);
|
|
const bufferLength = monoData.length;
|
|
const bytesPerSample = bitDepth / 8;
|
|
const headerSize = 44;
|
|
const fileSizeBytes = headerSize + (bufferLength * bytesPerSample);
|
|
const fileBuffer = new ArrayBuffer(fileSizeBytes);
|
|
const view = new DataView(fileBuffer);
|
|
|
|
const writeString = (offset, string) => {
|
|
for (let i = 0; i < string.length; i++) {
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|
}
|
|
};
|
|
|
|
writeString(0, 'RIFF');
|
|
view.setUint32(4, fileSizeBytes - 8, true);
|
|
writeString(8, 'WAVE');
|
|
writeString(12, 'fmt ');
|
|
view.setUint32(16, 16, true);
|
|
view.setUint16(20, 1, true);
|
|
view.setUint16(22, 1, true);
|
|
view.setUint32(24, targetRate, true);
|
|
view.setUint32(28, targetRate * bytesPerSample, true);
|
|
view.setUint16(32, bytesPerSample, true);
|
|
view.setUint16(34, bitDepth, true);
|
|
writeString(36, 'data');
|
|
view.setUint32(40, bufferLength * bytesPerSample, true);
|
|
|
|
let offset = 44;
|
|
for (let i = 0; i < bufferLength; i++) {
|
|
const sample = Math.max(-1, Math.min(1, monoData[i]));
|
|
if (bitDepth === 8) {
|
|
view.setUint8(offset, Math.floor((sample + 1.0) * 127.5));
|
|
} else if (bitDepth === 16) {
|
|
view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true);
|
|
} else if (bitDepth === 24) {
|
|
const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF);
|
|
view.setUint8(offset, val24 & 0xFF);
|
|
view.setUint8(offset + 1, (val24 >> 8) & 0xFF);
|
|
view.setUint8(offset + 2, (val24 >> 16) & 0xFF);
|
|
}
|
|
offset += bytesPerSample;
|
|
}
|
|
|
|
const blob = new Blob([view], { type: 'audio/wav' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast("Xuất bản âm thanh hoàn tất!", "success");
|
|
} catch (err) {
|
|
showToast("Lỗi xuất âm thanh: " + err.message, "error");
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
// ── AI Analysis (server-side with fallback) ──
|
|
const triggerAIAnalysis = async () => {
|
|
setAnalysisState({ status: 'Connecting to AI Engine...', data: null, isRunning: true });
|
|
|
|
// Check if we have a file on the server to analyze
|
|
const activeTrack = tracks.find(t => t.id === selectedTrackId);
|
|
const serverFileId = serverFileIdMap[selectedTrackId];
|
|
|
|
if (serverFileId && serverStatus === 'connected') {
|
|
try {
|
|
const resp = await fetch(`${API_AUDIO}/analyze-ai`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
file_id: serverFileId,
|
|
api_base_url: aiConfig.baseUrl !== API_BASE_URL ? aiConfig.baseUrl : null,
|
|
model: aiConfig.model,
|
|
}),
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
showToast("Đang phân tích cấu trúc trên máy chủ...", "info");
|
|
|
|
try {
|
|
const result = await pollTaskResult(data.task_id, 15);
|
|
if (result.bpm) {
|
|
setAnalysisState({
|
|
status: 'Hoàn thành phân tích (Server)',
|
|
data: result,
|
|
isRunning: false,
|
|
});
|
|
showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected`, 'success');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Server AI poll failed, using fallback:', e);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('Server AI analysis failed, using fallback:', err);
|
|
}
|
|
}
|
|
|
|
// Fallback: client-side analysis with simulated BPM
|
|
setAnalysisState({ status: 'Processing structural detection...', data: null, isRunning: true });
|
|
|
|
setTimeout(() => {
|
|
let bpm = 120;
|
|
// Try to detect BPM from buffer
|
|
if (activeTrack && activeTrack.buffer) {
|
|
const data = activeTrack.buffer.getChannelData(0);
|
|
const sr = activeTrack.buffer.sampleRate;
|
|
// Simple autocorrelation for BPM estimation
|
|
const windowSize = Math.min(sr * 3, data.length);
|
|
if (windowSize > sr) {
|
|
let maxCorr = 0;
|
|
let bestLag = Math.floor(sr * 0.25); // ~240 BPM max
|
|
for (let lag = Math.floor(sr * 0.25); lag < Math.floor(sr * 2); lag++) {
|
|
let corr = 0;
|
|
const n = Math.floor(windowSize / 2);
|
|
for (let i = 0; i < n; i++) {
|
|
corr += data[i] * data[i + lag];
|
|
}
|
|
corr /= n;
|
|
if (corr > maxCorr) {
|
|
maxCorr = corr;
|
|
bestLag = lag;
|
|
}
|
|
}
|
|
if (bestLag > 0) {
|
|
bpm = Math.round(60 * sr / bestLag);
|
|
bpm = Math.max(60, Math.min(200, bpm));
|
|
}
|
|
}
|
|
}
|
|
|
|
setAnalysisState({
|
|
status: 'Hoàn thành phân tích (Client)',
|
|
data: { bpm, bars: Math.max(4, Math.round(bpm / 30)), timeSig: '4/4', detectedKey: 'Am' },
|
|
isRunning: false,
|
|
});
|
|
showToast(`Phân tích: ${bpm} BPM`, 'success');
|
|
}, 1500);
|
|
};
|
|
|
|
// ── Mark Selection ──
|
|
const handleMarkSelection = () => {
|
|
if (selLeft === null || selRight === null || selectionStats.length === 0) {
|
|
showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.", "warning");
|
|
return;
|
|
}
|
|
|
|
const targetTrack = tracks.find(t => t.id === selectedTrackId);
|
|
if (!targetTrack || !targetTrack.buffer) {
|
|
showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.", "warning");
|
|
return;
|
|
}
|
|
|
|
// For local selection, force mark on the local-selected track
|
|
const markTrackId = selectionMode === 'local' && localSelectionTrackId
|
|
? localSelectionTrackId : selectedTrackId;
|
|
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id !== markTrackId) return t;
|
|
const snapStart = findZeroCrossing(t.buffer, selLeft);
|
|
const snapEnd = findZeroCrossing(t.buffer, selRight);
|
|
|
|
const newMarkers = [
|
|
...t.markers,
|
|
{ id: Date.now() + '_s', time: snapStart },
|
|
{ id: Date.now() + '_e', time: snapEnd }
|
|
].sort((a,b) => a.time - b.time);
|
|
|
|
return { ...t, markers: newMarkers };
|
|
}));
|
|
|
|
showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`, "success");
|
|
};
|
|
|
|
// ── AI Cut to New Track (server-side with client fallback) ──
|
|
const handleAICutToNewTrack = () => {
|
|
const activeTrack = tracks.find(t => t.id === selectedTrackId);
|
|
if (!activeTrack || !activeTrack.buffer) {
|
|
showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.", "warning");
|
|
return;
|
|
}
|
|
if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) {
|
|
showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.", "warning");
|
|
return;
|
|
}
|
|
|
|
setAnalysisState({ status: 'AI đang phân tích điểm Zero-crossing...', data: null, isRunning: true });
|
|
showToast("AI đang dò tìm Zero-crossing...", "info");
|
|
|
|
const buffer = activeTrack.buffer;
|
|
const sampleRate = buffer.sampleRate;
|
|
const channelData = buffer.getChannelData(0);
|
|
|
|
const snapStart = findZeroCrossing(buffer, selectionStats.start);
|
|
const snapEnd = findZeroCrossing(buffer, selectionStats.end);
|
|
|
|
const startSample = Math.max(0, Math.min(channelData.length - 1, Math.floor(snapStart * sampleRate)));
|
|
const endSample = Math.max(0, Math.min(channelData.length, Math.floor(snapEnd * sampleRate)));
|
|
const sliceLength = endSample - startSample;
|
|
|
|
if (sliceLength <= 0) {
|
|
showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.", "error");
|
|
setAnalysisState({ status: 'Thất bại', data: null, isRunning: false });
|
|
return;
|
|
}
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
const context = getAudioContext();
|
|
const slicedBuffer = context.createBuffer(1, sliceLength, sampleRate);
|
|
const slicedData = slicedBuffer.getChannelData(0);
|
|
slicedData.set(channelData.subarray(startSample, endSample));
|
|
|
|
const newId = 'track_ai_cut_' + Date.now();
|
|
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
|
const selectColor = colors[tracks.length % colors.length];
|
|
|
|
setTracks(prev => [
|
|
...prev,
|
|
{
|
|
id: newId,
|
|
name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s.wav`,
|
|
buffer: slicedBuffer,
|
|
volume: 0.8,
|
|
muted: false,
|
|
solo: false,
|
|
color: selectColor,
|
|
markers: [
|
|
{ id: Date.now() + '_s', time: 0 },
|
|
{ id: Date.now() + '_e', time: snapEnd - snapStart }
|
|
],
|
|
serverFileId: null,
|
|
}
|
|
]);
|
|
|
|
setSelectedTrackId(newId);
|
|
setAnalysisState({
|
|
status: 'Phân đoạn AI hoàn tất!',
|
|
data: { bpm: 120, bars: 4, timeSig: '4/4', detectedKey: 'Am' },
|
|
isRunning: false
|
|
});
|
|
showToast(`AI đã cắt & gộp thành công vào Track mới (Zero-Crossing aligned)`, "success");
|
|
setTimeout(() => lucide.createIcons(), 200);
|
|
} catch (err) {
|
|
showToast("Lỗi giải mã dải cắt: " + err.message, "error");
|
|
setAnalysisState({ status: 'Lỗi biên tập', data: null, isRunning: false });
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
// ── Split Track at Playhead ──
|
|
const handleSplitTrack = (trackId) => {
|
|
const track = tracks.find(t => t.id === trackId);
|
|
if (!track || !track.buffer) return;
|
|
|
|
const cutTime = findZeroCrossing(track.buffer, currentTime);
|
|
const sr = track.buffer.sampleRate;
|
|
const cutSample = Math.floor(cutTime * sr);
|
|
const originalData = track.buffer.getChannelData(0);
|
|
|
|
if (cutSample <= 0 || cutSample >= originalData.length) {
|
|
showToast("Vị trí Playhead nằm ngoài dải biên tập.", "warning");
|
|
return;
|
|
}
|
|
|
|
const ctx = getAudioContext();
|
|
const b1 = ctx.createBuffer(1, cutSample, sr);
|
|
b1.copyToChannel(originalData.subarray(0, cutSample), 0);
|
|
|
|
const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr);
|
|
b2.copyToChannel(originalData.subarray(cutSample), 0);
|
|
|
|
setTracks(prev => {
|
|
const idx = prev.findIndex(t => t.id === trackId);
|
|
const updated = [...prev];
|
|
updated[idx] = { ...track, name: `${track.name} (Part 1)`, buffer: b1 };
|
|
|
|
const newTrack = {
|
|
...track,
|
|
id: 'track_split_' + Date.now(),
|
|
name: `${track.name} (Part 2)`,
|
|
buffer: b2,
|
|
markers: [],
|
|
serverFileId: null,
|
|
};
|
|
updated.splice(idx + 1, 0, newTrack);
|
|
return updated;
|
|
});
|
|
showToast("Đã chia nhỏ track tại vị trí Playhead.", "info");
|
|
};
|
|
|
|
// ── Save AI config to localStorage ──
|
|
useEffect(() => {
|
|
localStorage.setItem('ai_base_url', aiConfig.baseUrl);
|
|
localStorage.setItem('ai_api_key', aiConfig.apiKey);
|
|
localStorage.setItem('ai_model', aiConfig.model);
|
|
}, [aiConfig]);
|
|
|
|
return (
|
|
<div className="h-full w-full flex flex-col bg-[#1e1e1e]">
|
|
{/* ── Header ── */}
|
|
{/* ── 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>
|
|
<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 && (
|
|
<div className="bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all">
|
|
<div className="text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1">
|
|
<i data-lucide="cpu" className="w-4 h-4"></i> Cấu hình cổng kết nối API
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[10px] text-zinc-500 font-bold uppercase">Endpoint Base URL</span>
|
|
<input
|
|
type="text"
|
|
value={aiConfig.baseUrl}
|
|
onChange={(e) => setAiConfig(prev => ({ ...prev, baseUrl: e.target.value }))}
|
|
className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]"
|
|
placeholder="https://api.openai.com/v1"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[10px] text-zinc-500 font-bold uppercase">API Token Key</span>
|
|
<input
|
|
type="password"
|
|
value={aiConfig.apiKey}
|
|
onChange={(e) => setAiConfig(prev => ({ ...prev, apiKey: e.target.value }))}
|
|
className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]"
|
|
placeholder="sk-..."
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[10px] text-zinc-500 font-bold uppercase">Model Name</span>
|
|
<input
|
|
type="text"
|
|
value={aiConfig.model}
|
|
onChange={(e) => setAiConfig(prev => ({ ...prev, model: e.target.value }))}
|
|
className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]"
|
|
placeholder="gpt-4o-mini"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Workspace ── */}
|
|
<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">
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase">Danh Sách Kênh</span>
|
|
<span className="text-[9px] bg-cyan-950 text-cyan-400 px-1 rounded font-bold uppercase">Chọn Click</span>
|
|
</div>
|
|
|
|
<div className="flex flex-col divide-y divide-[#141414]">
|
|
{tracks.map((track, idx) => {
|
|
const isSelected = selectedTrackId === track.id;
|
|
return (
|
|
<div
|
|
key={track.id}
|
|
onClick={() => setSelectedTrackId(track.id)}
|
|
className={`h-[96px] p-2.5 flex flex-col justify-between transition-all relative cursor-pointer ${
|
|
isSelected
|
|
? 'bg-zinc-800/80 border-l-4 border-cyan-500 pl-1.5 ring-1 ring-cyan-500/30'
|
|
: 'hover:bg-zinc-800/30'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span>
|
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: track.color }} />
|
|
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[120px]" title={track.name}>
|
|
{track.name}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleTrackMute(track.id); }}
|
|
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
|
track.muted
|
|
? 'bg-red-950 text-red-400 border-red-700'
|
|
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
|
}`}
|
|
>M</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }}
|
|
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
|
soloedTrackId === track.id || track.solo
|
|
? 'bg-amber-950 text-amber-400 border-amber-600'
|
|
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
|
}`}
|
|
title="Solo nghe thử"
|
|
>S</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 text-[9px] text-zinc-400" onClick={e => e.stopPropagation()}>
|
|
<span className="font-semibold uppercase text-[8px] text-zinc-500">Mô phỏng:</span>
|
|
<button
|
|
onClick={() => generateSynthToTrack(track.id, 'kick')}
|
|
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
|
>Kick Drum</button>
|
|
<button
|
|
onClick={() => generateSynthToTrack(track.id, 'synth')}
|
|
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
|
>Arpeggiator</button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center gap-1.5">
|
|
<VolumeKnob value={track.volume} onChange={(v) => updateTrackVolume(track.id, v)} />
|
|
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(track.volume * 100)}%</span>
|
|
</div>
|
|
|
|
<div>
|
|
<input
|
|
type="file"
|
|
id={`upload-${track.id}`}
|
|
accept="audio/*"
|
|
className="hidden"
|
|
onChange={(e) => loadFileOnTrack(track.id, e.target.files[0])}
|
|
/>
|
|
<label
|
|
htmlFor={`upload-${track.id}`}
|
|
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer transition border border-zinc-700"
|
|
>
|
|
<i data-lucide="upload" className="w-3 h-3"></i> Tải file
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline Right Canvas */}
|
|
<div
|
|
ref={timelineWrapperRef}
|
|
className="flex-1 overflow-x-auto relative bg-[#111111]"
|
|
>
|
|
<div style={{ width: `${timelineWidth}px` }} className="relative flex flex-col h-full">
|
|
|
|
{/* Ruler - LOOP_MAKER.md VÙNG A: Global Selection hitbox */}
|
|
<div ref={rulerRef}
|
|
className="h-8 border-b border-zinc-900 bg-[#242424] sticky top-0 z-30 flex items-center select-none shrink-0 cursor-ew-resize"
|
|
onMouseDown={handleRulerMouseDown}
|
|
>
|
|
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => {
|
|
const sec = i;
|
|
const x = sec * zoom;
|
|
return (
|
|
<div key={i} className="absolute h-full border-l border-zinc-800 pl-1 pt-1 text-[9px] font-mono text-zinc-500 pointer-events-none" style={{ left: `${x}px` }}>
|
|
{formatTime(sec)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Stacked Lanes */}
|
|
<div className="flex-1 flex flex-col relative divide-y divide-[#141414]">
|
|
{tracks.map((track) => {
|
|
const isSelected = selectedTrackId === track.id;
|
|
return (
|
|
<div key={track.id} className="h-[96px] w-full relative flex-shrink-0"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
if (e.dataTransfer.files[0]) {
|
|
loadFileOnTrack(track.id, e.dataTransfer.files[0]);
|
|
}
|
|
}}
|
|
>
|
|
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
|
|
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}
|
|
localSelRight={localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null} />
|
|
|
|
{track.buffer && (
|
|
<div className="absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100 transition">
|
|
<button onClick={() => handleSplitTrack(track.id)}
|
|
className="px-1.5 py-0.5 bg-zinc-900/90 text-zinc-300 rounded text-[9px] flex items-center gap-1 border border-zinc-700/50"
|
|
title="Cắt đoạn tại Playhead">
|
|
<i data-lucide="scissors" className="w-2.5 h-2.5 text-cyan-400"></i> Cắt
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* 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}
|
|
title="Kéo để di chuyển vùng chọn"
|
|
>
|
|
<div className="absolute -left-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
|
onMouseDown={(e) => handleHandleDragStart(e, 'left')}
|
|
title="Kéo giãn mốc bắt đầu">
|
|
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
|
</div>
|
|
<div className="absolute -right-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
|
onMouseDown={(e) => handleHandleDragStart(e, 'right')}
|
|
title="Kéo giãn mốc kết thúc">
|
|
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Playhead */}
|
|
<div className="absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none"
|
|
style={{ left: `${playheadLeftPos}px` }}>
|
|
<div className="w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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" 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">
|
|
<div>
|
|
<h3 className="font-bold text-zinc-200 flex items-center gap-1.5 mb-1.5">
|
|
<i data-lucide="save" className="w-4 h-4 text-cyan-400"></i> Trộn & Xuất bản
|
|
</h3>
|
|
<div className="grid grid-cols-3 gap-2 mt-1">
|
|
<div>
|
|
<label className="block text-[8px] text-zinc-500 font-bold uppercase mb-0.5">Sample Rate</label>
|
|
<select value={exportSettings.sampleRate}
|
|
onChange={(e) => setExportSettings(prev => ({ ...prev, sampleRate: e.target.value }))}
|
|
className="w-full bg-[#141414] border border-zinc-800 rounded px-1.5 py-1 text-[10px] text-zinc-300 focus:outline-none">
|
|
<option value="22500">22.5 kHz</option>
|
|
<option value="44100">44.1 kHz</option>
|
|
<option value="48000">48.0 kHz</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[8px] text-zinc-500 font-bold uppercase mb-0.5">Bit Depth</label>
|
|
<select value={exportSettings.bitDepth}
|
|
onChange={(e) => setExportSettings(prev => ({ ...prev, bitDepth: e.target.value }))}
|
|
className="w-full bg-[#141414] border border-zinc-800 rounded px-1.5 py-1 text-[10px] text-zinc-300 focus:outline-none">
|
|
<option value="8">8-bit</option>
|
|
<option value="16">16-bit</option>
|
|
<option value="24">24-bit</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-[8px] text-zinc-500 font-bold uppercase mb-0.5">Mã Hóa</label>
|
|
<select value={exportSettings.format}
|
|
onChange={(e) => setExportSettings(prev => ({ ...prev, format: e.target.value }))}
|
|
className="w-full bg-[#141414] border border-zinc-800 rounded px-1.5 py-1 text-[10px] text-zinc-300 focus:outline-none">
|
|
<option value="wav">WAV (.wav)</option>
|
|
<option value="mp3">MP3 (.mp3)</option>
|
|
<option value="ogg">OGG (.ogg)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button onClick={triggerWavExport} disabled={isExporting}
|
|
className="w-full py-1.5 mt-2 bg-cyan-700 hover:bg-cyan-600 active:bg-cyan-800 text-zinc-100 font-bold rounded flex items-center justify-center gap-1.5 transition text-xs shadow-md">
|
|
<i data-lucide="download-cloud" className="w-4 h-4"></i>
|
|
{isExporting ? 'Đang xuất...' : 'Xuất bản bản phối'}
|
|
</button>
|
|
</section>
|
|
|
|
{/* Transport Section */}
|
|
<section className="md:col-span-4 bg-[#242424] border border-zinc-800 rounded p-3 flex flex-col justify-between items-center text-center">
|
|
<div className="flex w-full items-center justify-between">
|
|
<span className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Transport Controls</span>
|
|
<div className="text-sm font-bold font-mono text-zinc-100">{formatTime(currentTime)}</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2 w-full mt-1 bg-[#141414] p-1.5 rounded border border-zinc-800">
|
|
<div>
|
|
<span className="block text-[8px] text-zinc-500 font-bold uppercase mb-0.5">Vùng Chọn Đầu</span>
|
|
<input type="number" step="0.01" value={selectionStats.start}
|
|
onChange={(e) => handleSelectionInputChange('start', e.target.value)}
|
|
className="w-full bg-[#242424] text-amber-400 text-center font-mono text-[11px] rounded py-0.5 border border-zinc-700 focus:outline-none" />
|
|
</div>
|
|
<div>
|
|
<span className="block text-[8px] text-zinc-500 font-bold uppercase mb-0.5">Vùng Chọn Cuối</span>
|
|
<input type="number" step="0.01" value={selectionStats.end}
|
|
onChange={(e) => handleSelectionInputChange('end', e.target.value)}
|
|
className="w-full bg-[#242424] text-amber-400 text-center font-mono text-[11px] rounded py-0.5 border border-zinc-700 focus:outline-none" />
|
|
</div>
|
|
<div className="flex flex-col justify-center">
|
|
<span className="text-[8px] text-zinc-500 font-bold uppercase">Thời Lượng</span>
|
|
<span className="text-zinc-200 font-mono text-[11px] font-semibold mt-0.5">{selectionStats.length}s</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 justify-center w-full mt-1.5">
|
|
<button onClick={() => setCurrentTime(0)}
|
|
className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
|
|
title="Quay lại đầu"><i data-lucide="skip-back" className="w-4 h-4"></i></button>
|
|
<button onClick={() => { if (selLeft !== null) setCurrentTime(selLeft); }}
|
|
className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
|
|
title="Đầu vùng chọn"><i data-lucide="step-back" className="w-4 h-4"></i></button>
|
|
<button onClick={handlePlayPause}
|
|
className={`w-8 h-8 flex items-center justify-center rounded border transition ${
|
|
isPlaying
|
|
? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500'
|
|
: 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'
|
|
}`}
|
|
title={isPlaying ? "Tạm dừng" : "Play"}>
|
|
<i data-lucide="play" className="w-4 h-4 fill-current"></i>
|
|
</button>
|
|
<button onClick={handlePause}
|
|
className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
|
|
title="Pause"><i data-lucide="pause" className="w-4 h-4"></i></button>
|
|
<button onClick={handleStop}
|
|
className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
|
|
title="Stop"><i data-lucide="square" className="w-4 h-4 fill-current"></i></button>
|
|
<button onClick={() => { if (selRight !== null) setCurrentTime(selRight); }}
|
|
className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
|
|
title="Cuối vùng chọn"><i data-lucide="step-forward" className="w-4 h-4"></i></button>
|
|
<button onClick={() => setCurrentTime(maxDuration)}
|
|
className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
|
|
title="Đến cuối"><i data-lucide="skip-forward" className="w-4 h-4"></i></button>
|
|
<button onClick={() => setIsLoopingSelection(prev => !prev)}
|
|
className={`w-8 h-8 flex items-center justify-center rounded border transition ${
|
|
isLoopingSelection
|
|
? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500'
|
|
: 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'
|
|
}`} title="Bật/Tắt Lặp vùng chọn">
|
|
<i data-lucide="repeat" className="w-4 h-4"></i>
|
|
</button>
|
|
</div>
|
|
</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> 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>
|
|
{analysisState.data && (
|
|
<div className="text-emerald-500 font-semibold mt-0.5">
|
|
BPM: {analysisState.data.bpm} | Bars: {analysisState.data.bars}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 mt-1.5">
|
|
<button onClick={handleMarkSelection}
|
|
className="flex-1 py-1 px-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded flex items-center justify-center gap-1 border border-purple-700 transition"
|
|
title="Tạo Marker">
|
|
<i data-lucide="map-pin" className="w-3.5 h-3.5"></i> Mark
|
|
</button>
|
|
<button onClick={handleAICutToNewTrack} disabled={analysisState.isRunning}
|
|
className="flex-[2] py-1 px-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded flex items-center justify-center gap-1 transition shadow-md"
|
|
title="AI Zero-crossing Cut">
|
|
<i data-lucide="scissors" className="w-3.5 h-3.5"></i> AI Cut
|
|
</button>
|
|
<button onClick={triggerAIAnalysis} disabled={analysisState.isRunning}
|
|
className="p-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700"
|
|
title="AI Analysis">
|
|
<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" 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>
|
|
{selectionMode === 'local' && <span className="text-amber-400 font-semibold uppercase text-[9px]">Local Sel</span>}
|
|
{selectionMode === 'global' && <span className="text-purple-400 font-semibold uppercase text-[9px]">Global Sel</span>}
|
|
{soloedTrackId && <span className="text-amber-500 font-semibold">Solo: ID {soloedTrackId}</span>}
|
|
{isLoopingSelection && selectionMode === 'local' && <span className="text-emerald-400 font-semibold uppercase text-[9px]">Solo Loop</span>}
|
|
{isLoopingSelection && selectionMode !== 'local' && <span className="text-cyan-400 font-semibold uppercase text-[9px]">Master Loop</span>}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex items-center gap-1">
|
|
<i data-lucide="info" className="w-3 h-3 text-zinc-600"></i> Scroll: Zoom
|
|
</span>
|
|
<span>|</span>
|
|
<span className="flex items-center gap-1">
|
|
<i data-lucide="keyboard" className="w-3 h-3 text-zinc-600"></i> Ctrl+Scroll: Playhead
|
|
</span>
|
|
</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">
|
|
<i data-lucide="bell" className={`w-4 h-4 ${
|
|
toastMessage.type === 'success' ? 'text-emerald-400' :
|
|
toastMessage.type === 'error' ? 'text-rose-400' :
|
|
toastMessage.type === 'warning' ? 'text-amber-400' : 'text-cyan-400'
|
|
}`}></i>
|
|
{toastMessage.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
setTimeout(() => lucide.createIcons(), 300);
|
|
</script>
|
|
</body>
|
|
</html> |