// 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. const PIXELS_PER_SECOND = 100; // default, adjustable via zoom const TRACK_HEIGHT = 48; const HEADER_WIDTH = 140; const TIME_AXIS_HEIGHT = 24; 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.beatTimes = []; this.calibrationOffset = 0.03; this.snapEnabled = true; this.selectedBlock = null; this._dragState = null; this._onSelectBlock = null; // callback(cue, trackIndex) for inspector 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 }); } // ── 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.beatTimes.length > 0) { ctx.strokeStyle = 'rgba(0, 255, 255, 0.4)'; ctx.lineWidth = 1; for (const beatTime of this.beatTimes) { const displayTime = beatTime - this.calibrationOffset; const x = this.timeToX(displayTime); if (x < HEADER_WIDTH || x > W) continue; ctx.beginPath(); ctx.moveTo(x, TIME_AXIS_HEIGHT); ctx.lineTo(x, H); ctx.stroke(); } } // 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); // Label if (w > 20) { ctx.save(); ctx.beginPath(); ctx.rect(x + 2, y + 1, w - 4, TRACK_HEIGHT - 2); ctx.clip(); ctx.fillStyle = '#00ffff'; ctx.font = '10px monospace'; ctx.textAlign = 'left'; const label = (cue.animation || 'CUE').toUpperCase(); ctx.fillText(label, x + 6, y + TRACK_HEIGHT / 2 + 4); ctx.restore(); } } }); // 6. Drag ghost if (this._dragState && this._dragState.ghost) { const g = this._dragState.ghost; ctx.fillStyle = 'rgba(0, 255, 255, 0.15)'; ctx.fillRect(g.x, g.y, g.w, g.h); ctx.strokeStyle = '#00ffff'; ctx.lineWidth = 1; ctx.strokeRect(g.x, g.y, g.w, g.h); } // 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(); } // ── 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) { try { const res = await fetch(`/api/audio/beats?path=${encodeURIComponent(audioPath)}`); const data = await res.json(); // API returns 'beats' field (list of timestamps in seconds) this.beatTimes = data.beats || data.beat_times || []; } catch (e) { console.warn('[beats]', e); } } } export { TimelineCanvas };