- app.js imports BlockInspector and DeleteBlockCommand - Inspector created with #block-inspector element and timeline.history - timeline._onSelectBlock callback shows/hides inspector on block select - inspector._onDelete fires DeleteBlockCommand and hides inspector - inspector.tracks kept in sync after loadTracks() resolves - timeline.js block rendering uses blockColor + '1a' for color accent fill - undo/redo handlers re-trigger _onSelectBlock to refresh inspector values
380 lines
14 KiB
JavaScript
380 lines
14 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 } 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();
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ── 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 });
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 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} · ${d.led_count} LEDs</span>
|
|
</div>
|
|
<button class="device-remove" data-id="${d.id}" title="Remove">×</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();
|
|
|
|
// ── 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
|
|
}).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">></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();
|