feat(04-01): TimelineCanvas class + CSS + app.js wiring

- Create lightsync/frontend/timeline/timeline.js with TimelineCanvas class
  - Per-device horizontal tracks with track headers and time axis
  - HiDPI canvas rendering with devicePixelRatio handling
  - Waveform peaks background layer, beat marks overlay, cue block rendering
  - Playback cursor synced to audio.currentTime via requestAnimationFrame
  - Auto-scroll when cursor approaches right edge
  - Mouse wheel scrolling on timeline canvas
  - loadTracks(), loadWaveform(), loadBeats() data loading methods
  - Empty state messages when no devices or audio loaded
- Add timeline CSS to style.css (toolbar, canvas, inspector strip, snap button)
- Wire timeline into app.js: import, initTimeline(), zoom/beat-offset/snap controls
- Remove old animation-grid click listener (element no longer exists in main-area)
- Populate animation palette dynamically in sidebar with 7 animation types
This commit is contained in:
Claude
2026-04-06 23:34:32 +00:00
parent 454fddc35e
commit efa612380c
3 changed files with 519 additions and 13 deletions

View File

@@ -1,6 +1,8 @@
// LightSync frontend — controller UI
// Browser <audio> handles playback; server receives position ticks and sends beat events
import { TimelineCanvas } from './timeline/timeline.js';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
// ── API helpers ──────────────────────────────────────────────────────────────
@@ -196,19 +198,6 @@ document.getElementById('btn-load')?.addEventListener('click', async () => {
fetch(`/api/audio/beats?path=${encodeURIComponent(path)}`).catch(() => {});
});
// ── Animation tiles ──────────────────────────────────────────────────────────
let selectedAnim = 'beat_pulse';
document.getElementById('animation-grid')?.addEventListener('click', (e) => {
const tile = e.target.closest('.anim-tile');
if (!tile) return;
document.querySelectorAll('.anim-tile').forEach(t => t.classList.remove('active'));
tile.classList.add('active');
selectedAnim = tile.dataset.anim;
client.send({ type: 'select_animation', animation: selectedAnim });
});
// ── Devices ──────────────────────────────────────────────────────────────────
async function loadDevices() {
@@ -260,3 +249,62 @@ document.getElementById('add-device-form')?.addEventListener('submit', async (e)
// ── Init ─────────────────────────────────────────────────────────────────────
wireAudio();
// ── Timeline ─────────────────────────────────────────────────────────────────
let timeline = null;
function initTimeline() {
const canvas = document.getElementById('timeline-canvas');
const audio = document.getElementById('player');
if (!canvas || !audio) return;
timeline = new TimelineCanvas(canvas, audio);
// Load device tracks
fetch('/api/devices').then(r => r.json()).then(devices => {
timeline.loadTracks(devices);
}).catch(e => console.warn('[timeline]', e));
// When audio loads, fetch waveform + beats
audio.addEventListener('loadedmetadata', () => {
const src = audio.src;
const filename = src.split('/').pop();
const path = `/app/shows/${filename}`;
timeline.audioDuration = audio.duration;
timeline.loadWaveform(path);
timeline.loadBeats(path);
});
// Zoom slider
document.getElementById('zoom-slider')?.addEventListener('input', (e) => {
timeline.pixelsPerSecond = parseFloat(e.target.value);
});
// Beat offset
document.getElementById('beat-offset')?.addEventListener('input', (e) => {
timeline.calibrationOffset = parseFloat(e.target.value) || 0.03;
});
// Snap toggle
document.getElementById('btn-snap')?.addEventListener('click', (e) => {
timeline.snapEnabled = !timeline.snapEnabled;
e.target.classList.toggle('active', timeline.snapEnabled);
});
// Populate animation palette in sidebar
const palette = document.getElementById('animation-palette');
if (palette) {
const anims = ['chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire', 'solid'];
palette.innerHTML = anims.map(name => `
<button class="anim-tile" data-anim="${name}" draggable="true">
<span class="anim-cursor">&gt;</span>
<span class="anim-name">${name.toUpperCase().replace('_', ' ')}</span>
</button>
`).join('');
}
timeline.startRenderLoop();
}
initTimeline();