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:
@@ -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">></span>
|
||||
<span class="anim-name">${name.toUpperCase().replace('_', ' ')}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
timeline.startRenderLoop();
|
||||
}
|
||||
|
||||
initTimeline();
|
||||
|
||||
Reference in New Issue
Block a user