From e064954b28a47fe14fde32e5b273b4a17ec189fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 10:15:34 +0000 Subject: [PATCH] feat(05-02): wire preview_update handler, show selector, and preview strip builder - Import setCurrentShowId from commands.js - Add preview_update WebSocket message handler that calls updatePreviewStrip - Add updatePreviewStrip() function to update device strip color and animation - Add buildPreviewStrips() function to populate #live-preview from device list - Call buildPreviewStrips() inside device fetch callback in initTimeline - Add async refreshShows() to populate show-select dropdown from /api/shows - Add btn-load-show click handler: loads show, populates tracks, sends WebSocket load msg - Clear preview strips on seek (remove active class, reset color and anim label) --- lightsync/frontend/app.js | 118 +++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js index f3dc9a8..842acdf 100644 --- a/lightsync/frontend/app.js +++ b/lightsync/frontend/app.js @@ -3,7 +3,7 @@ import { TimelineCanvas } from './timeline/timeline.js'; import { BlockInspector } from './timeline/inspector.js'; -import { DeleteBlockCommand } from './timeline/commands.js'; +import { DeleteBlockCommand, setCurrentShowId } from './timeline/commands.js'; const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`; @@ -50,6 +50,7 @@ class LightSyncClient { this.ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'beat') flashBeat(); + else if (msg.type === 'preview_update') updatePreviewStrip(msg.device_id, msg.animation, msg.color); }; } @@ -71,6 +72,25 @@ function flashBeat() { setTimeout(() => dot.classList.remove('beat-flash'), 180); } +// ── Preview strip update ───────────────────────────────────────────────────── + +function updatePreviewStrip(deviceId, animation, color) { + const strip = document.querySelector(`.device-strip[data-device-id="${deviceId}"]`); + if (!strip) return; + const colorEl = strip.querySelector('.strip-color'); + const animEl = strip.querySelector('.strip-anim'); + if (animation && color) { + const [r, g, b] = color; + colorEl.style.backgroundColor = `rgb(${r},${g},${b})`; + animEl.textContent = animation.toUpperCase().replace('_', ' '); + strip.classList.add('active'); + } else { + colorEl.style.backgroundColor = ''; // revert to CSS default (--text-dim) + animEl.textContent = '\u2014'; // em dash + strip.classList.remove('active'); + } +} + // ── 10Hz position tick loop ────────────────────────────────────────────────── let _tickInterval = null; @@ -143,6 +163,12 @@ function wireAudio() { if (audio.duration > 0) { audio.currentTime = (seek.value / 100) * audio.duration; client.send({ type: 'seek', position: audio.currentTime }); + // Clear preview strips on seek (strips re-light when next cue fires) + document.querySelectorAll('.device-strip').forEach(s => { + s.classList.remove('active'); + s.querySelector('.strip-color').style.backgroundColor = ''; + s.querySelector('.strip-anim').textContent = '\u2014'; + }); } }); } @@ -252,6 +278,20 @@ document.getElementById('add-device-form')?.addEventListener('submit', async (e) wireAudio(); +// ── Preview strip builder ──────────────────────────────────────────────────── + +function buildPreviewStrips(devices) { + const panel = document.getElementById('live-preview'); + if (!panel) return; + panel.innerHTML = devices.map(d => ` +
+ ${d.name} +
+ \u2014 +
+ `).join(''); +} + // ── Timeline ───────────────────────────────────────────────────────────────── let timeline = null; @@ -285,6 +325,7 @@ function initTimeline() { fetch('/api/devices').then(r => r.json()).then(devices => { timeline.loadTracks(devices); inspector.tracks = timeline.tracks; // keep reference in sync + buildPreviewStrips(devices); // populate preview strips }).catch(e => console.warn('[timeline]', e)); // When audio loads, fetch waveform + beats @@ -377,3 +418,78 @@ function initTimeline() { } initTimeline(); + +// ── Show Selector ─────────────────────────────────────────────────────────── + +async function refreshShows() { + const sel = document.getElementById('show-select'); + if (!sel) return; + try { + const shows = await (await fetch('/api/shows')).json(); + sel.innerHTML = shows.length + ? '' + : ''; + shows.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.name; + sel.appendChild(opt); + }); + } catch (e) { console.error('[shows]', e); } +} + +refreshShows(); + +document.getElementById('btn-load-show')?.addEventListener('click', async () => { + const sel = document.getElementById('show-select'); + const showId = sel?.value; + if (!showId) return; + + const btn = document.getElementById('btn-load-show'); + btn.textContent = '...'; + + try { + const show = await (await fetch(`/api/shows/${showId}`)).json(); + + // Populate timeline tracks with saved cues + if (timeline && show.tracks) { + for (const showTrack of show.tracks) { + const existing = timeline.tracks.find( + t => String(t.device_id) === String(showTrack.device_id) + ); + if (existing) { + existing.cues = (showTrack.cues || []).map(c => ({...c})); + } else { + console.warn('[show] track for unknown device', showTrack.device_id); + } + } + } + + // Set current show ID for auto-save + if (timeline) timeline.showId = showId; + setCurrentShowId(showId); + + // Load audio if show has audio path + if (show.audio?.path) { + const filename = show.audio.path.split('/').pop(); + const audio = document.getElementById('player'); + if (audio && filename) { + audio.src = `/shows/${filename}`; + audio.load(); + } + } + + // Tell server to load show cue list for scheduling + client.send({ type: 'load', show_id: showId, path: show.audio?.path || '' }); + + // Show preview panel + const preview = document.getElementById('live-preview'); + if (preview) preview.style.display = 'flex'; + + console.log('[show] loaded', show.name, 'with', show.tracks?.length || 0, 'tracks'); + } catch (e) { + console.error('[show] failed to load', showId, e); + } finally { + btn.textContent = 'LOAD'; + } +});