From 148f16ba5eb84f80c19f4f37a4ebaeda80633a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 13:14:05 +0000 Subject: [PATCH] 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 --- lightsync/frontend/app.js | 153 +++++++++++++++++++++++++++++++++- lightsync/frontend/index.html | 26 +++++- lightsync/frontend/style.css | 90 ++++++++++++++++++++ 3 files changed, 264 insertions(+), 5 deletions(-) diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js index 884d605..d4fe6ee 100644 --- a/lightsync/frontend/app.js +++ b/lightsync/frontend/app.js @@ -56,8 +56,12 @@ class LightSyncClient { } handleMessage(msg) { - // Phase 2 will dispatch on msg.type - console.debug("[ws]", msg); + if (msg.type === 'tick') { + updatePosition(msg.position, msg.duration, msg.paused); + audioLoaded = msg.loaded; + } else { + console.debug('[ws]', msg); + } } send(msg) { @@ -67,6 +71,86 @@ class LightSyncClient { } } +// --- 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(); @@ -115,6 +199,71 @@ function escapeHtml(str) { 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; diff --git a/lightsync/frontend/index.html b/lightsync/frontend/index.html index eb8f2e6..b5c8e41 100644 --- a/lightsync/frontend/index.html +++ b/lightsync/frontend/index.html @@ -48,13 +48,33 @@
-
- [ TIMELINE — PHASE 2+ ] +
+
TIMELINE
+
+ +
+
diff --git a/lightsync/frontend/style.css b/lightsync/frontend/style.css index c19e239..0f73b8d 100644 --- a/lightsync/frontend/style.css +++ b/lightsync/frontend/style.css @@ -247,3 +247,93 @@ button:hover { color: #ff3333; border: none; } + +/* Transport controls */ +.transport-controls { + display: flex; + gap: 4px; + align-items: center; +} + +.transport-btn { + background: var(--bg-panel); + color: var(--accent); + border: 1px solid var(--border-dim); + padding: 4px 10px; + font-family: var(--font-mono); + font-size: 0.85rem; + cursor: pointer; + min-width: 36px; + text-align: center; +} + +.transport-btn:hover { + background: var(--accent); + color: var(--bg-primary); + border-color: var(--accent); +} + +.transport-time { + font-family: var(--font-mono); + font-size: 0.85rem; + white-space: nowrap; + min-width: 120px; +} + +.transport-seek { + flex: 1; + display: flex; + align-items: center; +} + +.seek-slider { + width: 100%; + accent-color: var(--accent); + cursor: pointer; +} + +.transport-file { + display: flex; + gap: 4px; + align-items: center; +} + +.audio-path-input { + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border-dim); + padding: 4px 8px; + font-family: var(--font-mono); + font-size: 0.8rem; + width: 200px; +} + +.audio-path-input:focus { + border-color: var(--accent); + outline: none; +} + +/* Transport bar layout override */ +.transport-bar { + gap: 12px; + padding: 6px 12px; +} + +/* Waveform */ +.waveform-container { + background: var(--bg-primary); + border: none; + overflow: hidden; +} + +.playback-cursor { + position: absolute; + top: 0; + left: 0; + width: 2px; + height: 100%; + background: var(--accent); + pointer-events: none; + transition: left 0.1s linear; + z-index: 10; +}