Files
led2/lightsync/frontend/app.js
Claude 148f16ba5e feat(02-03): build transport UI and waveform canvas
- Replace timeline placeholder with waveform canvas + playback cursor
- Replace transport placeholder with full transport bar controls
- Add play/pause/stop buttons, seek slider, time display, audio path input
- Add drawWaveform(), loadWaveform(), updatePosition(), formatTime() to app.js
- Dispatch msg.type=tick to updatePosition in handleMessage
- Wire transport buttons via WebSocket commands
- Add transport/waveform styles to style.css using existing CSS variables
- Deploy via ~/bin/deploy.sh
2026-04-06 13:14:05 +00:00

286 lines
9.1 KiB
JavaScript

// LightSync frontend — Phase 1 shell
// WebSocket stub + device list loader
// Phase 2 expands audio, transport, and WS protocol
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}`);
}
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 === 'tick') {
updatePosition(msg.position, msg.duration, msg.paused);
audioLoaded = msg.loaded;
} else {
console.debug('[ws]', msg);
}
}
send(msg) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
}
// --- Waveform rendering ---
let waveformPeaks = [];
let audioDuration = 0;
let audioLoaded = false;
function drawWaveform(canvas, peaks) {
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;
const barWidth = w / peaks.length;
ctx.clearRect(0, 0, w, h);
// Draw waveform bars using --accent color
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);
}
}
async function loadWaveform() {
try {
const res = await fetch('/api/audio/waveform?peaks=2000');
if (!res.ok) return;
const data = await res.json();
waveformPeaks = data.peaks;
const canvas = document.getElementById('waveform-canvas');
if (canvas) drawWaveform(canvas, waveformPeaks);
} catch (err) {
console.error('[waveform]', err);
}
}
// --- Time formatting ---
function formatTime(seconds) {
if (seconds == null || isNaN(seconds)) return '0:00.0';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s < 10 ? '0' : ''}${s.toFixed(1)}`;
}
// --- Position update from WebSocket tick ---
function updatePosition(position, duration, paused) {
audioDuration = duration || 0;
document.getElementById('time-current').textContent = formatTime(position);
document.getElementById('time-duration').textContent = formatTime(duration);
// Update seek bar
const seekBar = document.getElementById('seek-bar');
if (duration > 0 && !seekBar._dragging) {
seekBar.value = (position / duration) * 100;
}
// Update playback cursor position
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 + '%';
}
// Toggle play/pause button visibility
const btnPlay = document.getElementById('btn-play');
const btnPause = document.getElementById('btn-pause');
if (btnPlay && btnPause) {
btnPlay.style.display = paused ? '' : 'none';
btnPause.style.display = paused ? 'none' : '';
}
}
const client = new LightSyncClient();
client.connect();
// Load device list on startup
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;
}
loadDevices();
// --- Transport controls ---
document.getElementById('btn-play')?.addEventListener('click', () => {
client.send({ type: 'play' });
});
document.getElementById('btn-pause')?.addEventListener('click', () => {
client.send({ type: 'pause' });
});
document.getElementById('btn-stop')?.addEventListener('click', () => {
client.send({ type: 'pause' });
client.send({ type: 'seek', position: 0 });
});
// Seek bar interaction
const seekBar = document.getElementById('seek-bar');
if (seekBar) {
seekBar.addEventListener('mousedown', () => { seekBar._dragging = true; });
seekBar.addEventListener('mouseup', () => {
seekBar._dragging = false;
const pos = (parseFloat(seekBar.value) / 100) * audioDuration;
client.send({ type: 'seek', position: pos });
});
}
// Load audio button
document.getElementById('btn-load')?.addEventListener('click', async () => {
const pathInput = document.getElementById('audio-path');
const path = pathInput?.value.trim();
if (!path) return;
try {
const res = await fetch('/api/audio/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!res.ok) {
const err = await res.json();
console.error('[audio] load failed:', err.detail);
return;
}
// Wait briefly for mpv to initialize, then fetch waveform
setTimeout(loadWaveform, 500);
} catch (err) {
console.error('[audio] load error:', err);
}
});
// Click on waveform to seek
document.querySelector('.waveform-container')?.addEventListener('click', (e) => {
if (audioDuration <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const pos = pct * audioDuration;
client.send({ type: 'seek', position: pos });
});
// Resize waveform on window resize
window.addEventListener('resize', () => {
if (waveformPeaks.length > 0) {
const canvas = document.getElementById('waveform-canvas');
if (canvas) drawWaveform(canvas, waveformPeaks);
}
});
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);
}
});