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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -48,13 +48,33 @@
|
||||
</aside>
|
||||
|
||||
<main class="main-area">
|
||||
<div class="panel" id="timeline-panel" style="flex: 1; display: flex; align-items: center; justify-content: center;">
|
||||
<span>[ TIMELINE — PHASE 2+ ]</span>
|
||||
<div class="panel" id="timeline-panel" style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div class="panel-header">TIMELINE</div>
|
||||
<div class="waveform-container" style="flex: 1; position: relative; min-height: 100px;">
|
||||
<canvas id="waveform-canvas" style="width: 100%; height: 100%;"></canvas>
|
||||
<div id="playback-cursor" class="playback-cursor"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="transport-bar" id="transport-panel">
|
||||
<span>[ TRANSPORT — PHASE 2+ ]</span>
|
||||
<div class="transport-controls">
|
||||
<button id="btn-play" class="transport-btn" title="Play">▶</button>
|
||||
<button id="btn-pause" class="transport-btn" title="Pause" style="display:none">▮▮</button>
|
||||
<button id="btn-stop" class="transport-btn" title="Stop">■</button>
|
||||
</div>
|
||||
<div class="transport-time">
|
||||
<span id="time-current">0:00.0</span>
|
||||
<span class="text-dim">/</span>
|
||||
<span id="time-duration">0:00.0</span>
|
||||
</div>
|
||||
<div class="transport-seek">
|
||||
<input type="range" id="seek-bar" min="0" max="100" value="0" step="0.1" class="seek-slider">
|
||||
</div>
|
||||
<div class="transport-file">
|
||||
<input type="text" id="audio-path" placeholder="Audio file path on server..." class="audio-path-input">
|
||||
<button id="btn-load" class="transport-btn" title="Load audio">LOAD</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user