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 => `
+
+ `).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';
+ }
+});