feat: cài đặt giao diện và tích hợp AI Analysis engine

This commit is contained in:
2026-07-18 15:30:33 +07:00
parent a34b01a035
commit 1968867d46
3 changed files with 541 additions and 45 deletions
+236 -45
View File
@@ -146,7 +146,12 @@
onPlayheadSet,
isSelected,
onSelectTrack,
markers
markers,
selectionMode,
localSelectionTrackId,
localSelLeft,
localSelRight,
onTrackLaneMouseDown,
}) => {
const canvasRef = useRef(null);
@@ -234,26 +239,36 @@
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', width / 2, height / 2);
}
// Selection highlight
// (handled by overlay in parent)
// 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]);
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight]);
return (
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseDown={(e) => {
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (canvasRef.current.parentElement?.parentElement?.scrollLeft || 0);
const time = x / zoom;
if (e.shiftKey) {
onSelectRange(0, time, false);
} else {
onSelectRange(time, time, true);
}
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();
}}
/>
);
@@ -270,6 +285,10 @@
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);
@@ -294,11 +313,14 @@
const [serverStatus, setServerStatus] = useState('checking...');
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);
// ── Server Health Check ──
useEffect(() => {
@@ -323,14 +345,20 @@
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]);
}, [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]);
}, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]);
// ── Toast helper ──
const showToast = (text, type = 'info') => {
@@ -432,14 +460,24 @@
const elapsed = context.currentTime - startAudioTimeRef.current;
const updatedTime = startOffsetTimeRef.current + elapsed;
// Selection Loop
// Selection Loop - LOOP_MAKER.md spec: local vs global behavior
if (isLoopingSelection && selLeft !== null && selRight !== null) {
if (selRight > selLeft && updatedTime >= selRight) {
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
startAudioTimeRef.current = context.currentTime;
startTrackPlayback(selLeft);
setCurrentTime(selLeft);
if (selectionMode === 'local') {
// Local Solo Loop: only restart the selected track
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
startAudioTimeRef.current = context.currentTime;
startLocalTrackPlayback(localSelectionTrackId, selLeft);
setCurrentTime(selLeft);
} else {
// Global Master Loop: restart all tracks
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
startAudioTimeRef.current = context.currentTime;
startTrackPlayback(selLeft);
setCurrentTime(selLeft);
}
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return;
}
@@ -462,7 +500,7 @@
cancelAnimationFrame(animationFrameIdRef.current);
}
return () => cancelAnimationFrame(animationFrameIdRef.current);
}, [isPlaying, isLoopingSelection, selLeft, selRight]);
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId]);
// ── Playback ──
const startTrackPlayback = (offsetTime) => {
@@ -494,6 +532,27 @@
});
};
// 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) {
@@ -524,6 +583,103 @@
};
// ── 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));
@@ -539,45 +695,59 @@
const handleSelectionInputChange = (field, val) => {
const numericVal = Math.max(0, parseFloat(val) || 0);
if (field === 'start') {
setSelectionStart(numericVal);
if (selectionMode === 'local') {
// Editing local selection directly
if (field === 'start') {
setLocalSelectionStart(numericVal);
} else {
setLocalSelectionEnd(numericVal);
}
} else {
setSelectionEnd(numericVal);
if (field === 'start') {
setSelectionStart(numericVal);
} else {
setSelectionEnd(numericVal);
}
}
};
const selectionStats = useMemo(() => {
if (selectionStart === null || selectionEnd === null) {
if (selLeft === null || selRight === null) {
return { start: 0, end: 0, length: 0 };
}
const s = Math.min(selectionStart, selectionEnd);
const e = Math.max(selectionStart, selectionEnd);
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))
};
}, [selectionStart, selectionEnd]);
}, [selLeft, selRight]);
// ── Handle Drag (selection resize) ──
const handleHandleDragStart = (e, side) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const initialLeft = Math.min(selectionStart, selectionEnd);
const initialRight = Math.max(selectionStart, selectionEnd);
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));
setSelectionStart(newLeft);
setSelectionEnd(initialRight);
setStart(newLeft);
setEnd(initialRight);
} else {
const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec));
setSelectionStart(initialLeft);
setSelectionEnd(newRight);
setStart(initialLeft);
setEnd(newRight);
}
};
@@ -594,9 +764,14 @@
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const initialLeft = Math.min(selectionStart, selectionEnd);
const initialRight = Math.max(selectionStart, selectionEnd);
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;
@@ -613,8 +788,8 @@
newLeft = maxDuration - widthSec;
}
setSelectionStart(newLeft);
setSelectionEnd(newRight);
setStart(newLeft);
setEnd(newRight);
};
const handleMouseUp = () => {
@@ -986,7 +1161,7 @@
// ── Mark Selection ──
const handleMarkSelection = () => {
if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) {
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;
}
@@ -997,10 +1172,14 @@
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 !== selectedTrackId) return t;
const snapStart = findZeroCrossing(t.buffer, selectionStats.start);
const snapEnd = findZeroCrossing(t.buffer, selectionStats.end);
if (t.id !== markTrackId) return t;
const snapStart = findZeroCrossing(t.buffer, selLeft);
const snapEnd = findZeroCrossing(t.buffer, selRight);
const newMarkers = [
...t.markers,
@@ -1319,8 +1498,11 @@
>
<div style={{ width: `${timelineWidth}px` }} className="relative flex flex-col h-full">
{/* Ruler */}
<div className="h-8 border-b border-zinc-900 bg-[#242424] sticky top-0 z-30 flex items-center select-none shrink-0">
{/* 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;
@@ -1348,7 +1530,12 @@
>
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers} />
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
onTrackLaneMouseDown={handleTrackLaneMouseDown}
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">
@@ -1548,7 +1735,11 @@
<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">