Files
led2/lightsync/frontend/app.js
Claude f1941a90db feat(02-03): redesign frontend — browser audio element, beat markers, animation selector
- index.html: add <audio id=player> (hidden), beat-indicator dot in header
- index.html: animation selector panel with Beat Pulse/Strobe/Color Wave/Rainbow options
- index.html: remove seek slider + time display (browser audio element has native controls)
- index.html: upload button + file selector remain in transport bar
- app.js: wire <audio> element — play/pause/seeked/timeupdate events send WS ticks
- app.js: 10Hz setInterval tick loop (supplements native timeupdate ~4Hz)
- app.js: WS beat message -> flashBeatIndicator() animation
- app.js: loadWaveformAndBeats() draws waveform peaks + beat marker lines on canvas
- app.js: loadSelectedFile() sets audio.src=/shows/filename, fetches beats+waveform
- app.js: upload response caches beat data returned from server immediately
- style.css: .beat-indicator with .beat-flash (yellow glow animation)
- style.css: .animation-select-input for the animations panel dropdown
2026-04-06 15:38:30 +00:00

406 lines
13 KiB
JavaScript

// LightSync frontend — Phase 02-03 browser-audio architecture
// Browser <audio> element plays audio; server does beat detection via librosa
// 10Hz position ticks sent to server; server sends beat notifications back
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
async function apiPost(path, data) {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) 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}`);
}
// --- Beat markers state ---
let beatTimestamps = []; // sorted list of beat times in seconds
let audioDuration = 0;
let currentFilePath = ""; // absolute server path for beat/waveform fetches
// --- WebSocket client ---
class LightSyncClient {
constructor() {
this.ws = null;
this.reconnectDelay = 2000;
this._reconnectTimer = null;
}
connect() {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
this.ws = new WebSocket(WS_URL);
this.ws.onopen = () => {
document.getElementById("status-dot").className = "status-dot connected";
document.getElementById("status-text").textContent = "CONNECTED";
};
this.ws.onclose = () => {
document.getElementById("status-dot").className = "status-dot";
document.getElementById("status-text").textContent = "OFFLINE";
this._reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
};
this.ws.onerror = () => {
document.getElementById("status-dot").className = "status-dot error";
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
this.handleMessage(msg);
};
}
handleMessage(msg) {
if (msg.type === 'beat') {
flashBeatIndicator();
} else {
console.debug('[ws]', msg);
}
}
send(msg) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
}
// --- Beat indicator flash ---
function flashBeatIndicator() {
const dot = document.getElementById('beat-indicator');
if (!dot) return;
dot.classList.add('beat-flash');
// Remove class after animation completes (200ms)
setTimeout(() => dot.classList.remove('beat-flash'), 200);
}
// --- Waveform + beat marker rendering ---
let waveformPeaks = [];
function drawWaveform(canvas, peaks, beats, duration) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
const mid = h / 2;
ctx.clearRect(0, 0, w, h);
// Draw waveform bars using --accent color
if (peaks.length > 0) {
const barWidth = w / peaks.length;
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0ff';
for (let i = 0; i < peaks.length; i++) {
const amp = peaks[i] * mid * 0.9;
const x = i * barWidth;
ctx.fillRect(x, mid - amp, Math.max(barWidth - 0.5, 0.5), amp * 2);
}
}
// Draw beat markers as thin vertical lines
if (beats.length > 0 && duration > 0) {
ctx.strokeStyle = 'rgba(255, 200, 0, 0.6)';
ctx.lineWidth = 1;
for (const t of beats) {
const x = (t / duration) * w;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
}
}
}
async function loadWaveformAndBeats(filePath) {
try {
const encodedPath = encodeURIComponent(filePath);
const [waveRes, beatRes] = await Promise.allSettled([
fetch(`/api/audio/waveform?path=${encodedPath}&peaks=2000`),
fetch(`/api/audio/beats?path=${encodedPath}`),
]);
if (waveRes.status === 'fulfilled' && waveRes.value.ok) {
const data = await waveRes.value.json();
waveformPeaks = data.peaks;
}
if (beatRes.status === 'fulfilled' && beatRes.value.ok) {
const data = await beatRes.value.json();
beatTimestamps = data.beats || [];
audioDuration = document.getElementById('player')?.duration || audioDuration;
}
redrawCanvas();
} catch (err) {
console.error('[waveform/beats]', err);
}
}
function redrawCanvas() {
const canvas = document.getElementById('waveform-canvas');
if (canvas) {
const audio = document.getElementById('player');
const dur = (audio && isFinite(audio.duration)) ? audio.duration : audioDuration;
drawWaveform(canvas, waveformPeaks, beatTimestamps, dur);
}
}
// --- 10Hz position tick loop ---
let _tickInterval = null;
function startTickLoop() {
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 stopTickLoop() {
if (_tickInterval) {
clearInterval(_tickInterval);
_tickInterval = null;
}
}
// --- Audio element event wiring ---
function wireAudioElement() {
const audio = document.getElementById('player');
if (!audio) return;
// Also send ticks on native timeupdate (~4Hz) as a fallback bridge
audio.addEventListener('timeupdate', () => {
if (!audio.paused) {
client.send({ type: 'tick', position: audio.currentTime });
updateCursor(audio.currentTime, audio.duration);
}
});
audio.addEventListener('play', () => {
client.send({ type: 'play', position: audio.currentTime });
startTickLoop();
document.getElementById('show-name').textContent = currentFilePath
? currentFilePath.split('/').pop()
: '— playing —';
});
audio.addEventListener('pause', () => {
client.send({ type: 'pause', position: audio.currentTime });
stopTickLoop();
});
audio.addEventListener('ended', () => {
stopTickLoop();
client.send({ type: 'pause', position: audio.currentTime });
});
audio.addEventListener('seeked', () => {
client.send({ type: 'seek', position: audio.currentTime });
updateCursor(audio.currentTime, audio.duration);
});
audio.addEventListener('loadedmetadata', () => {
audioDuration = audio.duration;
redrawCanvas();
});
}
function updateCursor(position, duration) {
const cursor = document.getElementById('playback-cursor');
const container = document.querySelector('.waveform-container');
if (cursor && container && duration > 0) {
const pct = (position / duration) * 100;
cursor.style.left = pct + '%';
}
}
// --- File selector ---
async function refreshFileList(selectValue) {
const sel = document.getElementById('audio-select');
if (!sel) return;
try {
const res = await fetch('/api/audio/files');
if (!res.ok) return;
const { files } = await res.json();
const prev = selectValue ?? sel.value;
sel.innerHTML = '<option value="">&#8212; select file &#8212;</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('[audio] file list error:', e);
}
}
// --- Upload handler ---
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) {
const err = await res.json();
console.error('[audio] upload failed:', err.detail);
return;
}
const { path, beats } = await res.json();
// Cache beat timestamps returned from upload response
if (beats && beats.beats) {
beatTimestamps = beats.beats;
}
await refreshFileList(`/shows/${path.split('/').pop()}`);
// Auto-select and load the just-uploaded file
const sel = document.getElementById('audio-select');
const serverPath = `/shows/${path.split('/').pop()}`;
if (sel) {
// find matching option and select it
for (const opt of sel.options) {
if (opt.value === serverPath || opt.value === path) {
opt.selected = true;
break;
}
}
}
loadSelectedFile(path);
} catch (err) {
console.error('[audio] upload error:', err);
}
e.target.value = '';
});
// --- Load button handler ---
document.getElementById('btn-load')?.addEventListener('click', () => {
const sel = document.getElementById('audio-select');
const path = sel?.value;
if (!path) return;
// Convert /shows/filename to /app/shows/filename for absolute path
const absPath = path.startsWith('/app/') ? path : `/app${path}`;
loadSelectedFile(absPath);
});
function loadSelectedFile(absPath) {
const audio = document.getElementById('player');
if (!audio) return;
// Derive the URL path from the absolute server path
// /app/shows/song.mp3 -> /shows/song.mp3
const urlPath = absPath.replace(/^\/app/, '');
audio.src = urlPath;
audio.load();
currentFilePath = absPath;
document.getElementById('show-name').textContent = absPath.split('/').pop();
// Tell WebSocket which file is loaded for beat lookup
client.send({ type: 'load', path: absPath });
// Fetch waveform + beat markers
loadWaveformAndBeats(absPath);
}
// --- Device management ---
async function loadDevices() {
try {
const res = await fetch("/api/devices");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const devices = await res.json();
const list = document.getElementById("device-list");
if (devices.length === 0) {
list.innerHTML = '<span class="text-dim">no devices registered</span>';
return;
}
list.innerHTML = devices.map(d =>
`<div class="device-row">
<div class="device-row-info">
<span class="text-accent">${escapeHtml(d.name)}</span>
<span class="text-dim">${escapeHtml(d.strip_type)} / ${d.led_count} LEDs / ${escapeHtml(d.ip)}:${d.port}</span>
</div>
<button class="device-remove" onclick="removeDevice('${d.id}')" title="Remove device">&times;</button>
</div>`
).join("");
} catch (err) {
const list = document.getElementById("device-list");
list.innerHTML = `<span class="text-dim">error loading devices</span>`;
console.error("[devices]", err);
}
}
async function removeDevice(id) {
try {
await apiDelete(`/api/devices/${id}`);
await loadDevices();
} catch (err) {
console.error("[devices] remove failed:", err);
}
}
window.removeDevice = removeDevice;
function escapeHtml(str) {
const div = document.createElement("div");
div.appendChild(document.createTextNode(String(str)));
return div.innerHTML;
}
// --- Waveform click-to-seek ---
document.querySelector('.waveform-container')?.addEventListener('click', (e) => {
const audio = document.getElementById('player');
if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
});
// --- Resize redraws ---
window.addEventListener('resize', redrawCanvas);
// --- Init ---
const client = new LightSyncClient();
client.connect();
wireAudioElement();
loadDevices();
refreshFileList();
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();
form.port.value = "21324";
await loadDevices();
} catch (err) {
console.error("[devices] add failed:", err);
}
});