feat(02-03): redesign frontend — browser audio element, beat markers, animation selector
- index.html: add <audio id=player> (hidden), beat-indicator dot in header - index.html: animation selector panel with Beat Pulse/Strobe/Color Wave/Rainbow options - index.html: remove seek slider + time display (browser audio element has native controls) - index.html: upload button + file selector remain in transport bar - app.js: wire <audio> element — play/pause/seeked/timeupdate events send WS ticks - app.js: 10Hz setInterval tick loop (supplements native timeupdate ~4Hz) - app.js: WS beat message -> flashBeatIndicator() animation - app.js: loadWaveformAndBeats() draws waveform peaks + beat marker lines on canvas - app.js: loadSelectedFile() sets audio.src=/shows/filename, fetches beats+waveform - app.js: upload response caches beat data returned from server immediately - style.css: .beat-indicator with .beat-flash (yellow glow animation) - style.css: .animation-select-input for the animations panel dropdown
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// LightSync frontend — Phase 1 shell
|
||||
// WebSocket stub + device list loader
|
||||
// Phase 2 expands audio, transport, and WS protocol
|
||||
// LightSync frontend — Phase 02-03 browser-audio architecture
|
||||
// Browser <audio> element plays audio; server does beat detection via librosa
|
||||
// 10Hz position ticks sent to server; server sends beat notifications back
|
||||
|
||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||
|
||||
@@ -19,6 +19,12 @@ async function apiDelete(path) {
|
||||
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${path}: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// --- Beat markers state ---
|
||||
let beatTimestamps = []; // sorted list of beat times in seconds
|
||||
let audioDuration = 0;
|
||||
let currentFilePath = ""; // absolute server path for beat/waveform fetches
|
||||
|
||||
// --- WebSocket client ---
|
||||
class LightSyncClient {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
@@ -56,9 +62,8 @@ class LightSyncClient {
|
||||
}
|
||||
|
||||
handleMessage(msg) {
|
||||
if (msg.type === 'tick') {
|
||||
updatePosition(msg.position, msg.duration, msg.paused);
|
||||
audioLoaded = msg.loaded;
|
||||
if (msg.type === 'beat') {
|
||||
flashBeatIndicator();
|
||||
} else {
|
||||
console.debug('[ws]', msg);
|
||||
}
|
||||
@@ -71,12 +76,19 @@ class LightSyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Waveform rendering ---
|
||||
let waveformPeaks = [];
|
||||
let audioDuration = 0;
|
||||
let audioLoaded = false;
|
||||
// --- Beat indicator flash ---
|
||||
function flashBeatIndicator() {
|
||||
const dot = document.getElementById('beat-indicator');
|
||||
if (!dot) return;
|
||||
dot.classList.add('beat-flash');
|
||||
// Remove class after animation completes (200ms)
|
||||
setTimeout(() => dot.classList.remove('beat-flash'), 200);
|
||||
}
|
||||
|
||||
function drawWaveform(canvas, peaks) {
|
||||
// --- Waveform + beat marker rendering ---
|
||||
let waveformPeaks = [];
|
||||
|
||||
function drawWaveform(canvas, peaks, beats, duration) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -87,11 +99,12 @@ function drawWaveform(canvas, peaks) {
|
||||
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
|
||||
if (peaks.length > 0) {
|
||||
const barWidth = w / peaks.length;
|
||||
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0ff';
|
||||
for (let i = 0; i < peaks.length; i++) {
|
||||
const amp = peaks[i] * mid * 0.9;
|
||||
@@ -100,61 +113,216 @@ function drawWaveform(canvas, peaks) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWaveform() {
|
||||
// Draw beat markers as thin vertical lines
|
||||
if (beats.length > 0 && duration > 0) {
|
||||
ctx.strokeStyle = 'rgba(255, 200, 0, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
for (const t of beats) {
|
||||
const x = (t / duration) * w;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWaveformAndBeats(filePath) {
|
||||
try {
|
||||
const res = await fetch('/api/audio/waveform?peaks=2000');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const encodedPath = encodeURIComponent(filePath);
|
||||
const [waveRes, beatRes] = await Promise.allSettled([
|
||||
fetch(`/api/audio/waveform?path=${encodedPath}&peaks=2000`),
|
||||
fetch(`/api/audio/beats?path=${encodedPath}`),
|
||||
]);
|
||||
|
||||
if (waveRes.status === 'fulfilled' && waveRes.value.ok) {
|
||||
const data = await waveRes.value.json();
|
||||
waveformPeaks = data.peaks;
|
||||
const canvas = document.getElementById('waveform-canvas');
|
||||
if (canvas) drawWaveform(canvas, waveformPeaks);
|
||||
}
|
||||
|
||||
if (beatRes.status === 'fulfilled' && beatRes.value.ok) {
|
||||
const data = await beatRes.value.json();
|
||||
beatTimestamps = data.beats || [];
|
||||
audioDuration = document.getElementById('player')?.duration || audioDuration;
|
||||
}
|
||||
|
||||
redrawCanvas();
|
||||
} catch (err) {
|
||||
console.error('[waveform]', err);
|
||||
console.error('[waveform/beats]', 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)}`;
|
||||
function redrawCanvas() {
|
||||
const canvas = document.getElementById('waveform-canvas');
|
||||
if (canvas) {
|
||||
const audio = document.getElementById('player');
|
||||
const dur = (audio && isFinite(audio.duration)) ? audio.duration : audioDuration;
|
||||
drawWaveform(canvas, waveformPeaks, beatTimestamps, dur);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Position update from WebSocket tick ---
|
||||
function updatePosition(position, duration, paused) {
|
||||
audioDuration = duration || 0;
|
||||
// --- 10Hz position tick loop ---
|
||||
let _tickInterval = null;
|
||||
|
||||
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;
|
||||
function startTickLoop() {
|
||||
if (_tickInterval) return;
|
||||
_tickInterval = setInterval(() => {
|
||||
const audio = document.getElementById('player');
|
||||
if (audio && !audio.paused && !audio.ended) {
|
||||
client.send({ type: 'tick', position: audio.currentTime });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Update playback cursor position
|
||||
function stopTickLoop() {
|
||||
if (_tickInterval) {
|
||||
clearInterval(_tickInterval);
|
||||
_tickInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audio element event wiring ---
|
||||
function wireAudioElement() {
|
||||
const audio = document.getElementById('player');
|
||||
if (!audio) return;
|
||||
|
||||
// Also send ticks on native timeupdate (~4Hz) as a fallback bridge
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (!audio.paused) {
|
||||
client.send({ type: 'tick', position: audio.currentTime });
|
||||
updateCursor(audio.currentTime, audio.duration);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
client.send({ type: 'play', position: audio.currentTime });
|
||||
startTickLoop();
|
||||
document.getElementById('show-name').textContent = currentFilePath
|
||||
? currentFilePath.split('/').pop()
|
||||
: '— playing —';
|
||||
});
|
||||
|
||||
audio.addEventListener('pause', () => {
|
||||
client.send({ type: 'pause', position: audio.currentTime });
|
||||
stopTickLoop();
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
stopTickLoop();
|
||||
client.send({ type: 'pause', position: audio.currentTime });
|
||||
});
|
||||
|
||||
audio.addEventListener('seeked', () => {
|
||||
client.send({ type: 'seek', position: audio.currentTime });
|
||||
updateCursor(audio.currentTime, audio.duration);
|
||||
});
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
audioDuration = audio.duration;
|
||||
redrawCanvas();
|
||||
});
|
||||
}
|
||||
|
||||
function updateCursor(position, duration) {
|
||||
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' : '';
|
||||
// --- File selector ---
|
||||
async function refreshFileList(selectValue) {
|
||||
const sel = document.getElementById('audio-select');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const res = await fetch('/api/audio/files');
|
||||
if (!res.ok) return;
|
||||
const { files } = await res.json();
|
||||
const prev = selectValue ?? sel.value;
|
||||
sel.innerHTML = '<option value="">— select file —</option>';
|
||||
files.forEach(name => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `/app/shows/${name}`;
|
||||
opt.textContent = name;
|
||||
if (opt.value === prev) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[audio] file list error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new LightSyncClient();
|
||||
client.connect();
|
||||
// --- Upload handler ---
|
||||
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/audio/upload', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
console.error('[audio] upload failed:', err.detail);
|
||||
return;
|
||||
}
|
||||
const { path, beats } = await res.json();
|
||||
// Cache beat timestamps returned from upload response
|
||||
if (beats && beats.beats) {
|
||||
beatTimestamps = beats.beats;
|
||||
}
|
||||
await refreshFileList(`/shows/${path.split('/').pop()}`);
|
||||
// Auto-select and load the just-uploaded file
|
||||
const sel = document.getElementById('audio-select');
|
||||
const serverPath = `/shows/${path.split('/').pop()}`;
|
||||
if (sel) {
|
||||
// find matching option and select it
|
||||
for (const opt of sel.options) {
|
||||
if (opt.value === serverPath || opt.value === path) {
|
||||
opt.selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
loadSelectedFile(path);
|
||||
} catch (err) {
|
||||
console.error('[audio] upload error:', err);
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// Load device list on startup
|
||||
// --- Load button handler ---
|
||||
document.getElementById('btn-load')?.addEventListener('click', () => {
|
||||
const sel = document.getElementById('audio-select');
|
||||
const path = sel?.value;
|
||||
if (!path) return;
|
||||
// Convert /shows/filename to /app/shows/filename for absolute path
|
||||
const absPath = path.startsWith('/app/') ? path : `/app${path}`;
|
||||
loadSelectedFile(absPath);
|
||||
});
|
||||
|
||||
function loadSelectedFile(absPath) {
|
||||
const audio = document.getElementById('player');
|
||||
if (!audio) return;
|
||||
|
||||
// Derive the URL path from the absolute server path
|
||||
// /app/shows/song.mp3 -> /shows/song.mp3
|
||||
const urlPath = absPath.replace(/^\/app/, '');
|
||||
audio.src = urlPath;
|
||||
audio.load();
|
||||
|
||||
currentFilePath = absPath;
|
||||
document.getElementById('show-name').textContent = absPath.split('/').pop();
|
||||
|
||||
// Tell WebSocket which file is loaded for beat lookup
|
||||
client.send({ type: 'load', path: absPath });
|
||||
|
||||
// Fetch waveform + beat markers
|
||||
loadWaveformAndBeats(absPath);
|
||||
}
|
||||
|
||||
// --- Device management ---
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const res = await fetch("/api/devices");
|
||||
@@ -197,116 +365,24 @@ function escapeHtml(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 });
|
||||
});
|
||||
}
|
||||
|
||||
// Populate file selector
|
||||
async function refreshFileList(selectValue) {
|
||||
const sel = document.getElementById('audio-select');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const res = await fetch('/api/audio/files');
|
||||
if (!res.ok) return;
|
||||
const { files } = await res.json();
|
||||
const prev = selectValue ?? sel.value;
|
||||
sel.innerHTML = '<option value="">— select file —</option>';
|
||||
files.forEach(name => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `/app/shows/${name}`;
|
||||
opt.textContent = name;
|
||||
if (opt.value === prev) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[audio] file list error:', e);
|
||||
}
|
||||
}
|
||||
refreshFileList();
|
||||
|
||||
// Upload audio file
|
||||
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/audio/upload', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
console.error('[audio] upload failed:', err.detail);
|
||||
return;
|
||||
}
|
||||
const { path } = await res.json();
|
||||
await refreshFileList(path);
|
||||
setTimeout(loadWaveform, 500);
|
||||
} catch (err) {
|
||||
console.error('[audio] upload error:', err);
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// Load selected audio file
|
||||
document.getElementById('btn-load')?.addEventListener('click', async () => {
|
||||
const sel = document.getElementById('audio-select');
|
||||
const path = sel?.value;
|
||||
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;
|
||||
}
|
||||
setTimeout(loadWaveform, 500);
|
||||
} catch (err) {
|
||||
console.error('[audio] load error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Click on waveform to seek
|
||||
// --- Waveform click-to-seek ---
|
||||
document.querySelector('.waveform-container')?.addEventListener('click', (e) => {
|
||||
if (audioDuration <= 0) return;
|
||||
const audio = document.getElementById('player');
|
||||
if (!audio || !isFinite(audio.duration) || audio.duration <= 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 });
|
||||
audio.currentTime = pct * audio.duration;
|
||||
});
|
||||
|
||||
// Resize waveform on window resize
|
||||
window.addEventListener('resize', () => {
|
||||
if (waveformPeaks.length > 0) {
|
||||
const canvas = document.getElementById('waveform-canvas');
|
||||
if (canvas) drawWaveform(canvas, waveformPeaks);
|
||||
}
|
||||
});
|
||||
// --- Resize redraws ---
|
||||
window.addEventListener('resize', redrawCanvas);
|
||||
|
||||
// --- Init ---
|
||||
const client = new LightSyncClient();
|
||||
client.connect();
|
||||
wireAudioElement();
|
||||
loadDevices();
|
||||
refreshFileList();
|
||||
|
||||
document.getElementById("add-device-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="status-text" class="text-dim">OFFLINE</span>
|
||||
<span id="show-name" class="text-dim">— no show loaded —</span>
|
||||
<div id="beat-indicator" class="beat-indicator" title="Beat pulse"></div>
|
||||
</header>
|
||||
|
||||
<aside class="sidebar">
|
||||
@@ -26,7 +27,7 @@
|
||||
<span class="text-dim">loading...</span>
|
||||
</div>
|
||||
<details class="device-manage-section">
|
||||
<summary>⚙ MANAGE DEVICES</summary>
|
||||
<summary>⚙ MANAGE DEVICES</summary>
|
||||
<form id="add-device-form" class="device-form">
|
||||
<input type="text" name="name" placeholder="Device name" required>
|
||||
<select name="strip_type" required>
|
||||
@@ -43,7 +44,14 @@
|
||||
</div>
|
||||
<div class="panel" id="animations-panel" style="flex: 1; min-height: 0;">
|
||||
<div class="panel-header">ANIMATIONS</div>
|
||||
<div class="panel-content text-dim">[ PHASE 3+ ]</div>
|
||||
<div class="panel-content">
|
||||
<select id="animation-select" class="animation-select-input">
|
||||
<option value="beat_pulse">Beat Pulse</option>
|
||||
<option value="strobe">Strobe</option>
|
||||
<option value="color_wave">Color Wave</option>
|
||||
<option value="rainbow">Rainbow</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -58,26 +66,16 @@
|
||||
</main>
|
||||
|
||||
<footer class="transport-bar" id="transport-panel">
|
||||
<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>
|
||||
<!-- Hidden native audio element — browser handles playback -->
|
||||
<audio id="player" style="display:none;"></audio>
|
||||
|
||||
<div class="transport-file">
|
||||
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;">
|
||||
+ UPLOAD
|
||||
<input type="file" id="audio-upload" accept=".mp3,.wav,.flac,.ogg" style="display:none;">
|
||||
</label>
|
||||
<select id="audio-select" class="audio-path-input">
|
||||
<option value="">— no files yet, upload first —</option>
|
||||
<option value="">— no files yet, upload first —</option>
|
||||
</select>
|
||||
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
|
||||
</div>
|
||||
|
||||
@@ -337,3 +337,35 @@ button:hover {
|
||||
transition: left 0.1s linear;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Beat indicator in header */
|
||||
.beat-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.05s ease;
|
||||
}
|
||||
|
||||
.beat-indicator.beat-flash {
|
||||
background: #ffcc00;
|
||||
box-shadow: 0 0 8px #ffcc00;
|
||||
}
|
||||
|
||||
/* Animation selector */
|
||||
.animation-select-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: var(--font-size);
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.animation-select-input:focus {
|
||||
border-color: var(--border-bright);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user