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
|
// LightSync frontend — Phase 02-03 browser-audio architecture
|
||||||
// WebSocket stub + device list loader
|
// Browser <audio> element plays audio; server does beat detection via librosa
|
||||||
// Phase 2 expands audio, transport, and WS protocol
|
// 10Hz position ticks sent to server; server sends beat notifications back
|
||||||
|
|
||||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
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}`);
|
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 {
|
class LightSyncClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
@@ -56,9 +62,8 @@ class LightSyncClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(msg) {
|
handleMessage(msg) {
|
||||||
if (msg.type === 'tick') {
|
if (msg.type === 'beat') {
|
||||||
updatePosition(msg.position, msg.duration, msg.paused);
|
flashBeatIndicator();
|
||||||
audioLoaded = msg.loaded;
|
|
||||||
} else {
|
} else {
|
||||||
console.debug('[ws]', msg);
|
console.debug('[ws]', msg);
|
||||||
}
|
}
|
||||||
@@ -71,12 +76,19 @@ class LightSyncClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Waveform rendering ---
|
// --- Beat indicator flash ---
|
||||||
let waveformPeaks = [];
|
function flashBeatIndicator() {
|
||||||
let audioDuration = 0;
|
const dot = document.getElementById('beat-indicator');
|
||||||
let audioLoaded = false;
|
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 ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
@@ -87,74 +99,230 @@ function drawWaveform(canvas, peaks) {
|
|||||||
const w = rect.width;
|
const w = rect.width;
|
||||||
const h = rect.height;
|
const h = rect.height;
|
||||||
const mid = h / 2;
|
const mid = h / 2;
|
||||||
const barWidth = w / peaks.length;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
// Draw waveform bars using --accent color
|
// Draw waveform bars using --accent color
|
||||||
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0ff';
|
if (peaks.length > 0) {
|
||||||
for (let i = 0; i < peaks.length; i++) {
|
const barWidth = w / peaks.length;
|
||||||
const amp = peaks[i] * mid * 0.9;
|
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0ff';
|
||||||
const x = i * barWidth;
|
for (let i = 0; i < peaks.length; i++) {
|
||||||
ctx.fillRect(x, mid - amp, Math.max(barWidth - 0.5, 0.5), amp * 2);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 loadWaveform() {
|
async function loadWaveformAndBeats(filePath) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/audio/waveform?peaks=2000');
|
const encodedPath = encodeURIComponent(filePath);
|
||||||
if (!res.ok) return;
|
const [waveRes, beatRes] = await Promise.allSettled([
|
||||||
const data = await res.json();
|
fetch(`/api/audio/waveform?path=${encodedPath}&peaks=2000`),
|
||||||
waveformPeaks = data.peaks;
|
fetch(`/api/audio/beats?path=${encodedPath}`),
|
||||||
const canvas = document.getElementById('waveform-canvas');
|
]);
|
||||||
if (canvas) drawWaveform(canvas, waveformPeaks);
|
|
||||||
|
if (waveRes.status === 'fulfilled' && waveRes.value.ok) {
|
||||||
|
const data = await waveRes.value.json();
|
||||||
|
waveformPeaks = data.peaks;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
console.error('[waveform]', err);
|
console.error('[waveform/beats]', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Time formatting ---
|
function redrawCanvas() {
|
||||||
function formatTime(seconds) {
|
const canvas = document.getElementById('waveform-canvas');
|
||||||
if (seconds == null || isNaN(seconds)) return '0:00.0';
|
if (canvas) {
|
||||||
const m = Math.floor(seconds / 60);
|
const audio = document.getElementById('player');
|
||||||
const s = seconds % 60;
|
const dur = (audio && isFinite(audio.duration)) ? audio.duration : audioDuration;
|
||||||
return `${m}:${s < 10 ? '0' : ''}${s.toFixed(1)}`;
|
drawWaveform(canvas, waveformPeaks, beatTimestamps, dur);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Position update from WebSocket tick ---
|
// --- 10Hz position tick loop ---
|
||||||
function updatePosition(position, duration, paused) {
|
let _tickInterval = null;
|
||||||
audioDuration = duration || 0;
|
|
||||||
|
|
||||||
document.getElementById('time-current').textContent = formatTime(position);
|
function startTickLoop() {
|
||||||
document.getElementById('time-duration').textContent = formatTime(duration);
|
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 seek bar
|
function stopTickLoop() {
|
||||||
const seekBar = document.getElementById('seek-bar');
|
if (_tickInterval) {
|
||||||
if (duration > 0 && !seekBar._dragging) {
|
clearInterval(_tickInterval);
|
||||||
seekBar.value = (position / duration) * 100;
|
_tickInterval = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update playback cursor position
|
// --- 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 cursor = document.getElementById('playback-cursor');
|
||||||
const container = document.querySelector('.waveform-container');
|
const container = document.querySelector('.waveform-container');
|
||||||
if (cursor && container && duration > 0) {
|
if (cursor && container && duration > 0) {
|
||||||
const pct = (position / duration) * 100;
|
const pct = (position / duration) * 100;
|
||||||
cursor.style.left = pct + '%';
|
cursor.style.left = pct + '%';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle play/pause button visibility
|
// --- File selector ---
|
||||||
const btnPlay = document.getElementById('btn-play');
|
async function refreshFileList(selectValue) {
|
||||||
const btnPause = document.getElementById('btn-pause');
|
const sel = document.getElementById('audio-select');
|
||||||
if (btnPlay && btnPause) {
|
if (!sel) return;
|
||||||
btnPlay.style.display = paused ? '' : 'none';
|
try {
|
||||||
btnPause.style.display = paused ? 'none' : '';
|
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();
|
// --- Upload handler ---
|
||||||
client.connect();
|
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() {
|
async function loadDevices() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/devices");
|
const res = await fetch("/api/devices");
|
||||||
@@ -197,116 +365,24 @@ function escapeHtml(str) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDevices();
|
// --- Waveform click-to-seek ---
|
||||||
|
|
||||||
// --- 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
|
|
||||||
document.querySelector('.waveform-container')?.addEventListener('click', (e) => {
|
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 rect = e.currentTarget.getBoundingClientRect();
|
||||||
const pct = (e.clientX - rect.left) / rect.width;
|
const pct = (e.clientX - rect.left) / rect.width;
|
||||||
const pos = pct * audioDuration;
|
audio.currentTime = pct * audio.duration;
|
||||||
client.send({ type: 'seek', position: pos });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize waveform on window resize
|
// --- Resize redraws ---
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', redrawCanvas);
|
||||||
if (waveformPeaks.length > 0) {
|
|
||||||
const canvas = document.getElementById('waveform-canvas');
|
// --- Init ---
|
||||||
if (canvas) drawWaveform(canvas, waveformPeaks);
|
const client = new LightSyncClient();
|
||||||
}
|
client.connect();
|
||||||
});
|
wireAudioElement();
|
||||||
|
loadDevices();
|
||||||
|
refreshFileList();
|
||||||
|
|
||||||
document.getElementById("add-device-form").addEventListener("submit", async (e) => {
|
document.getElementById("add-device-form").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<div class="status-dot" id="status-dot"></div>
|
<div class="status-dot" id="status-dot"></div>
|
||||||
<span id="status-text" class="text-dim">OFFLINE</span>
|
<span id="status-text" class="text-dim">OFFLINE</span>
|
||||||
<span id="show-name" class="text-dim">— no show loaded —</span>
|
<span id="show-name" class="text-dim">— no show loaded —</span>
|
||||||
|
<div id="beat-indicator" class="beat-indicator" title="Beat pulse"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<span class="text-dim">loading...</span>
|
<span class="text-dim">loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<details class="device-manage-section">
|
<details class="device-manage-section">
|
||||||
<summary>⚙ MANAGE DEVICES</summary>
|
<summary>⚙ MANAGE DEVICES</summary>
|
||||||
<form id="add-device-form" class="device-form">
|
<form id="add-device-form" class="device-form">
|
||||||
<input type="text" name="name" placeholder="Device name" required>
|
<input type="text" name="name" placeholder="Device name" required>
|
||||||
<select name="strip_type" required>
|
<select name="strip_type" required>
|
||||||
@@ -43,7 +44,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel" id="animations-panel" style="flex: 1; min-height: 0;">
|
<div class="panel" id="animations-panel" style="flex: 1; min-height: 0;">
|
||||||
<div class="panel-header">ANIMATIONS</div>
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -58,26 +66,16 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="transport-bar" id="transport-panel">
|
<footer class="transport-bar" id="transport-panel">
|
||||||
<div class="transport-controls">
|
<!-- Hidden native audio element — browser handles playback -->
|
||||||
<button id="btn-play" class="transport-btn" title="Play">▶</button>
|
<audio id="player" style="display:none;"></audio>
|
||||||
<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">
|
<div class="transport-file">
|
||||||
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;">
|
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;">
|
||||||
+ UPLOAD
|
+ UPLOAD
|
||||||
<input type="file" id="audio-upload" accept=".mp3,.wav,.flac,.ogg" style="display:none;">
|
<input type="file" id="audio-upload" accept=".mp3,.wav,.flac,.ogg" style="display:none;">
|
||||||
</label>
|
</label>
|
||||||
<select id="audio-select" class="audio-path-input">
|
<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>
|
</select>
|
||||||
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
|
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -337,3 +337,35 @@ button:hover {
|
|||||||
transition: left 0.1s linear;
|
transition: left 0.1s linear;
|
||||||
z-index: 10;
|
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