fix: sửa lỗi zoom in full màn hình
This commit is contained in:
+165
@@ -0,0 +1,165 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# Technical Specification: Grid Snapping System (Grid Snapping Specification)
|
||||||
|
|
||||||
|
This document defines the graphical user interface design and coordinate/signal processing algorithms required to build a synchronized grid snapping feature across both the Web Frontend and the Dockerized Python Desktop Backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Toolbar UI Design Upgrade
|
||||||
|
|
||||||
|
The toolbar layout has been expanded to double its physical vertical height. This increase in interactive space allows for larger navigation buttons and the integration of a dedicated Snap controller.
|
||||||
|
|
||||||
|
```text
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| [Pro Toolbar - Height: 64px] |
|
||||||
|
| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ |
|
||||||
|
| | Cut | | Copy | | Paste | | Snap: | | Transport Monitor | |
|
||||||
|
| | [Ctrl+X]| | [Ctrl+C]| | [Ctrl+V]| | [1/4 ▼] | | [Tempo: 120 BPM] [Time Signature: 4/4] [Bar:Beat 1.3.00] | |
|
||||||
|
| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ |
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1. Snap Dropdown Configuration
|
||||||
|
|
||||||
|
* **Placement:** Located immediately following the *Paste* button on the primary toolbar row.
|
||||||
|
* **UI Syntax:** `Snap: <Dropdown_Widget>`
|
||||||
|
* **Dropdown Option Matrix:**
|
||||||
|
* `free`: Disables snapping; allows unrestricted pixel-by-pixel dragging.
|
||||||
|
* `1`: Snaps to the beginning of each complete measure (Whole Bar / 1/1).
|
||||||
|
* `1/2`: Divides the bar into 2 subdivisions (Half Note).
|
||||||
|
* `1/4`: Divides the bar into 4 subdivisions (Quarter Note / 1 Beat).
|
||||||
|
* `1/8`: Divides the bar into 8 subdivisions (Eighth Note).
|
||||||
|
* `1/16`: Divides the bar into 16 subdivisions (Sixteenth Note).
|
||||||
|
* `1/32`: Divides the bar into 32 subdivisions (Thirty-second Note).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DSP Grid Math: Grid Subdivision & Time Interval Calculations
|
||||||
|
|
||||||
|
To calculate the absolute temporal duration between individual grid lanes, the time metrics must be derived dynamically from the project's master tempo.
|
||||||
|
|
||||||
|
Let:
|
||||||
|
|
||||||
|
* $B$ be the project tempo (Beats Per Minute, e.g., $120\text{ BPM}$).
|
||||||
|
* $T_{\text{beat}}$ be the duration of a single beat (seconds).
|
||||||
|
* $T_{\text{bar}}$ be the duration of a full measure/bar (seconds)—assuming a standard $4/4$ time signature (4 beats per bar).
|
||||||
|
|
||||||
|
The foundational constants are established as follows:
|
||||||
|
|
||||||
|
|
||||||
|
$$T_{\text{beat}} = \frac{60}{B} \quad (\text{seconds})$$
|
||||||
|
|
||||||
|
$$T_{\text{bar}} = 4 \times T_{\text{beat}} = \frac{240}{B} \quad (\text{seconds})$$
|
||||||
|
|
||||||
|
*Example:* At a tempo of $120\text{ BPM}$, a single $1\text{ Bar}$ measure spans exactly $2.0\text{ seconds}$.
|
||||||
|
|
||||||
|
### 2.1. Determining Grid Time Interval Modifiers ($\Delta t$)
|
||||||
|
|
||||||
|
Based on the user's active choice inside the snap dropdown selection ($S \in \{\text{free}, 1, 1/2, 1/4, 1/8, 1/16, 1/32\}$), the exact grid time step $\Delta t$ (seconds) is mapped as follows:
|
||||||
|
|
||||||
|
$$\Delta t = \begin{cases} 0 & S = \text{free} \\ T_{\text{bar}} & S = 1 \\ \frac{T_{\text{bar}}}{2} & S = 1/2 \\ \frac{T_{\text{bar}}}{4} = T_{\text{beat}} & S = 1/4 \\ \frac{T_{\text{bar}}}{8} & S = 1/8 \\ \frac{T_{\text{bar}}}{16} & S = 1/16 \\ \frac{T_{\text{bar}}}{32} & S = 1/32 \end{cases}$$
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Snapping Coordinate Calculation
|
||||||
|
|
||||||
|
When a user executes a drag-and-drop event on an audio clip, or updates a timeline marker position, the system continuously converts raw cursor values into aligned coordinates.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Grid Line 1 (k * dt) Grid Line 2 ((k+1) * dt)
|
||||||
|
│ │
|
||||||
|
├───────────────○─────────────┤
|
||||||
|
▲
|
||||||
|
│ [ Cursor dragging action ]
|
||||||
|
Raw Time (t_raw)
|
||||||
|
│
|
||||||
|
▼ [ Apply Snap round() function ]
|
||||||
|
────────────────┼─────────────►
|
||||||
|
Snapped Time (t_snapped)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1. Pixel to Snap-Time Translation Workflow
|
||||||
|
|
||||||
|
1. Intercept the actual client-side horizontal cursor position $X_{\text{raw}}$ (pixels).
|
||||||
|
2. Convert it into a raw timeline duration metric $t_{\text{raw}}$ (seconds) utilizing the current scaling zoom factor $Z$ (pixels/second):
|
||||||
|
|
||||||
|
$$t_{\text{raw}} = \frac{X_{\text{raw}}}{Z}$$
|
||||||
|
|
||||||
|
|
||||||
|
3. Apply the rounding constraint formula to lock the raw timestamp to the absolute nearest grid marker:
|
||||||
|
|
||||||
|
$$t_{\text{snapped}} = \begin{cases} t_{\text{raw}} & S = \text{free} \\ \text{round}\left( \frac{t_{\text{raw}}}{\Delta t} \right) \times \Delta t & S \neq \text{free} \end{cases}$$
|
||||||
|
|
||||||
|
|
||||||
|
4. Map the snapped timeline index $t_{\text{snapped}}$ back to the layout canvas system coordinates to paint the element at its snapped visual boundary:
|
||||||
|
|
||||||
|
$$X_{\text{snapped}} = t_{\text{snapped}} \times Z$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Python Porting Blueprint
|
||||||
|
|
||||||
|
When translating this architectural logic into a desktop Python core environment using frameworks like PyQt6, the snapping evaluations are tied directly into the tracking loop inside the `mouseMoveEvent` handler.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# [PYTHON PORTING BLUEPRINT] - Integrating the Snap algorithm into Python UI layer
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
class AudioSnapEngine:
|
||||||
|
def __init__(self, bpm: float = 120.0):
|
||||||
|
self.bpm = bpm
|
||||||
|
|
||||||
|
def calculate_grid_step(self, snap_option: str) -> float:
|
||||||
|
"""
|
||||||
|
Calculates the grid's target duration step (seconds) based on Tempo and Snap selection.
|
||||||
|
"""
|
||||||
|
if snap_option == "free":
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# 1 Bar in a standard 4/4 signature equals 240 / BPM seconds
|
||||||
|
t_bar = 240.0 / self.bpm
|
||||||
|
|
||||||
|
fraction_map = {
|
||||||
|
"1": 1.0,
|
||||||
|
"1/2": 2.0,
|
||||||
|
"1/4": 4.0,
|
||||||
|
"1/8": 8.0,
|
||||||
|
"1/16": 16.0,
|
||||||
|
"1/32": 32.0
|
||||||
|
}
|
||||||
|
|
||||||
|
division = fraction_map.get(snap_option, 4.0)
|
||||||
|
return float(t_bar / division)
|
||||||
|
|
||||||
|
def snap_time(self, raw_time_seconds: float, snap_option: str) -> float:
|
||||||
|
"""
|
||||||
|
Hard-clamps a raw timestamp to the nearest grid milestone. Prevents negative index overflows.
|
||||||
|
"""
|
||||||
|
dt = self.calculate_grid_step(snap_option)
|
||||||
|
if dt == 0.0:
|
||||||
|
return max(0.0, raw_time_seconds)
|
||||||
|
|
||||||
|
# Find the nearest integer index k of the target grid lane: raw_time / dt
|
||||||
|
k = round(raw_time_seconds / dt)
|
||||||
|
snapped_time = k * dt
|
||||||
|
|
||||||
|
return max(0.0, snapped_time)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Visual Grid Alignment
|
||||||
|
|
||||||
|
To maintain an intuitive environment for multi-channel editing, whenever a snapping constraint value other than `free` is engaged:
|
||||||
|
|
||||||
|
* The rendering engine overlays thin, low-contrast vertical grid lines (`rgba(255, 255, 255, 0.05)`) over the background profile of every active Waveform Lane.
|
||||||
|
* These marker lines are projected onto every timeline axis point that satisfies a whole multiple increment of $\Delta t$.
|
||||||
|
* Displaying these alignment indicators ensures that users can visually anticipate bounding snapping positions before releasing their mouse track buttons.
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# Technical Specification: Multi-Channel Layout Synchronization & Scroll Management (Unified DAW Layout & Sync Scroll)
|
||||||
|
|
||||||
|
This document analyzes and defines the structural hierarchy of the graphical user interface based on the real-world interface analysis. This specification serves to guide Frontend interface programming and porting to a Python Desktop application running inside a Docker container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Structural Wireframe
|
||||||
|
|
||||||
|
Based on the visual analysis, the layout composition is split into vertically static and dynamic zones:
|
||||||
|
|
||||||
|
```text
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| [ZONE A - STATIC] HEADER ZONE (Sticky - Permanently fixed when scrolling down)|
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
| | Channels & Tools | Time Ruler Scale | |
|
||||||
|
| |--------------------|------------------------------------------------------| |
|
||||||
|
| | Tempo Track Header | Tempo Grid Lane (120 BPM) | |
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| [ZONE B - DYNAMIC] TRACKS SCROLL WORKSPACE (Synchronized vertical scroll) |
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
| | TCP - Track 01 | Waveform Lane - Track 01 | |
|
||||||
|
| | TCP - Track 02 | Waveform Lane - Track 02 | |
|
||||||
|
| | TCP - Track 03 | Waveform Lane - Track 03 | |
|
||||||
|
| | ... | ... | |
|
||||||
|
| +────────────────────+──────────────────────────────────────────────────────+ |
|
||||||
|
+───────────────────────────────────────────────────────────────────────────────+ ▲
|
||||||
|
│ [Vertical Scrollbar]
|
||||||
|
│ (Single unified scroll)
|
||||||
|
▼
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layout Specifications
|
||||||
|
|
||||||
|
### 2.1. Fixed Header Zone (Green Border Area - Sticky Header)
|
||||||
|
|
||||||
|
* **Visual Scope:** Encompasses the toolbar, the time ruler scale, and the Tempo Track Lane (indicated by the green bounding border in `image_fbbd4e.png`).
|
||||||
|
* **Graphical Sticky Behavior:**
|
||||||
|
* When a user adds dozens of tracks and scrolls downward, this entire zone must remain anchored to the top of the screen and is not permitted to slide out of view.
|
||||||
|
* This ensures that users can continuously track the Ruler Seconds and the master project tempo (Tempo BPM) while editing tracks located deeper down the timeline.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2.2. Absolute Horizontal Row Alignment (Red Border Area - Row Alignment)
|
||||||
|
|
||||||
|
* **Interaction Scope:** The exact matching pair consisting of the left Track Control Panel (TCP) and the right Waveform Lane of the same track (e.g., Track 4 inside the red border of `image_fbbd4e.png`).
|
||||||
|
* **Row Alignment Rules:**
|
||||||
|
* The corresponding TCP and Waveform Lane must have identical heights ($H = 96\text{ px}$).
|
||||||
|
* These two elements must be wrapped within a single parent row container (`Flex Row` or `Grid Row`) to guarantee that during vertical scrolling, both move simultaneously along the exact same vertical axis coordinate ($Y$).
|
||||||
|
* Row misalignment must be strictly avoided (e.g., situations where the Track 4 TCP sits higher or lower than the Track 4 Waveform lane).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### 2.3. Single Vertical Scrollbar Mandate
|
||||||
|
|
||||||
|
* **Issue to Avoid:** Separating the TCP into an independent scrollable column and the Timeline into another independent scrollable column. Doing so leads to scroll-position desynchronization errors when a user drags the scrollbar.
|
||||||
|
* **Design Standard:**
|
||||||
|
* Only a single unified Vertical Scrollbar is permitted to appear on the absolute far right of the application window (as directed by the two red arrows in `image_fbbd4e.png`).
|
||||||
|
* This vertical scrollbar moves the entire dynamic wrapper (**Tracks Scroll Workspace**), scrolling both TCPs and Waveform Lanes up or down in sync.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementation Guide
|
||||||
|
|
||||||
|
### 3.1. Web Frontend Integration (HTML / Tailwind CSS)
|
||||||
|
|
||||||
|
To group everything into one scrollbar while keeping the Tempo Track anchored at the top, use `position: sticky` and wrap the dynamic track list inside a single container:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Main Container (Entire Editor Wrapper) -->
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
|
||||||
|
<!-- [ZONE A] Top Anchored Sticky Header Zone -->
|
||||||
|
<div class="sticky top-0 z-40 bg-[#242424] border-b border-[#141414] shrink-0">
|
||||||
|
<!-- Toolbar & Time Ruler -->
|
||||||
|
<div class="h-8 flex">
|
||||||
|
<div class="w-[300px] border-r border-zinc-900 px-4 flex items-center">CHANNELS</div>
|
||||||
|
<div class="flex-1 relative h-full">...Ruler Numbers...</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tempo Track (Green Border Area) -->
|
||||||
|
<div class="h-[44px] flex border-t border-zinc-800 bg-[#212121]">
|
||||||
|
<div class="w-[300px] border-r border-zinc-900 px-4 flex items-center justify-between">
|
||||||
|
<span class="font-bold text-zinc-400">Tempo Track</span>
|
||||||
|
<span class="bg-zinc-800 px-1.5 py-0.5 rounded text-[10px]">120 BPM</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">...Tempo Grid Lines...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [ZONE B] Dynamic Track Workspace (Single global vertical scrollbar on the far right) -->
|
||||||
|
<div class="flex-1 overflow-y-auto bg-[#1a1a1a]">
|
||||||
|
<div class="flex flex-col divide-y divide-[#141414]">
|
||||||
|
|
||||||
|
<!-- Track Row Container (Absolute Horizontal Row Alignment) -->
|
||||||
|
<div class="h-[96px] flex hover:bg-zinc-800/20 transition-colors">
|
||||||
|
<!-- Left: TCP -->
|
||||||
|
<div class="w-[300px] border-r border-zinc-900 p-2.5 flex-shrink-0">
|
||||||
|
...Controls (Mute, Solo, Volume, File Name)...
|
||||||
|
</div>
|
||||||
|
<!-- Right: Waveform Lane -->
|
||||||
|
<div class="flex-1 relative overflow-hidden">
|
||||||
|
...Waveform Canvas...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add more track rows repeating the structure above... -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Desktop App Integration (Python PyQt6)
|
||||||
|
|
||||||
|
When engineering this user interface using the Qt framework in Python, utilize a `QScrollArea` to encapsulate a `QWidget` managed by a layout of rows to control the single scrollbar behavior:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QLabel
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
class MasterDAWWidget(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. Initialize Fixed Header (Toolbar, Ruler, Tempo)
|
||||||
|
self.header_widget = QWidget()
|
||||||
|
self.header_widget.setFixedHeight(76) # 32px Ruler + 44px Tempo
|
||||||
|
self.setup_header_ui()
|
||||||
|
self.main_layout.addWidget(self.header_widget)
|
||||||
|
|
||||||
|
# 2. Initialize Scroll Area for dynamic track rows
|
||||||
|
self.scroll_area = QScrollArea()
|
||||||
|
self.scroll_area.setWidgetResizable(True)
|
||||||
|
# Force a single vertical scrollbar on the far right
|
||||||
|
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||||
|
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
|
|
||||||
|
# Widget container hosting the track list inside the Scroll Area
|
||||||
|
self.tracks_container = QWidget()
|
||||||
|
self.tracks_layout = QVBoxLayout(self.tracks_container)
|
||||||
|
self.tracks_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.tracks_layout.setSpacing(0)
|
||||||
|
self.tracks_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
self.scroll_area.setWidget(self.tracks_container)
|
||||||
|
self.main_layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
|
def add_track_row(self, track_id, track_name):
|
||||||
|
"""
|
||||||
|
Appends a new track row. Uses QHBoxLayout to lock the TCP and Waveform Lane
|
||||||
|
into absolute horizontal sync within the row.
|
||||||
|
"""
|
||||||
|
row_widget = QWidget()
|
||||||
|
row_widget.setFixedHeight(96) # Rigid constraint for the entire row
|
||||||
|
row_layout = QHBoxLayout(row_widget)
|
||||||
|
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
row_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Left: Track Control Panel (TCP)
|
||||||
|
tcp_widget = QWidget()
|
||||||
|
tcp_widget.setFixedWidth(300)
|
||||||
|
# Setup TCP UI components...
|
||||||
|
row_layout.addWidget(tcp_widget)
|
||||||
|
|
||||||
|
# Right: Waveform Lane
|
||||||
|
waveform_widget = QWidget()
|
||||||
|
# Setup Waveform Canvas Painter...
|
||||||
|
row_layout.addWidget(waveform_widget)
|
||||||
|
|
||||||
|
self.tracks_layout.addWidget(row_widget)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Layout Architecture Advantages
|
||||||
|
|
||||||
|
* **Fluid User Experience:** Eliminates row-stuttering or scrolling layout shifts between the control panels and audio visuals when a user scrolls through long track stacks rapidly.
|
||||||
|
* **Flawless Python Porting Compatibility:** By wrapping the TCP and the Waveform Canvas inside a common row (`QHBoxLayout` in Qt or `Flex Row` in Web), the core widget tree hierarchy remains incredibly lean. This design removes the need to write custom coordinate bridging code to bind two separate scroll engines together.
|
||||||
|
* **Clean Interface Aesthetics:** Safely protects the pixel rendering mapping ratios of the fixed time grids at the top, precisely matching the professional DAW interface conventions observed
|
||||||
+556
-147
@@ -175,6 +175,10 @@
|
|||||||
onTrackLaneMouseDown,
|
onTrackLaneMouseDown,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onClipDragStart,
|
onClipDragStart,
|
||||||
|
activeTool,
|
||||||
|
onSplitTrackAtTime,
|
||||||
|
snapValue,
|
||||||
|
bpm,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
@@ -193,13 +197,34 @@
|
|||||||
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
|
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
// Grid lines
|
// Grid lines based on Snap value
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.02)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
const stepSeconds = zoom > 120 ? 0.5 : 1;
|
|
||||||
const totalSec = width / zoom;
|
const totalSec = width / zoom;
|
||||||
|
|
||||||
for (let s = 0; s <= totalSec; s += stepSeconds) {
|
let gridSpacing = 1.0; // default 1 second
|
||||||
|
if (snapValue && snapValue !== 'free') {
|
||||||
|
const beatDuration = 60 / parseFloat(bpm || 120);
|
||||||
|
let divisor = 1;
|
||||||
|
if (snapValue === '1') divisor = 1;
|
||||||
|
else if (snapValue === '1/2') divisor = 0.5;
|
||||||
|
else if (snapValue === '1/4') divisor = 0.25;
|
||||||
|
else if (snapValue === '1/8') divisor = 0.125;
|
||||||
|
else if (snapValue === '1/16') divisor = 0.0625;
|
||||||
|
else if (snapValue === '1/32') divisor = 0.03125;
|
||||||
|
|
||||||
|
gridSpacing = beatDuration * divisor;
|
||||||
|
} else {
|
||||||
|
gridSpacing = 60 / parseFloat(bpm || 120); // default to 1 beat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: if lines are too close, scale grid spacing by multiples of 2
|
||||||
|
let drawSpacing = gridSpacing;
|
||||||
|
while (drawSpacing * zoom < 10) {
|
||||||
|
drawSpacing *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let s = 0; s <= totalSec; s += drawSpacing) {
|
||||||
const x = s * zoom;
|
const x = s * zoom;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x, 0);
|
ctx.moveTo(x, 0);
|
||||||
@@ -208,16 +233,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw waveform lane
|
// Draw waveform lane
|
||||||
if (track.buffer) {
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
const data = track.buffer.getChannelData(0);
|
id: 'default',
|
||||||
const sampleRate = track.buffer.sampleRate;
|
buffer: track.buffer,
|
||||||
|
startTime: track.startTime || 0,
|
||||||
|
name: track.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
if (clips.length > 0) {
|
||||||
|
clips.forEach(clip => {
|
||||||
|
const data = clip.buffer.getChannelData(0);
|
||||||
|
const sampleRate = clip.buffer.sampleRate;
|
||||||
const totalSamples = data.length;
|
const totalSamples = data.length;
|
||||||
const duration = totalSamples / sampleRate;
|
const duration = totalSamples / sampleRate;
|
||||||
|
|
||||||
const xStart = (track.startTime || 0) * zoom;
|
const xStart = (clip.startTime || 0) * zoom;
|
||||||
const wClip = duration * zoom;
|
const wClip = duration * zoom;
|
||||||
const xEnd = xStart + wClip;
|
const xEnd = xStart + wClip;
|
||||||
|
|
||||||
|
// 1. Draw Clip Layer Background & Border
|
||||||
|
ctx.fillStyle = track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)';
|
||||||
|
ctx.strokeStyle = track.color || '#06b6d4';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
|
||||||
|
const clipTop = 8;
|
||||||
|
const clipHeight = height - 16;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) {
|
||||||
|
ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4);
|
||||||
|
} else {
|
||||||
|
ctx.rect(xStart, clipTop, wClip, clipHeight);
|
||||||
|
}
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// 2. Draw Clip Label
|
||||||
|
ctx.fillStyle = '#e4e4e7';
|
||||||
|
ctx.font = 'bold 9px sans-serif';
|
||||||
|
ctx.fillText(clip.name || 'Clip', xStart + 8, clipTop + 14);
|
||||||
|
|
||||||
// Draw markers
|
// Draw markers
|
||||||
if (markers && markers.length > 0) {
|
if (markers && markers.length > 0) {
|
||||||
markers.forEach(m => {
|
markers.forEach(m => {
|
||||||
@@ -229,8 +284,8 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peak waveform drawing only within clip bounds
|
// 3. Peak waveform drawing only within clip bounds
|
||||||
ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7';
|
ctx.strokeStyle = isSelected ? '#22d3ee' : '#a7f3d0';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
const drawXStart = Math.max(0, Math.floor(xStart));
|
const drawXStart = Math.max(0, Math.floor(xStart));
|
||||||
@@ -251,13 +306,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mid = height / 2;
|
const mid = height / 2;
|
||||||
const peakHeight = maxVal * (height * 0.4);
|
const peakHeight = maxVal * (clipHeight * 0.45);
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(px, mid - peakHeight);
|
ctx.moveTo(px, mid - peakHeight);
|
||||||
ctx.lineTo(px, mid + peakHeight);
|
ctx.lineTo(px, mid + peakHeight);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#444';
|
ctx.fillStyle = '#444';
|
||||||
ctx.font = '12px Inter, sans-serif';
|
ctx.font = '12px Inter, sans-serif';
|
||||||
@@ -277,12 +333,39 @@
|
|||||||
ctx.strokeRect(hlLeft, 0, hlWidth, height);
|
ctx.strokeRect(hlLeft, 0, hlWidth, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight]);
|
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full cursor-crosshair"
|
className="w-full h-full cursor-crosshair"
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
||||||
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
||||||
|
const x = e.clientX - rect.left + scrollLeft;
|
||||||
|
const time = x / zoom;
|
||||||
|
|
||||||
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
|
id: 'default',
|
||||||
|
buffer: track.buffer,
|
||||||
|
startTime: track.startTime || 0,
|
||||||
|
name: track.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration);
|
||||||
|
const isOverClip = !!hoveredClip;
|
||||||
|
|
||||||
|
if (activeTool === 'grab') {
|
||||||
|
canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default';
|
||||||
|
} else if (activeTool === 'razor') {
|
||||||
|
canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed';
|
||||||
|
} else {
|
||||||
|
// select tool
|
||||||
|
canvasRef.current.style.cursor = (isOverClip && e.altKey) ? 'grab' : 'crosshair';
|
||||||
|
}
|
||||||
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// Ignore right-click for local selection drag (context menu handles it)
|
// Ignore right-click for local selection drag (context menu handles it)
|
||||||
if (e.button === 2) return;
|
if (e.button === 2) return;
|
||||||
@@ -293,13 +376,45 @@
|
|||||||
const time = Math.max(0, x / zoom);
|
const time = Math.max(0, x / zoom);
|
||||||
onSelectTrack(track.id);
|
onSelectTrack(track.id);
|
||||||
|
|
||||||
// Check for Alt+Click drag clip
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
const isOverClip = track.buffer && time >= (track.startTime || 0) && time < (track.startTime || 0) + track.buffer.duration;
|
id: 'default',
|
||||||
if (isOverClip && e.altKey) {
|
buffer: track.buffer,
|
||||||
|
startTime: track.startTime || 0,
|
||||||
|
name: track.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration);
|
||||||
|
|
||||||
|
if (activeTool === 'razor') {
|
||||||
|
if (clickedClip) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onSplitTrackAtTime) {
|
||||||
|
onSplitTrackAtTime(track.id, clickedClip.id, time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTool === 'grab') {
|
||||||
|
if (clickedClip) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onClipDragStart) {
|
if (onClipDragStart) {
|
||||||
onClipDragStart(track.id, time - (track.startTime || 0));
|
onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onPlayheadSet(time);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for click drag clip (no Alt key required if select tool is in fallback alt-mode)
|
||||||
|
if (clickedClip && e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onClipDragStart) {
|
||||||
|
onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -325,7 +440,7 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet }) => {
|
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet, snapValue }) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -375,12 +490,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw snap sub-ticks at the bottom
|
||||||
|
if (snapValue && snapValue !== 'free') {
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
let divisor = 1;
|
||||||
|
if (snapValue === '1') divisor = 1;
|
||||||
|
else if (snapValue === '1/2') divisor = 0.5;
|
||||||
|
else if (snapValue === '1/4') divisor = 0.25;
|
||||||
|
else if (snapValue === '1/8') divisor = 0.125;
|
||||||
|
else if (snapValue === '1/16') divisor = 0.0625;
|
||||||
|
else if (snapValue === '1/32') divisor = 0.03125;
|
||||||
|
|
||||||
|
const snapInterval = beatDuration * divisor;
|
||||||
|
if (snapInterval * zoom >= 4) {
|
||||||
|
for (let t = 0; t <= totalSec; t += snapInterval) {
|
||||||
|
const onBeat = Math.abs((t / beatDuration) - Math.round(t / beatDuration)) < 0.001;
|
||||||
|
if (!onBeat) {
|
||||||
|
const x = t * zoom;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, height - 6);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
|
||||||
ctx.font = 'bold 10px Inter, sans-serif';
|
ctx.font = 'bold 10px Inter, sans-serif';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(`${bpm} BPM`, width - 6, 12);
|
ctx.fillText(`${bpm} BPM`, width - 6, 12);
|
||||||
|
|
||||||
}, [bpm, zoom, timelineWidth]);
|
}, [bpm, zoom, timelineWidth, snapValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
@@ -448,10 +590,40 @@
|
|||||||
{ id: '1', name: 'Track 01', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
|
{ id: '1', name: 'Track 01', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
|
||||||
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
|
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
|
||||||
]);
|
]);
|
||||||
|
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||||
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
|
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
|
||||||
const [hoveredTrackId, setHoveredTrackId] = useState(null);
|
const [hoveredTrackId, setHoveredTrackId] = useState(null);
|
||||||
|
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
|
||||||
|
const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32'
|
||||||
|
|
||||||
|
const snapTime = (time, snapVal, bpmVal) => {
|
||||||
|
if (snapVal === 'free') return time;
|
||||||
|
const beatDuration = 60 / parseFloat(bpmVal || 120);
|
||||||
|
let divisor = 1;
|
||||||
|
if (snapVal === '1') divisor = 1;
|
||||||
|
else if (snapVal === '1/2') divisor = 0.5;
|
||||||
|
else if (snapVal === '1/4') divisor = 0.25;
|
||||||
|
else if (snapVal === '1/8') divisor = 0.125;
|
||||||
|
else if (snapVal === '1/16') divisor = 0.0625;
|
||||||
|
else if (snapVal === '1/32') divisor = 0.03125;
|
||||||
|
|
||||||
|
const gridSpacing = beatDuration * divisor;
|
||||||
|
return Math.round(time / gridSpacing) * gridSpacing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapValueRef = useRef(snapValue);
|
||||||
|
snapValueRef.current = snapValue;
|
||||||
|
const bpmRef = useRef(bpm);
|
||||||
|
bpmRef.current = bpm;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}, [activeTool]);
|
||||||
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
|
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
|
||||||
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
|
||||||
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -546,6 +718,12 @@
|
|||||||
// buffer is captured via reference copy for undo; we store a clone for redo
|
// buffer is captured via reference copy for undo; we store a clone for redo
|
||||||
buffer: track.buffer,
|
buffer: track.buffer,
|
||||||
startTime: track.startTime || 0,
|
startTime: track.startTime || 0,
|
||||||
|
clips: track.clips ? track.clips.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
buffer: c.buffer,
|
||||||
|
startTime: c.startTime,
|
||||||
|
name: c.name
|
||||||
|
})) : null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -892,81 +1070,51 @@
|
|||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
|
|
||||||
const targetTrack = tracks.find(t => t.id === targetTrackId);
|
const targetTrack = tracks.find(t => t.id === targetTrackId);
|
||||||
if (targetTrack && targetTrack.buffer) {
|
const newClip = {
|
||||||
const targetSr = targetTrack.buffer.sampleRate;
|
id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000),
|
||||||
const clipSr = clipBuffer.sampleRate;
|
startTime: pasteTime,
|
||||||
// Resample clipboard to target sample rate if needed
|
buffer: clipBuffer,
|
||||||
let pasteData;
|
name: name || 'Pasted Clip'
|
||||||
let pasteSamples;
|
};
|
||||||
if (clipSr === targetSr) {
|
|
||||||
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipSr);
|
|
||||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
|
||||||
pasteData = newClipBuffer.getChannelData(0);
|
|
||||||
pasteSamples = clipBuffer.length;
|
|
||||||
} else {
|
|
||||||
pasteSamples = Math.round(clipBuffer.length * targetSr / clipSr);
|
|
||||||
const resampled = ctx.createBuffer(1, pasteSamples, targetSr);
|
|
||||||
const out = resampled.getChannelData(0);
|
|
||||||
const src = clipBuffer.getChannelData(0);
|
|
||||||
const srcLen = clipBuffer.length;
|
|
||||||
for (let i = 0; i < pasteSamples; i++) {
|
|
||||||
const pos = (i / pasteSamples) * srcLen;
|
|
||||||
const idx = Math.floor(pos);
|
|
||||||
const frac = pos - idx;
|
|
||||||
const s0 = src[Math.min(idx, srcLen - 1)];
|
|
||||||
const s1 = src[Math.min(idx + 1, srcLen - 1)];
|
|
||||||
out[i] = s0 + (s1 - s0) * frac;
|
|
||||||
}
|
|
||||||
pasteData = out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetStart = targetTrack.startTime || 0;
|
|
||||||
const relativePasteTime = pasteTime - targetStart;
|
|
||||||
|
|
||||||
let newStartTime = targetStart;
|
|
||||||
let combined;
|
|
||||||
const origLen = targetTrack.buffer.length;
|
|
||||||
const origData = targetTrack.buffer.getChannelData(0);
|
|
||||||
|
|
||||||
if (relativePasteTime >= 0) {
|
|
||||||
const insertSample = Math.floor(relativePasteTime * targetSr);
|
|
||||||
const newLen = Math.max(origLen, insertSample + pasteSamples);
|
|
||||||
combined = ctx.createBuffer(1, newLen, targetSr);
|
|
||||||
const combinedData = combined.getChannelData(0);
|
|
||||||
for (let i = 0; i < origLen; i++) combinedData[i] = origData[i];
|
|
||||||
for (let i = 0; i < pasteSamples; i++) combinedData[insertSample + i] = pasteData[i];
|
|
||||||
} else {
|
|
||||||
newStartTime = pasteTime;
|
|
||||||
const prependSamples = Math.floor((targetStart - pasteTime) * targetSr);
|
|
||||||
const newLen = Math.max(prependSamples + origLen, pasteSamples);
|
|
||||||
combined = ctx.createBuffer(1, newLen, targetSr);
|
|
||||||
const combinedData = combined.getChannelData(0);
|
|
||||||
for (let i = 0; i < origLen; i++) combinedData[prependSamples + i] = origData[i];
|
|
||||||
for (let i = 0; i < pasteSamples; i++) combinedData[i] = pasteData[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined, startTime: newStartTime } : t));
|
|
||||||
setCurrentTime(pasteTime);
|
|
||||||
showToast('Đã dán vào track.', 'success');
|
|
||||||
return targetTrackId;
|
|
||||||
}
|
|
||||||
if (targetTrack) {
|
if (targetTrack) {
|
||||||
// Track exists but no buffer — use clipboard sample rate
|
setTracks(p => p.map(t => {
|
||||||
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
|
if (t.id === targetTrackId) {
|
||||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
|
||||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: newClipBuffer, startTime: pasteTime, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t));
|
id: 'default_' + t.id,
|
||||||
|
buffer: t.buffer,
|
||||||
|
startTime: t.startTime || 0,
|
||||||
|
name: t.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
const updatedClips = [...existingClips, newClip];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
clips: updatedClips,
|
||||||
|
buffer: updatedClips[0].buffer,
|
||||||
|
startTime: updatedClips[0].startTime,
|
||||||
|
name: name || t.name,
|
||||||
|
volume: volume || t.volume,
|
||||||
|
color: color || t.color
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}));
|
||||||
setCurrentTime(pasteTime);
|
setCurrentTime(pasteTime);
|
||||||
showToast('Đã dán vào track.', 'success');
|
showToast('Đã dán clip vào track.', 'success');
|
||||||
return targetTrackId;
|
return targetTrackId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No matching track — create a new one
|
// No matching track — create a new one
|
||||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||||
const newId = 'track_pasted_' + Date.now();
|
const newId = 'track_pasted_' + Date.now();
|
||||||
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
|
|
||||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
|
||||||
setTracks(prev => [...prev, {
|
setTracks(prev => [...prev, {
|
||||||
id: newId, name: `Pasted_${name || 'track'}`, buffer: newClipBuffer,
|
id: newId,
|
||||||
|
name: `Pasted_${name || 'track'}`,
|
||||||
|
buffer: clipBuffer,
|
||||||
startTime: pasteTime,
|
startTime: pasteTime,
|
||||||
|
clips: [newClip],
|
||||||
volume: volume || 0.8, muted: false, solo: false,
|
volume: volume || 0.8, muted: false, solo: false,
|
||||||
color: color || colors[prev.length % colors.length],
|
color: color || colors[prev.length % colors.length],
|
||||||
markers: [], serverFileId: null,
|
markers: [], serverFileId: null,
|
||||||
@@ -1157,7 +1305,7 @@
|
|||||||
maxDurationRef.current = maxDuration;
|
maxDurationRef.current = maxDuration;
|
||||||
|
|
||||||
const minZoom = useMemo(() => {
|
const minZoom = useMemo(() => {
|
||||||
return Math.max(20, viewportWidth / maxDuration);
|
return viewportWidth / maxDuration;
|
||||||
}, [viewportWidth, maxDuration]);
|
}, [viewportWidth, maxDuration]);
|
||||||
|
|
||||||
const timelineWidth = useMemo(() => {
|
const timelineWidth = useMemo(() => {
|
||||||
@@ -1339,16 +1487,24 @@
|
|||||||
const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null;
|
const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null;
|
||||||
|
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
if (!track.buffer) return;
|
|
||||||
|
|
||||||
const isPlayable = hasSolo
|
const isPlayable = hasSolo
|
||||||
? (track.id === soloedTrackId || track.solo)
|
? (track.id === soloedTrackId || track.solo)
|
||||||
: !track.muted;
|
: !track.muted;
|
||||||
|
|
||||||
if (!isPlayable) return;
|
if (!isPlayable) return;
|
||||||
|
|
||||||
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
|
id: 'default',
|
||||||
|
buffer: track.buffer,
|
||||||
|
startTime: track.startTime || 0,
|
||||||
|
name: track.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
clips.forEach(clip => {
|
||||||
|
if (!clip.buffer) return;
|
||||||
|
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
source.buffer = track.buffer;
|
source.buffer = clip.buffer;
|
||||||
|
|
||||||
const gainNode = context.createGain();
|
const gainNode = context.createGain();
|
||||||
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
||||||
@@ -1356,30 +1512,41 @@
|
|||||||
source.connect(gainNode);
|
source.connect(gainNode);
|
||||||
gainNode.connect(context.destination);
|
gainNode.connect(context.destination);
|
||||||
|
|
||||||
const trackStart = track.startTime || 0;
|
const clipStart = clip.startTime || 0;
|
||||||
const trackDuration = track.buffer.duration;
|
const clipDuration = clip.buffer.duration;
|
||||||
const trackEnd = trackStart + trackDuration;
|
const clipEnd = clipStart + clipDuration;
|
||||||
|
|
||||||
if (offsetTime < trackStart) {
|
if (offsetTime < clipStart) {
|
||||||
const delay = trackStart - offsetTime;
|
const delay = clipStart - offsetTime;
|
||||||
source.start(context.currentTime + delay, 0);
|
source.start(context.currentTime + delay, 0);
|
||||||
activeSourcesRef.current.push(source);
|
activeSourcesRef.current.push(source);
|
||||||
} else if (offsetTime < trackEnd) {
|
} else if (offsetTime < clipEnd) {
|
||||||
const playOffset = offsetTime - trackStart;
|
const playOffset = offsetTime - clipStart;
|
||||||
source.start(context.currentTime, playOffset);
|
source.start(context.currentTime, playOffset);
|
||||||
activeSourcesRef.current.push(source);
|
activeSourcesRef.current.push(source);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
|
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
|
||||||
const startLocalTrackPlayback = (trackId, offsetTime) => {
|
const startLocalTrackPlayback = (trackId, offsetTime) => {
|
||||||
const context = getAudioContext();
|
const context = getAudioContext();
|
||||||
const track = tracks.find(t => t.id === trackId);
|
const track = tracks.find(t => t.id === trackId);
|
||||||
if (!track || !track.buffer) return;
|
if (!track) return;
|
||||||
|
|
||||||
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
|
id: 'default',
|
||||||
|
buffer: track.buffer,
|
||||||
|
startTime: track.startTime || 0,
|
||||||
|
name: track.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
clips.forEach(clip => {
|
||||||
|
if (!clip.buffer) return;
|
||||||
|
|
||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
source.buffer = track.buffer;
|
source.buffer = clip.buffer;
|
||||||
|
|
||||||
const gainNode = context.createGain();
|
const gainNode = context.createGain();
|
||||||
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
||||||
@@ -1387,19 +1554,20 @@
|
|||||||
source.connect(gainNode);
|
source.connect(gainNode);
|
||||||
gainNode.connect(context.destination);
|
gainNode.connect(context.destination);
|
||||||
|
|
||||||
const trackStart = track.startTime || 0;
|
const clipStart = clip.startTime || 0;
|
||||||
const trackDuration = track.buffer.duration;
|
const clipDuration = clip.buffer.duration;
|
||||||
const trackEnd = trackStart + trackDuration;
|
const clipEnd = clipStart + clipDuration;
|
||||||
|
|
||||||
if (offsetTime < trackStart) {
|
if (offsetTime < clipStart) {
|
||||||
const delay = trackStart - offsetTime;
|
const delay = clipStart - offsetTime;
|
||||||
source.start(context.currentTime + delay, 0);
|
source.start(context.currentTime + delay, 0);
|
||||||
activeSourcesRef.current.push(source);
|
activeSourcesRef.current.push(source);
|
||||||
} else if (offsetTime < trackEnd) {
|
} else if (offsetTime < clipEnd) {
|
||||||
const playOffset = offsetTime - trackStart;
|
const playOffset = offsetTime - clipStart;
|
||||||
source.start(context.currentTime, playOffset);
|
source.start(context.currentTime, playOffset);
|
||||||
activeSourcesRef.current.push(source);
|
activeSourcesRef.current.push(source);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
@@ -1534,20 +1702,31 @@
|
|||||||
draggedClipRef.current = draggedClip;
|
draggedClipRef.current = draggedClip;
|
||||||
const hoveredTrackIdRef = useRef(null);
|
const hoveredTrackIdRef = useRef(null);
|
||||||
hoveredTrackIdRef.current = hoveredTrackId;
|
hoveredTrackIdRef.current = hoveredTrackId;
|
||||||
|
const captureTrackSnapshotRef = useRef(null);
|
||||||
|
captureTrackSnapshotRef.current = captureTrackSnapshot;
|
||||||
|
|
||||||
const handleClipDragStart = (trackId, clickOffset) => {
|
const handleClipDragStart = (trackId, clipId, clickOffset) => {
|
||||||
const track = tracks.find(t => t.id === trackId);
|
const track = tracks.find(t => t.id === trackId);
|
||||||
if (!track || !track.buffer) return;
|
if (!track) return;
|
||||||
|
|
||||||
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
|
id: 'default',
|
||||||
|
buffer: track.buffer,
|
||||||
|
startTime: track.startTime || 0,
|
||||||
|
name: track.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
const clip = clips.find(c => c.id === clipId);
|
||||||
|
if (!clip) return;
|
||||||
|
|
||||||
const beforeSnap = captureTrackSnapshot(trackId);
|
const beforeSnap = captureTrackSnapshot(trackId);
|
||||||
|
|
||||||
setDraggedClip({
|
setDraggedClip({
|
||||||
trackId: trackId,
|
trackId: trackId,
|
||||||
|
clipId: clipId,
|
||||||
clickOffset: clickOffset,
|
clickOffset: clickOffset,
|
||||||
buffer: track.buffer,
|
buffer: clip.buffer,
|
||||||
name: track.name,
|
name: clip.name,
|
||||||
volume: track.volume,
|
|
||||||
color: track.color,
|
|
||||||
beforeSnap: beforeSnap
|
beforeSnap: beforeSnap
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1564,22 +1743,50 @@
|
|||||||
const scrollLeft = wrapper.scrollLeft;
|
const scrollLeft = wrapper.scrollLeft;
|
||||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||||
const time = mouseX / zoom;
|
const time = mouseX / zoom;
|
||||||
const newStart = Math.max(0, time - drag.clickOffset);
|
const rawStart = Math.max(0, time - drag.clickOffset);
|
||||||
|
const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current);
|
||||||
|
|
||||||
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
|
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
|
||||||
|
|
||||||
setTracks(prev => prev.map(t => {
|
setTracks(prev => prev.map(t => {
|
||||||
|
// Clear the clip from its previous track if it moved to a new track
|
||||||
if (t.id === drag.trackId && drag.trackId !== targetTrackId) {
|
if (t.id === drag.trackId && drag.trackId !== targetTrackId) {
|
||||||
return { ...t, buffer: null, startTime: 0 };
|
const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId);
|
||||||
}
|
|
||||||
if (t.id === targetTrackId) {
|
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
|
clips: updatedClips,
|
||||||
|
buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null,
|
||||||
|
startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0,
|
||||||
|
name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Update/set clip on target track
|
||||||
|
if (t.id === targetTrackId) {
|
||||||
|
const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
|
||||||
|
id: 'default_' + t.id,
|
||||||
|
buffer: t.buffer,
|
||||||
|
startTime: t.startTime || 0,
|
||||||
|
name: t.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
const hasClip = existingClips.some(c => c.id === drag.clipId);
|
||||||
|
let updatedClips;
|
||||||
|
if (hasClip) {
|
||||||
|
updatedClips = existingClips.map(c => c.id === drag.clipId ? { ...c, startTime: newStart } : c);
|
||||||
|
} else {
|
||||||
|
updatedClips = [...existingClips, {
|
||||||
|
id: drag.clipId,
|
||||||
buffer: drag.buffer,
|
buffer: drag.buffer,
|
||||||
startTime: newStart,
|
startTime: newStart,
|
||||||
name: drag.name,
|
name: drag.name
|
||||||
volume: drag.volume,
|
}];
|
||||||
color: drag.color
|
}
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
clips: updatedClips,
|
||||||
|
buffer: updatedClips[0].buffer,
|
||||||
|
startTime: updatedClips[0].startTime,
|
||||||
|
name: updatedClips[0].name
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
@@ -1594,7 +1801,7 @@
|
|||||||
const drag = draggedClipRef.current;
|
const drag = draggedClipRef.current;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
|
|
||||||
const afterSnap = captureTrackSnapshot(drag.trackId);
|
const afterSnap = captureTrackSnapshotRef.current(drag.trackId);
|
||||||
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
|
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
|
||||||
|
|
||||||
setDraggedClip(null);
|
setDraggedClip(null);
|
||||||
@@ -1863,6 +2070,7 @@
|
|||||||
}]);
|
}]);
|
||||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||||
setTimeout(() => lucide.createIcons(), 200);
|
setTimeout(() => lucide.createIcons(), 200);
|
||||||
|
return newId;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Server-side Export ──
|
// ── Server-side Export ──
|
||||||
@@ -2226,22 +2434,35 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Split Track at Playhead ──
|
// ── Split Track at Playhead ──
|
||||||
const handleSplitTrack = (trackId) => {
|
const handleSplitTrackAtTime = (trackId, clipId, time) => {
|
||||||
const track = tracks.find(t => t.id === trackId);
|
const track = tracks.find(t => t.id === trackId);
|
||||||
if (!track || !track.buffer) return;
|
if (!track) return;
|
||||||
|
|
||||||
const trackStart = track.startTime || 0;
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
const relCurrentTime = Math.max(0, currentTime - trackStart);
|
id: 'default',
|
||||||
const cutTime = findZeroCrossing(track.buffer, relCurrentTime);
|
buffer: track.buffer,
|
||||||
const sr = track.buffer.sampleRate;
|
startTime: track.startTime || 0,
|
||||||
const cutSample = Math.floor(cutTime * sr);
|
name: track.name
|
||||||
const originalData = track.buffer.getChannelData(0);
|
}] : []);
|
||||||
|
|
||||||
|
const targetClipId = clipId || (clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id);
|
||||||
|
if (!targetClipId) return;
|
||||||
|
|
||||||
|
const clip = clips.find(c => c.id === targetClipId);
|
||||||
|
if (!clip || !clip.buffer) return;
|
||||||
|
|
||||||
|
const relTime = Math.max(0, time - clip.startTime);
|
||||||
|
const sr = clip.buffer.sampleRate;
|
||||||
|
const cutSample = Math.floor(relTime * sr);
|
||||||
|
const originalData = clip.buffer.getChannelData(0);
|
||||||
|
|
||||||
if (cutSample <= 0 || cutSample >= originalData.length) {
|
if (cutSample <= 0 || cutSample >= originalData.length) {
|
||||||
showToast("Vị trí Playhead nằm ngoài dải biên tập.", "warning");
|
showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beforeSnap = captureTrackSnapshot(trackId);
|
||||||
|
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const b1 = ctx.createBuffer(1, cutSample, sr);
|
const b1 = ctx.createBuffer(1, cutSample, sr);
|
||||||
b1.copyToChannel(originalData.subarray(0, cutSample), 0);
|
b1.copyToChannel(originalData.subarray(0, cutSample), 0);
|
||||||
@@ -2249,24 +2470,130 @@
|
|||||||
const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr);
|
const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr);
|
||||||
b2.copyToChannel(originalData.subarray(cutSample), 0);
|
b2.copyToChannel(originalData.subarray(cutSample), 0);
|
||||||
|
|
||||||
setTracks(prev => {
|
const clip1 = {
|
||||||
const idx = prev.findIndex(t => t.id === trackId);
|
id: 'clip_' + Date.now() + '_p1',
|
||||||
const updated = [...prev];
|
name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`,
|
||||||
updated[idx] = { ...track, name: `${track.name} (Part 1)`, buffer: b1 };
|
buffer: b1,
|
||||||
|
startTime: clip.startTime
|
||||||
const newTrack = {
|
|
||||||
...track,
|
|
||||||
id: 'track_split_' + Date.now(),
|
|
||||||
name: `${track.name} (Part 2)`,
|
|
||||||
buffer: b2,
|
|
||||||
startTime: trackStart + (cutSample / sr),
|
|
||||||
markers: [],
|
|
||||||
serverFileId: null,
|
|
||||||
};
|
};
|
||||||
updated.splice(idx + 1, 0, newTrack);
|
|
||||||
return updated;
|
const clip2 = {
|
||||||
|
id: 'clip_' + Date.now() + '_p2',
|
||||||
|
name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`,
|
||||||
|
buffer: b2,
|
||||||
|
startTime: clip.startTime + (cutSample / sr)
|
||||||
|
};
|
||||||
|
|
||||||
|
setTracks(prev => prev.map(t => {
|
||||||
|
if (t.id === trackId) {
|
||||||
|
const remainingClips = clips.filter(c => c.id !== targetClipId);
|
||||||
|
const updatedClips = [...remainingClips, clip1, clip2];
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
clips: updatedClips,
|
||||||
|
buffer: updatedClips[0]?.buffer || null,
|
||||||
|
startTime: updatedClips[0]?.startTime || 0,
|
||||||
|
name: updatedClips[0]?.name || t.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const afterSnap = captureTrackSnapshot(trackId);
|
||||||
|
pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSplitTrack = (trackId) => {
|
||||||
|
handleSplitTrackAtTime(trackId, null, currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Glue (Merge) Clips on Selected Track ──
|
||||||
|
const handleGlueTracks = () => {
|
||||||
|
const track = tracks.find(t => t.id === selectedTrackId);
|
||||||
|
if (!track) {
|
||||||
|
showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
|
||||||
|
id: 'default',
|
||||||
|
buffer: track.buffer,
|
||||||
|
startTime: track.startTime || 0,
|
||||||
|
name: track.name
|
||||||
|
}] : []);
|
||||||
|
|
||||||
|
if (clips.length < 2) {
|
||||||
|
showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeSnap = captureTrackSnapshot(track.id);
|
||||||
|
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const sr = clips[0].buffer.sampleRate;
|
||||||
|
|
||||||
|
let minStart = Infinity;
|
||||||
|
let maxEnd = -Infinity;
|
||||||
|
clips.forEach(c => {
|
||||||
|
const start = c.startTime || 0;
|
||||||
|
const end = start + c.buffer.duration;
|
||||||
|
minStart = Math.min(minStart, start);
|
||||||
|
maxEnd = Math.max(maxEnd, end);
|
||||||
});
|
});
|
||||||
showToast("Đã chia nhỏ track tại vị trí Playhead.", "info");
|
|
||||||
|
const newDur = maxEnd - minStart;
|
||||||
|
const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr);
|
||||||
|
const newData = newBuffer.getChannelData(0);
|
||||||
|
|
||||||
|
clips.forEach(c => {
|
||||||
|
const data = c.buffer.getChannelData(0);
|
||||||
|
const offset = Math.floor(((c.startTime || 0) - minStart) * sr);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
if (offset + i < newData.length) {
|
||||||
|
newData[offset + i] += data[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let maxPeak = 0;
|
||||||
|
for (let i = 0; i < newData.length; i++) {
|
||||||
|
const abs = Math.abs(newData[i]);
|
||||||
|
if (abs > maxPeak) maxPeak = abs;
|
||||||
|
}
|
||||||
|
if (maxPeak > 1.0) {
|
||||||
|
for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedClip = {
|
||||||
|
id: 'clip_merged_' + Date.now(),
|
||||||
|
name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`,
|
||||||
|
buffer: newBuffer,
|
||||||
|
startTime: minStart
|
||||||
|
};
|
||||||
|
|
||||||
|
setTracks(prev => prev.map(t => {
|
||||||
|
if (t.id === track.id) {
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
clips: [mergedClip],
|
||||||
|
buffer: newBuffer,
|
||||||
|
startTime: minStart,
|
||||||
|
name: mergedClip.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const afterSnap = captureTrackSnapshot(track.id);
|
||||||
|
pushAction('GLUE', track.id, beforeSnap, afterSnap);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
showToast(`Đã gộp ${clips.length} clips thành công.`, 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Save AI config to localStorage ──
|
// ── Save AI config to localStorage ──
|
||||||
@@ -2442,9 +2769,71 @@
|
|||||||
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||||
{/* TCP Left Column */}
|
{/* 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="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">
|
<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">Danh Sách Kênh</span>
|
<span className="text-[10px] font-bold text-zinc-500 uppercase shrink-0 mr-2">Kênh</span>
|
||||||
<span className="text-[9px] bg-cyan-950 text-cyan-400 px-1 rounded font-bold uppercase">Chọn Click</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col divide-y divide-[#141414]">
|
<div className="flex flex-col divide-y divide-[#141414]">
|
||||||
@@ -2580,7 +2969,7 @@
|
|||||||
{/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
|
{/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
|
||||||
<div className="h-[40px] w-full relative flex-shrink-0 border-b border-purple-500/30">
|
<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}
|
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
||||||
onPlayheadSet={setCurrentTime} />
|
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tracks.map((track) => {
|
{tracks.map((track) => {
|
||||||
@@ -2602,6 +2991,10 @@
|
|||||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onClipDragStart={handleClipDragStart}
|
onClipDragStart={handleClipDragStart}
|
||||||
|
activeTool={activeTool}
|
||||||
|
onSplitTrackAtTime={handleSplitTrackAtTime}
|
||||||
|
snapValue={snapValue}
|
||||||
|
bpm={bpm}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
localSelectionTrackId={localSelectionTrackId}
|
localSelectionTrackId={localSelectionTrackId}
|
||||||
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
||||||
@@ -2620,6 +3013,22 @@
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Drop zone to create a new track during drag */}
|
||||||
|
<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"
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (draggedClipRef.current) {
|
||||||
|
const newId = addNewTrack();
|
||||||
|
setHoveredTrackId(newId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={addNewTrack}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1 text-zinc-400">
|
||||||
|
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Kéo clip xuống đây hoặc Click để tạo Track mới
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */}
|
{/* 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 && (
|
{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"
|
<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"
|
||||||
|
|||||||
Reference in New Issue
Block a user