- Add AI SYNC button to timeline toolbar, panel hidden by default (D-08) - Panel: feature toggles (BEATS, ONSETS, CHROMA, RMS), GENERATE button, USE LLM toggle - GENERATE disabled until audio loaded; re-enables on loadedmetadata event - LLM toggle enabled/disabled based on /api/sync/llm-available at page load - D-07: confirmation dialog before replacing existing timeline blocks - D-11: section_range passed when a segment is selected in the overlay - Segment selection watcher (200ms interval) updates info text dynamically - Add AI sync panel styles — flat terminal aesthetic, no border-radius
796 lines
31 KiB
JavaScript
796 lines
31 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(); client.send({ type: 'play', position: audio.currentTime }); });
|
|
audio.addEventListener('pause', () => { btn.textContent = '▶'; stopTicks(); client.send({ type: 'pause', position: audio.currentTime }); });
|
|
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';
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
|
|
|
function wireKeyboard() {
|
|
const audio = document.getElementById('player');
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
// Skip shortcuts when typing in form elements
|
|
const tag = document.activeElement?.tagName?.toLowerCase();
|
|
if (['input', 'select', 'textarea'].includes(tag)) return;
|
|
// Also skip Space on focused buttons to prevent double-toggle (Pitfall 6)
|
|
if (e.code === 'Space' && tag === 'button') return;
|
|
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
if (!audio) return;
|
|
if (audio.paused) {
|
|
audio.play();
|
|
client.send({ type: 'play', position: audio.currentTime });
|
|
} else {
|
|
audio.pause();
|
|
client.send({ type: 'pause', position: audio.currentTime });
|
|
}
|
|
} else if (e.code === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
if (!audio) return;
|
|
audio.currentTime = Math.max(0, audio.currentTime - 5);
|
|
client.send({ type: 'seek', position: audio.currentTime });
|
|
// Clear preview strips on seek
|
|
document.querySelectorAll('.device-strip').forEach(s => {
|
|
s.classList.remove('active');
|
|
const colorEl = s.querySelector('.strip-color');
|
|
if (colorEl) colorEl.style.backgroundColor = '';
|
|
const animEl = s.querySelector('.strip-anim');
|
|
if (animEl) animEl.textContent = '\u2014';
|
|
});
|
|
} else if (e.code === 'ArrowRight') {
|
|
e.preventDefault();
|
|
if (!audio) return;
|
|
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 5);
|
|
client.send({ type: 'seek', position: audio.currentTime });
|
|
// Clear preview strips on seek
|
|
document.querySelectorAll('.device-strip').forEach(s => {
|
|
s.classList.remove('active');
|
|
const colorEl = s.querySelector('.strip-color');
|
|
if (colorEl) colorEl.style.backgroundColor = '';
|
|
const animEl = s.querySelector('.strip-anim');
|
|
if (animEl) animEl.textContent = '\u2014';
|
|
});
|
|
} else if (e.ctrlKey && !e.shiftKey && e.code === 'KeyZ') {
|
|
e.preventDefault();
|
|
timeline?.history.undo();
|
|
} else if ((e.ctrlKey && e.shiftKey && e.code === 'KeyZ') || (e.ctrlKey && e.code === 'KeyY')) {
|
|
e.preventDefault();
|
|
timeline?.history.redo();
|
|
} else if (e.ctrlKey && e.code === 'KeyS') {
|
|
e.preventDefault(); // Prevent browser Save dialog (Pitfall 7)
|
|
if (timeline?.showId) {
|
|
fetch(`/api/shows/${timeline.showId}`)
|
|
.then(r => r.json())
|
|
.then(show => fetch('/api/shows', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(show),
|
|
}))
|
|
.then(() => console.log('[save] show saved via Ctrl+S'))
|
|
.catch(e => console.warn('[save]', e));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 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 = '';
|
|
});
|
|
|
|
// ── YouTube fetch ────────────────────────────────────────────────────────────
|
|
|
|
async function fetchWithProgress(url, body, onProgress, onDone, onError) {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
const lines = buf.split('\n\n');
|
|
buf = lines.pop();
|
|
for (const chunk of lines) {
|
|
if (chunk.startsWith('data: ')) {
|
|
const msg = JSON.parse(chunk.slice(6));
|
|
if (msg.type === 'progress') onProgress(msg.pct);
|
|
if (msg.type === 'done') onDone(msg);
|
|
if (msg.type === 'error') onError(msg.msg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderProgressBar(pct, width = 20) {
|
|
const filled = Math.round((pct / 100) * width);
|
|
const empty = width - filled;
|
|
return `DOWNLOADING ${'█'.repeat(filled)}${'░'.repeat(empty)} ${Math.round(pct)}%`;
|
|
}
|
|
|
|
document.getElementById('btn-fetch')?.addEventListener('click', async () => {
|
|
const urlInput = document.getElementById('yt-url');
|
|
const btn = document.getElementById('btn-fetch');
|
|
const progress = document.getElementById('yt-progress');
|
|
const url = urlInput?.value.trim();
|
|
if (!url) return;
|
|
|
|
// D-02: Disable input during download
|
|
urlInput.disabled = true;
|
|
btn.disabled = true;
|
|
btn.textContent = '...';
|
|
progress.style.display = 'inline';
|
|
progress.textContent = 'DOWNLOADING ░░░░░░░░░░░░░░░░░░░░ 0%';
|
|
|
|
try {
|
|
await fetchWithProgress('/api/audio/fetch', { url },
|
|
(pct) => {
|
|
progress.textContent = renderProgressBar(pct);
|
|
},
|
|
(msg) => {
|
|
// D-01: After download, load identically to local upload
|
|
progress.textContent = 'COMPLETE';
|
|
const audio = document.getElementById('player');
|
|
if (audio && msg.filename) {
|
|
audio.src = `/shows/${msg.filename}`;
|
|
audio.load();
|
|
}
|
|
refreshFiles(msg.path);
|
|
setTimeout(() => { progress.style.display = 'none'; }, 2000);
|
|
},
|
|
(errMsg) => {
|
|
progress.textContent = `ERROR: ${errMsg}`;
|
|
progress.style.color = '#ff4444';
|
|
setTimeout(() => {
|
|
progress.style.display = 'none';
|
|
progress.style.color = '';
|
|
}, 5000);
|
|
}
|
|
);
|
|
} catch (e) {
|
|
progress.textContent = `ERROR: ${e.message}`;
|
|
setTimeout(() => { progress.style.display = 'none'; }, 5000);
|
|
} finally {
|
|
urlInput.disabled = false;
|
|
urlInput.value = '';
|
|
btn.disabled = false;
|
|
btn.textContent = 'FETCH';
|
|
}
|
|
});
|
|
|
|
// ── 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();
|
|
wireKeyboard(); // keyboard shortcuts (Phase 5)
|
|
document.getElementById('yt-url').disabled = false;
|
|
|
|
// ── 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 structural segments for overlay
|
|
timeline.loadSegments(path);
|
|
// Enable AI Sync generate button
|
|
const genBtn = document.getElementById('btn-ai-generate');
|
|
if (genBtn) genBtn.disabled = false;
|
|
const infoEl = document.getElementById('ai-sync-info');
|
|
if (infoEl) {
|
|
const seg = timeline.segmentOverlay?.getSelected();
|
|
infoEl.textContent = seg ? `SECTION: ${seg.label}` : 'FULL SHOW';
|
|
}
|
|
// 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();
|
|
|
|
// Update AI Sync info when segment selection changes
|
|
let _lastSegIdx = -1;
|
|
setInterval(() => {
|
|
if (!timeline?.segmentOverlay) return;
|
|
const idx = timeline.segmentOverlay.selectedIndex;
|
|
if (idx !== _lastSegIdx) {
|
|
_lastSegIdx = idx;
|
|
const infoEl = document.getElementById('ai-sync-info');
|
|
if (infoEl) {
|
|
const seg = timeline.segmentOverlay.getSelected();
|
|
infoEl.textContent = seg ? `SECTION: ${seg.label}` : 'FULL SHOW';
|
|
}
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
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';
|
|
}
|
|
});
|
|
|
|
// ── AI Sync panel ───────────────────────────────────────────────────────────
|
|
|
|
document.getElementById('btn-ai-sync-open')?.addEventListener('click', () => {
|
|
const panel = document.getElementById('ai-sync-panel');
|
|
if (!panel) return;
|
|
const visible = panel.style.display !== 'none';
|
|
panel.style.display = visible ? 'none' : 'block';
|
|
});
|
|
|
|
fetch('/api/sync/llm-available')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const checkbox = document.getElementById('ai-use-llm');
|
|
const label = document.getElementById('ai-llm-label');
|
|
const container = checkbox?.closest('.ai-sync-llm-toggle');
|
|
if (data.available) {
|
|
checkbox.disabled = false;
|
|
if (container) container.classList.remove('disabled');
|
|
} else {
|
|
checkbox.disabled = true;
|
|
if (label) label.title = 'ANTHROPIC_API_KEY not configured';
|
|
if (container) container.classList.add('disabled');
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
|
|
document.getElementById('btn-ai-generate')?.addEventListener('click', async () => {
|
|
if (!timeline) return;
|
|
|
|
const audio = document.getElementById('player');
|
|
if (!audio?.src) return;
|
|
|
|
// D-07: Confirm replacement if existing blocks
|
|
const hasBlocks = timeline.tracks.some(t => t.cues.length > 0);
|
|
if (hasBlocks) {
|
|
if (!confirm('Replace all existing blocks?')) return;
|
|
}
|
|
|
|
const btn = document.getElementById('btn-ai-generate');
|
|
const infoEl = document.getElementById('ai-sync-info');
|
|
btn.disabled = true;
|
|
btn.textContent = 'GENERATING...';
|
|
if (infoEl) infoEl.textContent = 'ANALYZING...';
|
|
|
|
try {
|
|
const filename = audio.src.split('/').pop();
|
|
const path = `/app/shows/${filename}`;
|
|
|
|
// D-11: If a section is selected, only fill that range
|
|
const selectedSeg = timeline.segmentOverlay?.getSelected();
|
|
const sectionRange = selectedSeg ? [selectedSeg.start, selectedSeg.end] : null;
|
|
|
|
// Collect device IDs from timeline tracks
|
|
const deviceIds = timeline.tracks.map(t => t.device_id);
|
|
|
|
const useLlm = document.getElementById('ai-use-llm')?.checked || false;
|
|
|
|
const res = await fetch('/api/sync/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
path: path,
|
|
device_ids: deviceIds,
|
|
use_llm: useLlm,
|
|
section_range: sectionRange,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
// Clear existing blocks (or section range only)
|
|
if (sectionRange) {
|
|
// Clear only blocks in the section range
|
|
for (const track of timeline.tracks) {
|
|
track.cues = track.cues.filter(c =>
|
|
c.timestamp + (c.duration || 0) < sectionRange[0] ||
|
|
c.timestamp >= sectionRange[1]
|
|
);
|
|
}
|
|
} else {
|
|
// Clear all blocks
|
|
for (const track of timeline.tracks) {
|
|
track.cues = [];
|
|
}
|
|
}
|
|
|
|
// Place generated blocks onto tracks
|
|
for (const block of data.blocks) {
|
|
const track = timeline.tracks.find(t => String(t.device_id) === String(block.device_id));
|
|
if (track) {
|
|
track.cues.push({
|
|
id: block.id,
|
|
timestamp: block.timestamp,
|
|
duration: block.duration,
|
|
animation: block.animation,
|
|
params: block.params,
|
|
mode: 'animation',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (infoEl) infoEl.textContent = `GENERATED ${data.count} BLOCKS (${data.mode.toUpperCase()})`;
|
|
console.log(`[ai-sync] Generated ${data.count} blocks via ${data.mode}`);
|
|
|
|
} catch (e) {
|
|
console.error('[ai-sync]', e);
|
|
if (infoEl) infoEl.textContent = `ERROR: ${e.message}`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'GENERATE';
|
|
}
|
|
});
|