// 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 => `