// 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 = 'no devices registered'; return; } list.innerHTML = devices.map(d => `
${escapeHtml(d.name)} ${escapeHtml(d.strip_type)} / ${d.led_count} LEDs / ${escapeHtml(d.ip)}:${d.port}
` ).join(""); } catch (err) { const list = document.getElementById("device-list"); list.innerHTML = `error loading devices`; 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); } });