feat: cài đặt giao diện và tích hợp AI Analysis engine
This commit is contained in:
+236
-45
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user