fix: sửa lỗi tách bảng điều khiển TCP và timeline của track

This commit is contained in:
2026-07-18 21:17:08 +07:00
parent d14a11342a
commit f52e9b7bef
3 changed files with 383 additions and 230 deletions
+142
View File
@@ -0,0 +1,142 @@
# Bug Fix Specification: Resolving Critical Row Desynchronization
This document analyzes the root cause and provides a permanent structural solution to eliminate the vertical row desynchronization and internal horizontal scrolling artifacts occurring between the left Track Control Panel (TCP) and the right waveform lanes, based on the real-world visual analysis
---
## 1. Visual Symptom Analysis
The layout engine is suffering from two critical alignment failures indicated by the red arrows:
```text
[ LEFT COLUMN - TCP PANEL ] [ RIGHT COLUMN - TIMELINE GRID ]
┌──────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ ... Track 05, 06 (Aligned) │ ══════════ │ Waveform 05, 06 (Aligned) │
├──────────────────────────────┤ ├──────────────────────────────────────────────┤
│ 07 Track 3 (Channel Header) │ [MISALIGNED]│ [EMPTY BLACK DEAD SPACE] (Lower red arrow) │
│ [Junk horizontal scrollbar] │ ◄────────── │ ◄── Caused by Waveform 07 dropping height to 0│
│ (Upper red arrow) │ ├──────────────────────────────────────────────┤
├──────────────────────────────┤ │ Waveform 07 (Pushed down to Track 08's row) │
│ 08 Track 3 │ ══════════ │ ... │
└──────────────────────────────┘ └──────────────────────────────────────────────┘
```
### 1.1. Defect Index 1: Spurious Internal Horizontal Scrollbar (Upper Red Arrow)
* **Symptom:** A small gray horizontal scrollbar emerges directly beneath Track 07 within the left TCP column.
* **Root Cause:** The container wrapper for the left TCP column enforces a rigid bounding layout (`fixed width` or missing an explicit `overflow-x: hidden` safety attribute). When inner structural components (such as long text labels, Mute/Solo clusters, or upload file actions) expand horizontally, the browser generates a local scrollbar. This automatically inflates the effective physical height of the left Track 07 by roughly $12\text{ px} \rightarrow 16\text{ px}$.
### 1.2. Defect Index 2: Vertical Row Desynchronization & Dead Black Space (Lower Red Arrow)
* **Symptom:** On the right column (Timeline), a massive horizontal empty black gap disrupts the grid layout where Waveform 07 ought to sit. Consequently, all matching waveforms for Track 07 and Track 08 are offset downward, falling entirely out of phase with their corresponding control headers on the left.
* **Root Cause:** The system evaluates the target height ($H$) of the left TCP container independently from the right Waveform Lane. When the left Track 07 column expands due to the rendering of the junk scrollbar, the right canvas lane does not dynamically adapt. This triggers a cumulative pixel error along the vertical axis ($Y$), producing progressive, severe desynchronization downstream (the lower the tracks sit, the worse the alignment drifts).
---
## 2. Structural Correction Blueprint
To prevent this layout defect from recurring—especially when porting the interface to desktop Python using PyQt/PySide—the system must completely decouple from independent height calculations and embrace a **Unified Row Layout** model.
### 2.1. Standardized HTML / Tailwind CSS Architecture Blueprint
Instead of splitting the page tree layout into two isolated columns (`Col1: [TCP1, TCP2, TCP3]` and `Col2: [Wave1, Wave2, Wave3]`), the application must encapsulate each matching TCP and Waveform pair within a shared, unified row wrapper:
```html
<!-- Wrap the entire track stack inside a single vertical scroll container -->
<div class="flex-1 overflow-y-auto bg-[#111111]">
<!-- UNIFIED TRACK ROW (Enforces strict shared-row geometry) -->
<div class="flex h-[96px] w-full min-w-max border-b border-[#141414]">
<!-- Left Side: TCP (Fixed width; absolute containment of horizontal overflows) -->
<div class="w-[300px] shrink-0 bg-[#262626] p-2.5 overflow-hidden flex flex-col justify-between">
<!-- TCP Control Content Elements Go Here -->
</div>
<!-- Right Side: Waveform Lane (Flexibly fills remaining browser canvas viewport) -->
<div class="flex-1 relative overflow-hidden">
<!-- Waveform Canvas Engine -->
</div>
</div>
<!-- Add additional track rows duplicating the exact structural envelope above... -->
</div>
```
### Architectural Advantages:
* Because both the control deck and the waveform graphic share an identical row container wrapper (`flex row` or `grid row`), any arbitrary height fluctuation on the TCP side (due to text zoom-in behaviors or overflow glitches) will instantly force the right waveform canvas view to mirror the $100\%$ row scale change.
* Only one master vertical scrollbar exists on the outer perimeter window to slide all rows simultaneously.
---
## 3. Prevention Guidelines for Python Porting (PyQt6 / PySide6)
If you attempt to design this DAW interface inside a containerized Python Docker application by leveraging two separate `QScrollArea` nodes for the TCP track column and the timeline canvas, you will inevitably trigger this row alignment defect due to timing delays or scroll tracking errors (`scrollEvent` mismatch).
### 3.1. Secure Layout Architecture Using Python QWidget
Implement a nested widget strategy to securely bind the horizontal axes together at all times:
```python
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea
from PyQt6.QtCore import Qt
class ProDAWArrangeWindow(QWidget):
def __init__(self):
super().__init__()
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# 1. Instantiate a single, unified QScrollArea for the absolute Workspace
self.workspace_scroll = QScrollArea()
self.workspace_scroll.setWidgetResizable(True)
self.workspace_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.workspace_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 2. Outer container hosting the multi-channel rows
self.container_widget = QWidget()
self.container_layout = QVBoxLayout(self.container_widget)
self.container_layout.setContentsMargins(0, 0, 0, 0)
self.container_layout.setSpacing(0)
self.container_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.workspace_scroll.setWidget(self.container_widget)
self.main_layout.addWidget(self.workspace_scroll)
def add_track(self, track_id: str):
"""
Appends a unified track row utilizing QHBoxLayout with a rigid physical height constraint.
"""
track_row = QWidget()
track_row.setFixedHeight(96) # Lock physical pixel height constraints for the row
row_layout = QHBoxLayout(track_row)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(0)
# Left Panel: Track Control Panel (Enforces a strict rigid width constraint)
tcp_widget = QWidget()
tcp_widget.setFixedWidth(300)
# tcp_widget.setup_ui(...)
# Right Panel: Waveform Canvas Viewport
waveform_widget = QWidget()
# waveform_widget.setup_canvas(...)
# Combine both widgets into the layout block to guarantee row lock
row_layout.addWidget(tcp_widget)
row_layout.addWidget(waveform_widget)
self.container_layout.addWidget(track_row)
```
### 3.2. Concrete Advantages for Python Docker Environments
* **Zero Alignment Variance:** Row alignment is entirely guaranteed at the OS-level layout engine, bypassing desynchronization issues caused by asynchronous rendering cycles or UI latency.
* **Streamlined UI Pipelines:** The environment tracking mechanisms hook into a single scrollbar, reducing memory usage and optimizing the drawing threads for the Docker X11 Server or WebRTC stream pipelines when projecting graphics down to the client.
View File
+241 -230
View File
@@ -1671,32 +1671,32 @@
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]);
// 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 - 300) / 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 draggedClipRef = useRef(null);
draggedClipRef.current = draggedClip;
@@ -1742,7 +1742,7 @@
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const time = (mouseX - 300) / zoom;
const rawStart = Math.max(0, time - drag.clickOffset);
const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current);
@@ -2766,79 +2766,118 @@
)}
{/* ── 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-2 justify-between sticky top-0 z-30 select-none">
<span className="text-[10px] font-bold text-zinc-500 uppercase shrink-0 mr-2">Kênh</span>
{/* Timeline Toolbar */}
<div className="flex items-center gap-0.5 bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 shadow-sm">
<button
onClick={() => { setActiveTool('select'); showToast('Công cụ chọn (Select Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'select' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-800/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Select Tool: Chọn khoảng, Đặt playhead (Giữ Alt kéo để di chuyển nhanh clip)"
>
<i data-lucide="mouse-pointer" className="w-3 h-3"></i>
</button>
<button
onClick={() => { setActiveTool('grab'); showToast('Công cụ di chuyển (Hand Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'grab' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Grab Tool: Click kéo trực tiếp để di chuyển clip"
>
<i data-lucide="hand" className="w-3 h-3"></i>
</button>
<button
onClick={() => { setActiveTool('razor'); showToast('Công cụ chia đoạn (Razor Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'razor' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Razor Tool: Click trên clip để chia nhỏ tại điểm click"
>
{/* Biểu tượng lưỡi dao cạo - dùng custom SVG hoặc scissors */}
<svg className="w-3 h-3 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
<path d="M4 9h16l-3 9H7z"/>
<circle cx="12" cy="6" r="1"/>
</svg>
</button>
<div
ref={timelineWrapperRef}
className="flex-1 overflow-x-auto overflow-y-auto select-none daw-bg relative"
style={{ display: activeTab !== 'main' ? 'none' : '' }}
>
<div style={{ width: `calc(300px + ${timelineWidth}px)` }} className="relative flex flex-col min-h-full">
{/* [ZONE A] Header Row */}
<div className="sticky top-0 z-40 flex h-8 border-b border-zinc-900 bg-[#242424] shrink-0">
{/* Left Side: Channels & Tools Header */}
<div className="w-[300px] sticky left-0 z-50 bg-[#242424] border-r border-zinc-900 flex items-center px-2 justify-between select-none shrink-0">
<span className="text-[10px] font-bold text-zinc-500 uppercase shrink-0 mr-2">Kênh</span>
{/* Separator */}
{/* Timeline Toolbar */}
<div className="flex items-center gap-0.5 bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 shadow-sm">
<button
onClick={() => { setActiveTool('select'); showToast('Công cụ chọn (Select Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'select' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-800/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Select Tool: Chọn khoảng, Đặt playhead (Giữ Alt kéo để di chuyển nhanh clip)"
>
<i data-lucide="mouse-pointer" className="w-3 h-3"></i>
</button>
<button
onClick={() => { setActiveTool('grab'); showToast('Công cụ di chuyển (Hand Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'grab' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Grab Tool: Click kéo trực tiếp để di chuyển clip"
>
<i data-lucide="hand" className="w-3 h-3"></i>
</button>
<button
onClick={() => { setActiveTool('razor'); showToast('Công cụ chia đoạn (Razor Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'razor' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Razor Tool: Click trên clip để chia nhỏ tại điểm click"
>
<svg className="w-3 h-3 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
<path d="M4 9h16l-3 9H7z"/>
<circle cx="12" cy="6" r="1"/>
</svg>
</button>
{/* Separator */}
<div className="w-[1px] h-3 bg-zinc-800 mx-0.5"></div>
{/* Quick Actions (Glue, Cut, Copy, Paste) */}
<button
onClick={handleGlueTracks}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition"
title="Glue: Gộp track hiện tại với track liền dưới"
>
<i data-lucide="link" className="w-3 h-3"></i>
</button>
<button
onClick={handleCutTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition"
title="Cut Clip (Ctrl+X)"
>
<i data-lucide="scissors" className="w-3 h-3"></i>
</button>
<button
onClick={handleCopyTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition"
title="Copy Clip (Ctrl+C)"
>
<i data-lucide="copy" className="w-3 h-3"></i>
</button>
<button
onClick={handlePasteTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition"
title="Paste Clip (Ctrl+V)"
>
<i data-lucide="clipboard" className="w-3 h-3"></i>
</button>
</div>
{/* Snap Section */}
<div className="w-[1px] h-3 bg-zinc-800 mx-0.5"></div>
{/* Quick Actions (Glue, Cut, Copy, Paste) */}
<button
onClick={handleGlueTracks}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition"
title="Glue: Gộp track hiện tại với track liền dưới"
>
<i data-lucide="link" className="w-3 h-3"></i>
</button>
<button
onClick={handleCutTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition"
title="Cut Clip (Ctrl+X)"
>
<i data-lucide="scissors" className="w-3 h-3"></i>
</button>
<button
onClick={handleCopyTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition"
title="Copy Clip (Ctrl+C)"
>
<i data-lucide="copy" className="w-3 h-3"></i>
</button>
<button
onClick={handlePasteTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition"
title="Paste Clip (Ctrl+V)"
>
<i data-lucide="clipboard" className="w-3 h-3"></i>
</button>
<div className="flex items-center gap-1 pl-0.5 select-none">
<span className="text-[9px] text-zinc-500 font-bold uppercase">Snap</span>
<select
value={snapValue}
onChange={(e) => setSnapValue(e.target.value)}
className="bg-zinc-850 text-zinc-300 text-[10px] px-1 py-0.5 rounded border border-zinc-800 focus:outline-none focus:border-cyan-550 font-mono"
>
<option value="free">Free</option>
<option value="1">1</option>
<option value="1/2">1/2</option>
<option value="1/4">1/4</option>
<option value="1/8">1/8</option>
<option value="1/16">1/16</option>
<option value="1/32">1/32</option>
</select>
</div>
</div>
{/* Right Side: Time Ruler */}
<div ref={rulerRef} className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden" 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>
</div>
<div className="flex flex-col divide-y divide-[#141414]">
{/* Tempo Track TCP - LOOP_EDITOR_2.md §6 */}
<div className="h-[40px] p-2 flex flex-col justify-between bg-[#1a1a2e] border-l-4 border-purple-500">
{/* [ZONE A] Tempo Track Row */}
<div className="sticky top-8 z-30 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
{/* Left Side: Tempo TCP */}
<div className="w-[300px] sticky left-0 z-50 bg-[#1a1a2e] border-r border-zinc-900 p-2 flex flex-col justify-between border-l-4 border-purple-500 shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-purple-400 font-mono">TM</span>
@@ -2858,124 +2897,93 @@
</div>
</div>
</div>
{/* Right Side: Tempo Lane */}
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
</div>
</div>
{/* [ZONE B] Dynamic Track List Workspace */}
<div className="flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full">
{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 key={track.id} className={`h-[96px] flex hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`}>
{/* Left Column: TCP */}
<div
onClick={() => setSelectedTrackId(track.id)}
className={`w-[300px] sticky left-0 z-10 p-2.5 flex flex-col justify-between bg-[#1e1e1e] border-r border-zinc-900 shrink-0 cursor-pointer border-l-4 overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`}
>
<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">
<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"> phỏng:</span>
<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>
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={(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>
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> Ti file
</label>
</div>
</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"> 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> Ti 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]">
{/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
<div className="h-[40px] w-full relative flex-shrink-0 border-b border-purple-500/30">
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
</div>
{tracks.map((track) => {
const isSelected = selectedTrackId === track.id;
return (
<div key={track.id} className="h-[96px] w-full relative flex-shrink-0"
{/* Right Column: Waveform Lane */}
<div
className="flex-1 relative overflow-hidden h-full"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
@@ -3003,19 +3011,22 @@
{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"
className="px-1.5 py-0.5 bg-zinc-900/95 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> Ct
</button>
</div>
)}
</div>
);
})}
</div>
);
})}
{/* Drop zone to create a new track during drag */}
{/* Bottom Drop Zone to create new track */}
<div className="h-[48px] flex border-t border-dashed border-zinc-800">
<div className="w-[300px] sticky left-0 z-10 bg-[#1e1e1e]/50 border-r border-zinc-900 shrink-0"></div>
<div
className="h-[48px] w-full border-t border-dashed border-zinc-800 flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none shrink-0"
className="flex-1 flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none"
onMouseEnter={() => {
if (draggedClipRef.current) {
const newId = addNewTrack();
@@ -3028,32 +3039,32 @@
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Kéo clip xung đây hoc Click đ to Track mi
</span>
</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>
{/* 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-0 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 + 300}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>
)}
{/* 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>
)}
{/* Playhead */}
<div className="absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none"
style={{ left: `${playheadLeftPos + 300}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>