Files
led2/lightsync/frontend/app.js
Claude e064954b28 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)
2026-04-07 10:15:34 +00:00

496 lines
19 KiB
JavaScript

// LightSync frontend — controller UI
// Browser <audio> handles playback; server receives position ticks and sends beat events
import { TimelineCanvas } from './timeline/timeline.js';
import { BlockInspector } from './timeline/inspector.js';
import { DeleteBlockCommand, setCurrentShowId } from './timeline/commands.js';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
// ── API helpers ──────────────────────────────────────────────────────────────
async function apiPost(path, data) {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok && res.status !== 204) throw new Error(`POST ${path}: HTTP ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(path, { method: "DELETE" });
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${path}: HTTP ${res.status}`);
}
// ── WebSocket ────────────────────────────────────────────────────────────────
class LightSyncClient {
constructor() { this.ws = null; this._timer = null; }
connect() {
this.ws = new WebSocket(WS_URL);
this.ws.onopen = () => {
document.getElementById('status-dot')?.classList.add('connected');
document.getElementById('status-text').textContent = 'CONNECTED';
};
this.ws.onclose = () => {
document.getElementById('status-dot')?.classList.remove('connected');
document.getElementById('status-text').textContent = 'OFFLINE';
this._timer = setTimeout(() => this.connect(), 2000);
};
this.ws.onerror = () => {
document.getElementById('status-dot')?.classList.add('error');
};
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);
};
}
send(msg) {
if (this.ws?.readyState === WebSocket.OPEN)
this.ws.send(JSON.stringify(msg));
}
}
const client = new LightSyncClient();
client.connect();
// ── Beat flash ───────────────────────────────────────────────────────────────
function flashBeat() {
const dot = document.getElementById('beat-indicator');
if (!dot) return;
dot.classList.add('beat-flash');
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;
function startTicks() {
if (_tickInterval) return;
_tickInterval = setInterval(() => {
const audio = document.getElementById('player');
if (audio && !audio.paused && !audio.ended)
client.send({ type: 'tick', position: audio.currentTime });
}, 100);
}
function stopTicks() {
clearInterval(_tickInterval);
_tickInterval = null;
}
// ── Audio element ────────────────────────────────────────────────────────────
function fmt(s) {
if (!isFinite(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
function wireAudio() {
const audio = document.getElementById('player');
const btn = document.getElementById('btn-play');
const seek = document.getElementById('seek-bar');
const cur = document.getElementById('time-current');
const dur = document.getElementById('time-duration');
if (!audio) return;
// Play/Pause button
btn?.addEventListener('click', () => {
if (audio.paused) audio.play();
else audio.pause();
});
audio.addEventListener('play', () => { btn.textContent = '⏸'; startTicks(); });
audio.addEventListener('pause', () => { btn.textContent = '▶'; stopTicks(); });
audio.addEventListener('ended', () => { btn.textContent = '▶'; stopTicks(); seek.value = 0; });
// Time display + seek bar sync
audio.addEventListener('timeupdate', () => {
if (!seek._dragging) {
const pct = audio.duration > 0 ? (audio.currentTime / audio.duration) * 100 : 0;
seek.value = pct;
}
cur.textContent = fmt(audio.currentTime);
client.send({ type: 'tick', position: audio.currentTime });
});
audio.addEventListener('loadedmetadata', () => {
dur.textContent = fmt(audio.duration);
document.getElementById('show-name').textContent =
audio.src.split('/').pop() || '— playing —';
});
// Seek bar drag
seek.addEventListener('mousedown', () => { seek._dragging = true; });
seek.addEventListener('input', () => {
if (audio.duration > 0)
cur.textContent = fmt((seek.value / 100) * audio.duration);
});
seek.addEventListener('mouseup', () => {
seek._dragging = false;
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';
});
}
});
}
// ── File list ────────────────────────────────────────────────────────────────
async function refreshFiles(selectPath) {
const sel = document.getElementById('audio-select');
if (!sel) return;
try {
const { files } = await (await fetch('/api/audio/files')).json();
const prev = selectPath ?? sel.value;
sel.innerHTML = '<option value="">— select file —</option>';
files.forEach(name => {
const opt = document.createElement('option');
opt.value = `/app/shows/${name}`;
opt.textContent = name;
if (opt.value === prev) opt.selected = true;
sel.appendChild(opt);
});
} catch (e) { console.error('[files]', e); }
}
refreshFiles();
// ── Upload ───────────────────────────────────────────────────────────────────
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/audio/upload', { method: 'POST', body: form });
if (!res.ok) { console.error('[upload]', await res.json()); return; }
const { path } = await res.json();
await refreshFiles(path);
// Auto-select and load
const audio = document.getElementById('player');
if (audio) { audio.src = `/shows/${file.name}`; audio.load(); }
} catch (e) { console.error('[upload]', e); }
e.target.value = '';
});
// ── Load selected ────────────────────────────────────────────────────────────
document.getElementById('btn-load')?.addEventListener('click', async () => {
const sel = document.getElementById('audio-select');
const path = sel?.value;
if (!path) return;
const filename = path.split('/').pop();
const audio = document.getElementById('player');
if (audio) { audio.src = `/shows/${filename}`; audio.load(); }
// Trigger beat analysis in background
fetch(`/api/audio/beats?path=${encodeURIComponent(path)}`).catch(() => {});
});
// ── Devices ──────────────────────────────────────────────────────────────────
async function loadDevices() {
const list = document.getElementById('device-list');
try {
const devices = await (await fetch('/api/devices')).json();
if (!devices.length) {
list.innerHTML = '<span class="text-dim">no devices</span>';
return;
}
list.innerHTML = devices.map(d => `
<div class="device-row">
<div class="device-row-info">
<span>${d.name}</span>
<span class="text-dim" style="font-size:11px">${d.ip}:${d.port} &middot; ${d.led_count} LEDs</span>
</div>
<button class="device-remove" data-id="${d.id}" title="Remove">&#215;</button>
</div>`).join('');
list.querySelectorAll('.device-remove').forEach(btn => {
btn.addEventListener('click', async () => {
await apiDelete(`/api/devices/${btn.dataset.id}`);
await loadDevices();
});
});
} catch (e) {
list.innerHTML = '<span class="text-dim">error loading devices</span>';
}
}
loadDevices();
document.getElementById('add-device-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = {
name: form.elements['name'].value.trim(),
strip_type: form.strip_type.value,
led_count: parseInt(form.led_count.value, 10),
ip: form.ip.value.trim(),
port: parseInt(form.port.value, 10),
};
try {
await apiPost('/api/devices', data);
form.reset();
await loadDevices();
} catch (e) { console.error('[device]', e); }
});
// ── Init ─────────────────────────────────────────────────────────────────────
wireAudio();
// ── Preview strip builder ────────────────────────────────────────────────────
function buildPreviewStrips(devices) {
const panel = document.getElementById('live-preview');
if (!panel) return;
panel.innerHTML = devices.map(d => `
<div class="device-strip" data-device-id="${d.id}">
<span class="strip-label">${d.name}</span>
<div class="strip-color"></div>
<span class="strip-anim">\u2014</span>
</div>
`).join('');
}
// ── 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);
// Inspector
const inspectorEl = document.getElementById('block-inspector');
const inspector = new BlockInspector(inspectorEl, timeline.history);
inspector.tracks = timeline.tracks;
// Wire timeline selection → inspector
timeline._onSelectBlock = (block) => {
inspector.show(block);
};
// Wire inspector delete → timeline
inspector._onDelete = (block) => {
const cmd = new DeleteBlockCommand(timeline.tracks, block);
timeline.history.execute(cmd);
timeline.selectedBlock = null;
inspector.hide();
};
// Load device tracks
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
audio.addEventListener('loadedmetadata', () => {
const src = audio.src;
const filename = src.split('/').pop();
const path = `/app/shows/${filename}`;
timeline.audioDuration = audio.duration;
timeline.loadWaveform(path);
// Load beats and update BPM display when analysis completes
timeline.loadBeats(path).then(() => {
if (timeline.beatGrid.tempoBpm) {
const label = document.getElementById('beat-label');
if (label) label.textContent = `${Math.round(timeline.beatGrid.tempoBpm)} BPM`;
}
});
});
// 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('');
}
// Drag from palette to canvas
const canvasEl = document.getElementById('timeline-canvas');
palette?.addEventListener('dragstart', (e) => {
const tile = e.target.closest('.anim-tile');
if (!tile) return;
e.dataTransfer.setData('text/plain', tile.dataset.anim);
e.dataTransfer.effectAllowed = 'copy';
});
canvasEl?.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
// Show ghost preview for current drag position
const rect = canvasEl.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const trackIdx = timeline.yToTrackIndex(my);
const time = timeline.xToTime(mx);
timeline._dragState = {
ghost: true,
trackIdx: trackIdx,
time: Math.max(0, time),
duration: 4.0
};
});
canvasEl?.addEventListener('dragleave', () => {
timeline._dragState = null;
});
canvasEl?.addEventListener('drop', (e) => {
e.preventDefault();
const animType = e.dataTransfer.getData('text/plain');
if (!animType) return;
const rect = canvasEl.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
timeline.handleDrop(animType, mx, my);
timeline._dragState = null;
});
timeline.startRenderLoop();
}
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
? '<option value="">\u2014 select show \u2014</option>'
: '<option value="">\u2014 no shows \u2014</option>';
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';
}
});