// 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'; import { SegmentOverlay } from './segments.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.segmentOverlay = new SegmentOverlay(); 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); }); // 2.5 Segment overlay bands (drawn below waveform) if (this.segmentOverlay.segments.length > 0) { this.segmentOverlay.draw(ctx, W, H, this.tracks.length, (t) => this.timeToX(t)); } // 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; // Block fill — use block color at low opacity for visual distinction const blockColor = cue.params?.color || '#00ffff'; if (isSelected) { ctx.fillStyle = '#0d3a3a'; } else { ctx.fillStyle = blockColor + '1a'; // ~10% opacity hex suffix } 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 block, update segment selection this.selectedBlock = null; if (this._onSelectBlock) this._onSelectBlock(null); const clickTime = this.xToTime(mx); this.segmentOverlay.handleClick(clickTime); 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(); if (this._onSelectBlock) this._onSelectBlock(this.selectedBlock); } // 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(); if (this._onSelectBlock) this._onSelectBlock(this.selectedBlock); } }); } // ── 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); } async loadSegments(audioPath) { await this.segmentOverlay.load(audioPath); } } export { TimelineCanvas };