- Create beats.js with BeatGrid class: load(), getCalibratedBeats(), getCalibratedOnsets(), snap(), hasBeats getter - BeatGrid manages calibrationOffset and snapEnabled state - snap() uses 100ms threshold against calibrated beat positions - Handles API field variants: beats/beat_times, tempo/tempo_bpm - Refactor timeline.js: replace beatTimes/calibrationOffset/snapEnabled with this.beatGrid - Add getters/setters for calibrationOffset and snapEnabled (backward compat with app.js) - render() now shows DETECTING BEATS... loading indicator and error state - render() draws beat marks via getCalibratedBeats(), onset ticks via getCalibratedOnsets() - snapToBeat() delegates to beatGrid.snap() - app.js chains BPM label update after loadBeats() resolves
620 lines
24 KiB
JavaScript
620 lines
24 KiB
JavaScript
// TimelineCanvas — DAW-style canvas timeline renderer for LightSync
|
|
// Renders per-device tracks, waveform background, beat marks, cue blocks,
|
|
// and a realtime playback cursor synced to an HTML5 audio element.
|
|
|
|
import { CommandHistory } from './history.js';
|
|
import { PlaceBlockCommand, MoveBlockCommand, ResizeBlockCommand, DeleteBlockCommand, setCurrentShowId } from './commands.js';
|
|
import { BeatGrid } from './beats.js';
|
|
|
|
const PIXELS_PER_SECOND = 100; // default, adjustable via zoom
|
|
const TRACK_HEIGHT = 48;
|
|
const HEADER_WIDTH = 140;
|
|
const TIME_AXIS_HEIGHT = 24;
|
|
const EDGE = 8; // resize handle hit zone in pixels
|
|
const MIN_DURATION = 0.5; // minimum block duration in seconds
|
|
const SNAP_THRESHOLD = 0.1; // 100ms snap radius
|
|
|
|
class TimelineCanvas {
|
|
constructor(canvasEl, audioEl) {
|
|
this.canvas = canvasEl;
|
|
this.ctx = canvasEl.getContext('2d');
|
|
this.audio = audioEl;
|
|
|
|
this.pixelsPerSecond = PIXELS_PER_SECOND;
|
|
this.scrollX = 0;
|
|
this.tracks = []; // [{device_id, device_name, strip_type, cues: []}]
|
|
this.waveformPeaks = [];
|
|
this.audioDuration = 0;
|
|
this.beatGrid = new BeatGrid();
|
|
this.selectedBlock = null;
|
|
this._dragState = null;
|
|
this._onSelectBlock = null; // callback(cue) for inspector
|
|
this.history = new CommandHistory(50);
|
|
this.showId = null; // set when a show is loaded
|
|
|
|
this._resizeCanvas();
|
|
window.addEventListener('resize', () => this._resizeCanvas());
|
|
|
|
// Scroll via wheel
|
|
this.canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
this.scrollX = Math.max(0, this.scrollX + e.deltaX + e.deltaY);
|
|
}, { passive: false });
|
|
|
|
this._setupInteractions();
|
|
}
|
|
|
|
// ── Coordinate helpers ────────────────────────────────────────────────────
|
|
|
|
timeToX(t) {
|
|
return HEADER_WIDTH + t * this.pixelsPerSecond - this.scrollX;
|
|
}
|
|
|
|
xToTime(x) {
|
|
return (x - HEADER_WIDTH + this.scrollX) / this.pixelsPerSecond;
|
|
}
|
|
|
|
trackIndexToY(i) {
|
|
return TIME_AXIS_HEIGHT + i * TRACK_HEIGHT;
|
|
}
|
|
|
|
yToTrackIndex(y) {
|
|
if (y < TIME_AXIS_HEIGHT) return -1;
|
|
return Math.floor((y - TIME_AXIS_HEIGHT) / TRACK_HEIGHT);
|
|
}
|
|
|
|
// ── Canvas resize (HiDPI) ─────────────────────────────────────────────────
|
|
|
|
_resizeCanvas() {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
this.canvas.width = rect.width * dpr;
|
|
this.canvas.height = rect.height * dpr;
|
|
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
}
|
|
|
|
// ── Render loop ───────────────────────────────────────────────────────────
|
|
|
|
startRenderLoop() {
|
|
const loop = () => {
|
|
this.render();
|
|
requestAnimationFrame(loop);
|
|
};
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
render() {
|
|
const ctx = this.ctx;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const W = this.canvas.width / dpr;
|
|
const H = this.canvas.height / dpr;
|
|
|
|
// 1. Background
|
|
ctx.fillStyle = '#0a0a0a';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Empty state
|
|
if (this.tracks.length === 0) {
|
|
ctx.fillStyle = '#555555';
|
|
ctx.font = '12px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('NO DEVICES — add a device in the sidebar to create tracks', W / 2, H / 2);
|
|
ctx.textAlign = 'left';
|
|
this._drawTimeAxis(ctx, W, H);
|
|
this._drawTrackHeaders(ctx, W, H);
|
|
return;
|
|
}
|
|
|
|
if (this.audioDuration === 0) {
|
|
ctx.fillStyle = '#555555';
|
|
ctx.font = '12px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('LOAD AUDIO — use the transport bar to load a file', W / 2, H / 2);
|
|
ctx.textAlign = 'left';
|
|
}
|
|
|
|
// 2. Track row banding
|
|
this.tracks.forEach((_, i) => {
|
|
const y = this.trackIndexToY(i);
|
|
ctx.fillStyle = i % 2 === 0 ? '#0a0a0a' : '#0c0c0c';
|
|
ctx.fillRect(HEADER_WIDTH, y, W - HEADER_WIDTH, TRACK_HEIGHT);
|
|
});
|
|
|
|
// 3. Waveform background
|
|
if (this.waveformPeaks.length > 0 && this.audioDuration > 0) {
|
|
const totalH = this.tracks.length * TRACK_HEIGHT;
|
|
const midY = TIME_AXIS_HEIGHT + totalH / 2;
|
|
ctx.fillStyle = '#1a3a3a';
|
|
for (let i = 0; i < this.waveformPeaks.length; i++) {
|
|
const t = (i / this.waveformPeaks.length) * this.audioDuration;
|
|
const x = this.timeToX(t);
|
|
if (x < HEADER_WIDTH || x > W) continue;
|
|
const peak = this.waveformPeaks[i];
|
|
const h = peak * totalH * 0.4;
|
|
ctx.fillRect(x, midY - h, 1, h * 2);
|
|
}
|
|
}
|
|
|
|
// 4. Beat marks
|
|
if (this.beatGrid.hasBeats) {
|
|
const calibrated = this.beatGrid.getCalibratedBeats();
|
|
ctx.strokeStyle = 'rgba(0, 255, 255, 0.4)';
|
|
ctx.lineWidth = 1;
|
|
for (const bt of calibrated) {
|
|
const x = this.timeToX(bt);
|
|
if (x < HEADER_WIDTH || x > W) continue;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, TIME_AXIS_HEIGHT);
|
|
ctx.lineTo(x, H);
|
|
ctx.stroke();
|
|
}
|
|
// Also draw onset marks as dimmer, shorter ticks (if onset data available)
|
|
const onsets = this.beatGrid.getCalibratedOnsets();
|
|
if (onsets.length > 0) {
|
|
ctx.strokeStyle = 'rgba(0, 255, 255, 0.15)';
|
|
for (const ot of onsets) {
|
|
const x = this.timeToX(ot);
|
|
if (x < HEADER_WIDTH || x > W) continue;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, TIME_AXIS_HEIGHT);
|
|
ctx.lineTo(x, TIME_AXIS_HEIGHT + 8); // short tick at top
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
} else if (this.beatGrid.loading) {
|
|
// Loading indicator
|
|
ctx.fillStyle = '#555555';
|
|
ctx.font = '10px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('DETECTING BEATS...', HEADER_WIDTH + (W - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14);
|
|
ctx.textAlign = 'left';
|
|
} else if (this.beatGrid.error) {
|
|
ctx.fillStyle = '#555555';
|
|
ctx.font = '10px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(this.beatGrid.error, HEADER_WIDTH + (W - HEADER_WIDTH) / 2, TIME_AXIS_HEIGHT + 14);
|
|
ctx.textAlign = 'left';
|
|
}
|
|
|
|
// 5. Block rendering
|
|
this.tracks.forEach((track, trackIdx) => {
|
|
const y = this.trackIndexToY(trackIdx);
|
|
for (const cue of track.cues) {
|
|
const x = this.timeToX(cue.timestamp);
|
|
const w = (cue.duration || 4.0) * this.pixelsPerSecond;
|
|
if (x + w < HEADER_WIDTH || x > W) continue;
|
|
|
|
const isSelected = this.selectedBlock === cue;
|
|
ctx.fillStyle = isSelected ? '#0d3a3a' : '#0a2a2a';
|
|
ctx.fillRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
|
|
|
ctx.strokeStyle = isSelected ? '#00ffff' : '#1a4a4a';
|
|
ctx.lineWidth = isSelected ? 1.5 : 1;
|
|
ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
|
|
|
// Resize handle indicators (visible on selected block)
|
|
if (isSelected) {
|
|
ctx.fillStyle = '#00ffff';
|
|
ctx.fillRect(x, y + 1, EDGE, TRACK_HEIGHT - 2);
|
|
ctx.fillRect(x + w - EDGE, y + 1, EDGE, TRACK_HEIGHT - 2);
|
|
}
|
|
|
|
// Label
|
|
if (w > 20) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(x + EDGE + 2, y + 1, w - EDGE * 2 - 4, TRACK_HEIGHT - 2);
|
|
ctx.clip();
|
|
ctx.fillStyle = isSelected ? '#00ffff' : '#00cccc';
|
|
ctx.font = '10px monospace';
|
|
ctx.textAlign = 'left';
|
|
const label = (cue.animation || 'CUE').toUpperCase();
|
|
ctx.fillText(label, x + EDGE + 4, y + TRACK_HEIGHT / 2 + 4);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
});
|
|
|
|
// 6. Drag ghost
|
|
if (this._dragState && this._dragState.ghost) {
|
|
const trackIdx = this._dragState.trackIdx;
|
|
if (trackIdx >= 0 && trackIdx < this.tracks.length) {
|
|
const y = this.trackIndexToY(trackIdx);
|
|
const x = this.timeToX(this._dragState.time);
|
|
const w = (this._dragState.duration || 4.0) * this.pixelsPerSecond;
|
|
ctx.fillStyle = 'rgba(0, 255, 255, 0.15)';
|
|
ctx.fillRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
|
ctx.strokeStyle = '#00ffff';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(x, y + 1, w, TRACK_HEIGHT - 2);
|
|
}
|
|
}
|
|
|
|
// 7. Track headers (drawn over track rows)
|
|
this._drawTrackHeaders(ctx, W, H);
|
|
|
|
// 8. Time axis
|
|
this._drawTimeAxis(ctx, W, H);
|
|
|
|
// 9. Playback cursor + auto-scroll
|
|
if (!this.audio.paused) {
|
|
const cursorX = this.timeToX(this.audio.currentTime);
|
|
|
|
// Auto-scroll: if cursor approaches right edge, shift viewport
|
|
if (cursorX > W * 0.8) {
|
|
this.scrollX = HEADER_WIDTH + this.audio.currentTime * this.pixelsPerSecond - W * 0.2;
|
|
this.scrollX = Math.max(0, this.scrollX);
|
|
}
|
|
|
|
const cursorXAfterScroll = this.timeToX(this.audio.currentTime);
|
|
if (cursorXAfterScroll >= HEADER_WIDTH && cursorXAfterScroll <= W) {
|
|
ctx.strokeStyle = '#00ffff';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cursorXAfterScroll, TIME_AXIS_HEIGHT);
|
|
ctx.lineTo(cursorXAfterScroll, H);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
_drawTrackHeaders(ctx, W, H) {
|
|
// Header background
|
|
ctx.fillStyle = '#0f0f0f';
|
|
ctx.fillRect(0, TIME_AXIS_HEIGHT, HEADER_WIDTH, H - TIME_AXIS_HEIGHT);
|
|
|
|
// Separator lines + labels
|
|
ctx.strokeStyle = '#1a4a4a';
|
|
ctx.lineWidth = 1;
|
|
this.tracks.forEach((track, i) => {
|
|
const y = this.trackIndexToY(i);
|
|
|
|
// Bottom border of track row
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y + TRACK_HEIGHT);
|
|
ctx.lineTo(HEADER_WIDTH, y + TRACK_HEIGHT);
|
|
ctx.stroke();
|
|
|
|
// Device name label
|
|
ctx.fillStyle = '#cccccc';
|
|
ctx.font = '12px monospace';
|
|
ctx.textAlign = 'left';
|
|
const label = track.device_name || `Device ${i + 1}`;
|
|
// Clip to header width
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, y, HEADER_WIDTH - 4, TRACK_HEIGHT);
|
|
ctx.clip();
|
|
ctx.fillText(label, 8, y + TRACK_HEIGHT / 2 + 4);
|
|
ctx.restore();
|
|
});
|
|
|
|
// Right border of header area
|
|
ctx.beginPath();
|
|
ctx.moveTo(HEADER_WIDTH, TIME_AXIS_HEIGHT);
|
|
ctx.lineTo(HEADER_WIDTH, H);
|
|
ctx.stroke();
|
|
}
|
|
|
|
_drawTimeAxis(ctx, W, H) {
|
|
// Time axis background
|
|
ctx.fillStyle = '#080808';
|
|
ctx.fillRect(0, 0, W, TIME_AXIS_HEIGHT);
|
|
|
|
ctx.strokeStyle = '#1a4a4a';
|
|
ctx.fillStyle = '#555555';
|
|
ctx.font = '10px monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.lineWidth = 1;
|
|
|
|
const visibleStart = Math.max(0, this.xToTime(HEADER_WIDTH));
|
|
const visibleEnd = this.xToTime(W);
|
|
|
|
// Draw second marks
|
|
const firstSec = Math.ceil(visibleStart);
|
|
for (let sec = firstSec; sec <= visibleEnd; sec++) {
|
|
const x = this.timeToX(sec);
|
|
const isMajor = sec % 10 === 0;
|
|
const isMid = sec % 5 === 0;
|
|
const tickH = isMajor ? 10 : (isMid ? 7 : 4);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, TIME_AXIS_HEIGHT - tickH);
|
|
ctx.lineTo(x, TIME_AXIS_HEIGHT);
|
|
ctx.stroke();
|
|
|
|
if (isMajor || (isMid && this.pixelsPerSecond >= 40)) {
|
|
const m = Math.floor(sec / 60);
|
|
const s = sec % 60;
|
|
const label = m > 0 ? `${m}:${String(s).padStart(2, '0')}` : `${s}s`;
|
|
ctx.fillText(label, x + 2, TIME_AXIS_HEIGHT - 2);
|
|
}
|
|
}
|
|
|
|
// Bottom border
|
|
ctx.strokeStyle = '#1a4a4a';
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, TIME_AXIS_HEIGHT);
|
|
ctx.lineTo(W, TIME_AXIS_HEIGHT);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// ── Hit testing ───────────────────────────────────────────────────────────
|
|
|
|
hitTest(mouseX, mouseY) {
|
|
for (const track of this.tracks) {
|
|
const trackIdx = this.tracks.indexOf(track);
|
|
const y = this.trackIndexToY(trackIdx);
|
|
if (mouseY < y || mouseY > y + TRACK_HEIGHT) continue;
|
|
// Iterate cues in reverse (last placed = on top)
|
|
for (let i = track.cues.length - 1; i >= 0; i--) {
|
|
const cue = track.cues[i];
|
|
const x = this.timeToX(cue.timestamp);
|
|
const w = (cue.duration || 4.0) * this.pixelsPerSecond;
|
|
if (mouseX < x || mouseX > x + w) continue;
|
|
if (mouseX < x + EDGE) return { block: cue, handle: 'resize-left', trackIdx };
|
|
if (mouseX > x + w - EDGE) return { block: cue, handle: 'resize-right', trackIdx };
|
|
return { block: cue, handle: 'move', trackIdx };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Overlap check ─────────────────────────────────────────────────────────
|
|
|
|
_hasOverlap(track, block, startTime, duration) {
|
|
const end = startTime + duration;
|
|
return track.cues.some(c => {
|
|
if (c.id === block.id) return false; // skip self
|
|
const cEnd = c.timestamp + c.duration;
|
|
return startTime < cEnd && end > c.timestamp;
|
|
});
|
|
}
|
|
|
|
// ── Snap to beat ──────────────────────────────────────────────────────────
|
|
|
|
snapToBeat(t) {
|
|
return this.beatGrid.snap(t);
|
|
}
|
|
|
|
// ── Getters/setters for backward compatibility with app.js toolbar wiring ──
|
|
|
|
get snapEnabled() { return this.beatGrid.snapEnabled; }
|
|
set snapEnabled(v) { this.beatGrid.snapEnabled = v; }
|
|
get calibrationOffset() { return this.beatGrid.calibrationOffset; }
|
|
set calibrationOffset(v) { this.beatGrid.calibrationOffset = v; }
|
|
|
|
// ── Interaction setup ─────────────────────────────────────────────────────
|
|
|
|
_setupInteractions() {
|
|
const canvas = this.canvas;
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
|
|
// Ignore clicks in header or time axis area
|
|
if (mx < HEADER_WIDTH || my < TIME_AXIS_HEIGHT) return;
|
|
|
|
const hit = this.hitTest(mx, my);
|
|
|
|
if (!hit) {
|
|
// Clicked on empty canvas — deselect
|
|
this.selectedBlock = null;
|
|
if (this._onSelectBlock) this._onSelectBlock(null);
|
|
return;
|
|
}
|
|
|
|
// Select the clicked block
|
|
this.selectedBlock = hit.block;
|
|
if (this._onSelectBlock) this._onSelectBlock(hit.block);
|
|
|
|
if (hit.handle === 'move') {
|
|
this._startMove(e, hit.block, mx);
|
|
} else if (hit.handle === 'resize-left' || hit.handle === 'resize-right') {
|
|
this._startResize(e, hit.block, hit.handle, mx);
|
|
}
|
|
});
|
|
|
|
// Update cursor on hover
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
if (this._dragState) return; // during drag, document handles mousemove
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
const hit = this.hitTest(mx, my);
|
|
if (hit && (hit.handle === 'resize-left' || hit.handle === 'resize-right')) {
|
|
canvas.style.cursor = 'ew-resize';
|
|
} else if (hit && hit.handle === 'move') {
|
|
canvas.style.cursor = 'grab';
|
|
} else {
|
|
canvas.style.cursor = 'default';
|
|
}
|
|
});
|
|
|
|
// Keyboard: Delete, Ctrl+Z (undo), Ctrl+Shift+Z / Ctrl+Y (redo)
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Delete' && this.selectedBlock) {
|
|
const cmd = new DeleteBlockCommand(this.tracks, this.selectedBlock);
|
|
this.history.execute(cmd);
|
|
this.selectedBlock = null;
|
|
if (this._onSelectBlock) this._onSelectBlock(null);
|
|
}
|
|
// Undo: Ctrl+Z (not Ctrl+Shift+Z)
|
|
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
|
|
e.preventDefault();
|
|
this.history.undo();
|
|
}
|
|
// Redo: Ctrl+Shift+Z or Ctrl+Y
|
|
if ((e.ctrlKey && e.shiftKey && e.key === 'Z') || (e.ctrlKey && e.key === 'y')) {
|
|
e.preventDefault();
|
|
this.history.redo();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Move interaction ──────────────────────────────────────────────────────
|
|
|
|
_startMove(e, block, startMX) {
|
|
const oldTimestamp = block.timestamp;
|
|
const startTime = block.timestamp;
|
|
this.canvas.style.cursor = 'grabbing';
|
|
this._dragState = { type: 'move' };
|
|
|
|
const onDrag = (ev) => {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const mx = ev.clientX - rect.left;
|
|
const deltaX = mx - startMX;
|
|
const deltaTime = deltaX / this.pixelsPerSecond;
|
|
block.timestamp = Math.max(0, startTime + deltaTime);
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onDrag);
|
|
document.removeEventListener('mouseup', onUp);
|
|
this.canvas.style.cursor = 'default';
|
|
this._dragState = null;
|
|
|
|
const snapped = this.snapToBeat(Math.max(0, block.timestamp));
|
|
const track = this.tracks.find(t => t.device_id === block.device_id);
|
|
|
|
// Revert live position for command to apply cleanly
|
|
block.timestamp = oldTimestamp;
|
|
|
|
if (snapped === oldTimestamp) return;
|
|
|
|
// Check overlap at final position
|
|
if (track && this._hasOverlap(track, block, snapped, block.duration)) return;
|
|
|
|
const cmd = new MoveBlockCommand(this.tracks, block, oldTimestamp, snapped);
|
|
this.history.execute(cmd);
|
|
};
|
|
|
|
document.addEventListener('mousemove', onDrag);
|
|
document.addEventListener('mouseup', onUp, { once: true });
|
|
}
|
|
|
|
// ── Resize interaction ────────────────────────────────────────────────────
|
|
|
|
_startResize(e, block, handle, startMX) {
|
|
const oldTimestamp = block.timestamp;
|
|
const oldDuration = block.duration;
|
|
const rightEdgeTime = oldTimestamp + oldDuration; // fixed right edge for left-handle resize
|
|
const blockStartX = this.timeToX(oldTimestamp);
|
|
this._dragState = { type: 'resize' };
|
|
|
|
const onDrag = (ev) => {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const mx = ev.clientX - rect.left;
|
|
|
|
if (handle === 'resize-right') {
|
|
// Right edge moves, left stays fixed
|
|
const newRightTime = Math.max(0, this.xToTime(mx));
|
|
block.duration = Math.max(MIN_DURATION, newRightTime - oldTimestamp);
|
|
} else {
|
|
// Left edge moves, right edge stays fixed
|
|
const newLeftTime = Math.max(0, this.xToTime(mx));
|
|
const newDuration = rightEdgeTime - newLeftTime;
|
|
if (newDuration >= MIN_DURATION) {
|
|
block.timestamp = newLeftTime;
|
|
block.duration = newDuration;
|
|
}
|
|
}
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onDrag);
|
|
document.removeEventListener('mouseup', onUp);
|
|
this.canvas.style.cursor = 'default';
|
|
this._dragState = null;
|
|
|
|
let newTimestamp = block.timestamp;
|
|
let newDuration = block.duration;
|
|
|
|
// Snap left edge if resize-left
|
|
if (handle === 'resize-left') {
|
|
newTimestamp = this.snapToBeat(Math.max(0, newTimestamp));
|
|
newDuration = rightEdgeTime - newTimestamp;
|
|
}
|
|
// Enforce minimum
|
|
if (newDuration < MIN_DURATION) {
|
|
newTimestamp = oldTimestamp;
|
|
newDuration = oldDuration;
|
|
}
|
|
|
|
// Revert for command to apply cleanly
|
|
block.timestamp = oldTimestamp;
|
|
block.duration = oldDuration;
|
|
|
|
const unchanged = newTimestamp === oldTimestamp && newDuration === oldDuration;
|
|
if (unchanged) return;
|
|
|
|
const track = this.tracks.find(t => t.device_id === block.device_id);
|
|
if (track && this._hasOverlap(track, block, newTimestamp, newDuration)) return;
|
|
|
|
const cmd = new ResizeBlockCommand(
|
|
this.tracks, block, oldTimestamp, oldDuration, newTimestamp, newDuration
|
|
);
|
|
this.history.execute(cmd);
|
|
};
|
|
|
|
document.addEventListener('mousemove', onDrag);
|
|
document.addEventListener('mouseup', onUp, { once: true });
|
|
}
|
|
|
|
// ── Drop from palette ─────────────────────────────────────────────────────
|
|
|
|
handleDrop(animationType, mouseX, mouseY) {
|
|
const trackIdx = this.yToTrackIndex(mouseY);
|
|
if (trackIdx < 0 || trackIdx >= this.tracks.length) return;
|
|
const track = this.tracks[trackIdx];
|
|
const rawTime = Math.max(0, this.xToTime(mouseX));
|
|
const time = this.snapToBeat(rawTime);
|
|
const duration = 4.0; // default duration
|
|
|
|
// Reject if overlap
|
|
if (this._hasOverlap(track, { id: null }, time, duration)) return;
|
|
|
|
const block = {
|
|
id: crypto.randomUUID(),
|
|
device_id: track.device_id,
|
|
timestamp: time,
|
|
duration: duration,
|
|
animation: animationType,
|
|
params: { color: '#00ffff', speed: 1.0, direction: 'forward', length: 0.3 }
|
|
};
|
|
const cmd = new PlaceBlockCommand(this.tracks, block);
|
|
this.history.execute(cmd);
|
|
this.selectedBlock = block;
|
|
if (this._onSelectBlock) this._onSelectBlock(block);
|
|
}
|
|
|
|
// ── Data loading ──────────────────────────────────────────────────────────
|
|
|
|
loadTracks(devices) {
|
|
this.tracks = devices.map(d => ({
|
|
device_id: d.id,
|
|
device_name: d.name,
|
|
strip_type: d.strip_type,
|
|
cues: [],
|
|
}));
|
|
}
|
|
|
|
async loadWaveform(audioPath) {
|
|
try {
|
|
const res = await fetch(`/api/audio/waveform?path=${encodeURIComponent(audioPath)}&peaks=2000`);
|
|
const data = await res.json();
|
|
this.waveformPeaks = data.peaks || [];
|
|
} catch (e) {
|
|
console.warn('[waveform]', e);
|
|
}
|
|
}
|
|
|
|
async loadBeats(audioPath) {
|
|
await this.beatGrid.load(audioPath);
|
|
}
|
|
}
|
|
|
|
export { TimelineCanvas };
|