feat(02): controller UI — vertical anim list, play/pause, browser audio, terminal aesthetic
- Replace waveform+DAW layout with animation controller - Animations as vertical list with > cursor, no icons - Transport: play/pause button, seek slider, time display - Browser <audio> handles playback, 10Hz ticks to server - Sidebar: devices only (200px) - python:3.11-slim base (replaces Alpine, fixes librosa/numba build) - libsndfile symlink kept for soundfile - Remove python-mpv, add librosa for beat detection
This commit is contained in:
11
Dockerfile
11
Dockerfile
@@ -1,13 +1,12 @@
|
|||||||
FROM python:3.11-alpine
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Audio stack: ffmpeg for MP3 waveform + librosa, libsndfile for soundfile
|
# ffmpeg for librosa audio decoding, libsndfile for soundfile
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
ffmpeg-libs \
|
libsndfile1 && \
|
||||||
libsndfile && \
|
rm -rf /var/lib/apt/lists/*
|
||||||
ln -sf /usr/lib/libsndfile.so.1 /usr/lib/libsndfile.so
|
|
||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
// LightSync frontend — Phase 02-03 browser-audio architecture
|
// LightSync frontend — controller UI
|
||||||
// Browser <audio> element plays audio; server does beat detection via librosa
|
// Browser <audio> handles playback; server receives position ticks and sends beat events
|
||||||
// 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`;
|
||||||
|
|
||||||
|
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function apiPost(path, data) {
|
async function apiPost(path, data) {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`POST ${path}: HTTP ${res.status}`);
|
if (!res.ok && res.status !== 204) throw new Error(`POST ${path}: HTTP ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,229 +20,138 @@ 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 ---
|
// ── WebSocket ────────────────────────────────────────────────────────────────
|
||||||
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._timer = null; }
|
||||||
this.ws = null;
|
|
||||||
this.reconnectDelay = 2000;
|
|
||||||
this._reconnectTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (this._reconnectTimer) {
|
|
||||||
clearTimeout(this._reconnectTimer);
|
|
||||||
this._reconnectTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws = new WebSocket(WS_URL);
|
this.ws = new WebSocket(WS_URL);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
document.getElementById("status-dot").className = "status-dot connected";
|
document.getElementById('status-dot')?.classList.add('connected');
|
||||||
document.getElementById("status-text").textContent = "CONNECTED";
|
document.getElementById('status-text').textContent = 'CONNECTED';
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
document.getElementById("status-dot").className = "status-dot";
|
document.getElementById('status-dot')?.classList.remove('connected');
|
||||||
document.getElementById("status-text").textContent = "OFFLINE";
|
document.getElementById('status-text').textContent = 'OFFLINE';
|
||||||
this._reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
|
this._timer = setTimeout(() => this.connect(), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
this.ws.onerror = () => {
|
||||||
document.getElementById("status-dot").className = "status-dot error";
|
document.getElementById('status-dot')?.classList.add('error');
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (e) => {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(e.data);
|
||||||
this.handleMessage(msg);
|
if (msg.type === 'beat') flashBeat();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(msg) {
|
|
||||||
if (msg.type === 'beat') {
|
|
||||||
flashBeatIndicator();
|
|
||||||
} else {
|
|
||||||
console.debug('[ws]', msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
send(msg) {
|
send(msg) {
|
||||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
if (this.ws?.readyState === WebSocket.OPEN)
|
||||||
this.ws.send(JSON.stringify(msg));
|
this.ws.send(JSON.stringify(msg));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Beat indicator flash ---
|
const client = new LightSyncClient();
|
||||||
function flashBeatIndicator() {
|
client.connect();
|
||||||
|
|
||||||
|
// ── Beat flash ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function flashBeat() {
|
||||||
const dot = document.getElementById('beat-indicator');
|
const dot = document.getElementById('beat-indicator');
|
||||||
if (!dot) return;
|
if (!dot) return;
|
||||||
dot.classList.add('beat-flash');
|
dot.classList.add('beat-flash');
|
||||||
// Remove class after animation completes (200ms)
|
setTimeout(() => dot.classList.remove('beat-flash'), 180);
|
||||||
setTimeout(() => dot.classList.remove('beat-flash'), 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Waveform + beat marker rendering ---
|
// ── 10Hz position tick loop ──────────────────────────────────────────────────
|
||||||
let waveformPeaks = [];
|
|
||||||
|
|
||||||
function drawWaveform(canvas, peaks, beats, duration) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
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;
|
|
||||||
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 loadWaveformAndBeats(filePath) {
|
|
||||||
try {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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/beats]', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 10Hz position tick loop ---
|
|
||||||
let _tickInterval = null;
|
let _tickInterval = null;
|
||||||
|
|
||||||
function startTickLoop() {
|
function startTicks() {
|
||||||
if (_tickInterval) return;
|
if (_tickInterval) return;
|
||||||
_tickInterval = setInterval(() => {
|
_tickInterval = setInterval(() => {
|
||||||
const audio = document.getElementById('player');
|
const audio = document.getElementById('player');
|
||||||
if (audio && !audio.paused && !audio.ended) {
|
if (audio && !audio.paused && !audio.ended)
|
||||||
client.send({ type: 'tick', position: audio.currentTime });
|
client.send({ type: 'tick', position: audio.currentTime });
|
||||||
}
|
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTickLoop() {
|
function stopTicks() {
|
||||||
if (_tickInterval) {
|
|
||||||
clearInterval(_tickInterval);
|
clearInterval(_tickInterval);
|
||||||
_tickInterval = null;
|
_tickInterval = null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audio element event wiring ---
|
// ── Audio element ────────────────────────────────────────────────────────────
|
||||||
function wireAudioElement() {
|
|
||||||
|
function fmt(s) {
|
||||||
|
if (!isFinite(s)) return '0:00';
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = Math.floor(s % 60).toString().padStart(2, '0');
|
||||||
|
return `${m}:${sec}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireAudio() {
|
||||||
const audio = document.getElementById('player');
|
const audio = document.getElementById('player');
|
||||||
|
const btn = document.getElementById('btn-play');
|
||||||
|
const seek = document.getElementById('seek-bar');
|
||||||
|
const cur = document.getElementById('time-current');
|
||||||
|
const dur = document.getElementById('time-duration');
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
|
||||||
// Also send ticks on native timeupdate (~4Hz) as a fallback bridge
|
// Play/Pause button
|
||||||
|
btn?.addEventListener('click', () => {
|
||||||
|
if (audio.paused) audio.play();
|
||||||
|
else audio.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
audio.addEventListener('play', () => { btn.textContent = '⏸'; startTicks(); });
|
||||||
|
audio.addEventListener('pause', () => { btn.textContent = '▶'; stopTicks(); });
|
||||||
|
audio.addEventListener('ended', () => { btn.textContent = '▶'; stopTicks(); seek.value = 0; });
|
||||||
|
|
||||||
|
// Time display + seek bar sync
|
||||||
audio.addEventListener('timeupdate', () => {
|
audio.addEventListener('timeupdate', () => {
|
||||||
if (!audio.paused) {
|
if (!seek._dragging) {
|
||||||
client.send({ type: 'tick', position: audio.currentTime });
|
const pct = audio.duration > 0 ? (audio.currentTime / audio.duration) * 100 : 0;
|
||||||
updateCursor(audio.currentTime, audio.duration);
|
seek.value = pct;
|
||||||
}
|
}
|
||||||
});
|
cur.textContent = fmt(audio.currentTime);
|
||||||
|
client.send({ type: 'tick', position: audio.currentTime });
|
||||||
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', () => {
|
audio.addEventListener('loadedmetadata', () => {
|
||||||
audioDuration = audio.duration;
|
dur.textContent = fmt(audio.duration);
|
||||||
redrawCanvas();
|
document.getElementById('show-name').textContent =
|
||||||
|
audio.src.split('/').pop() || '— playing —';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seek bar drag
|
||||||
|
seek.addEventListener('mousedown', () => { seek._dragging = true; });
|
||||||
|
seek.addEventListener('input', () => {
|
||||||
|
if (audio.duration > 0)
|
||||||
|
cur.textContent = fmt((seek.value / 100) * audio.duration);
|
||||||
|
});
|
||||||
|
seek.addEventListener('mouseup', () => {
|
||||||
|
seek._dragging = false;
|
||||||
|
if (audio.duration > 0) {
|
||||||
|
audio.currentTime = (seek.value / 100) * audio.duration;
|
||||||
|
client.send({ type: 'seek', position: audio.currentTime });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCursor(position, duration) {
|
// ── File list ────────────────────────────────────────────────────────────────
|
||||||
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 + '%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- File selector ---
|
async function refreshFiles(selectPath) {
|
||||||
async function refreshFileList(selectValue) {
|
|
||||||
const sel = document.getElementById('audio-select');
|
const sel = document.getElementById('audio-select');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/audio/files');
|
const { files } = await (await fetch('/api/audio/files')).json();
|
||||||
if (!res.ok) return;
|
const prev = selectPath ?? sel.value;
|
||||||
const { files } = await res.json();
|
sel.innerHTML = '<option value="">— select file —</option>';
|
||||||
const prev = selectValue ?? sel.value;
|
|
||||||
sel.innerHTML = '<option value="">— select file —</option>';
|
|
||||||
files.forEach(name => {
|
files.forEach(name => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = `/app/shows/${name}`;
|
opt.value = `/app/shows/${name}`;
|
||||||
@@ -249,12 +159,13 @@ async function refreshFileList(selectValue) {
|
|||||||
if (opt.value === prev) opt.selected = true;
|
if (opt.value === prev) opt.selected = true;
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) { console.error('[files]', e); }
|
||||||
console.error('[audio] file list error:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Upload handler ---
|
refreshFiles();
|
||||||
|
|
||||||
|
// ── Upload ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
|
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -262,133 +173,90 @@ document.getElementById('audio-upload')?.addEventListener('change', async (e) =>
|
|||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/audio/upload', { method: 'POST', body: form });
|
const res = await fetch('/api/audio/upload', { method: 'POST', body: form });
|
||||||
if (!res.ok) {
|
if (!res.ok) { console.error('[upload]', await res.json()); return; }
|
||||||
const err = await res.json();
|
const { path } = await res.json();
|
||||||
console.error('[audio] upload failed:', err.detail);
|
await refreshFiles(path);
|
||||||
return;
|
// Auto-select and load
|
||||||
}
|
const audio = document.getElementById('player');
|
||||||
const { path, beats } = await res.json();
|
if (audio) { audio.src = `/shows/${file.name}`; audio.load(); }
|
||||||
// Cache beat timestamps returned from upload response
|
} catch (e) { console.error('[upload]', e); }
|
||||||
if (beats && beats.beats) {
|
|
||||||
beatTimestamps = beats.beats;
|
|
||||||
}
|
|
||||||
// Options use /app/shows/filename as value — pass abs path to pre-select
|
|
||||||
await refreshFileList(path);
|
|
||||||
loadSelectedFile(path);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[audio] upload error:', err);
|
|
||||||
}
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Load button handler ---
|
// ── Load selected ────────────────────────────────────────────────────────────
|
||||||
document.getElementById('btn-load')?.addEventListener('click', () => {
|
|
||||||
|
document.getElementById('btn-load')?.addEventListener('click', async () => {
|
||||||
const sel = document.getElementById('audio-select');
|
const sel = document.getElementById('audio-select');
|
||||||
const path = sel?.value;
|
const path = sel?.value;
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
// Convert /shows/filename to /app/shows/filename for absolute path
|
const filename = path.split('/').pop();
|
||||||
const absPath = path.startsWith('/app/') ? path : `/app${path}`;
|
const audio = document.getElementById('player');
|
||||||
loadSelectedFile(absPath);
|
if (audio) { audio.src = `/shows/${filename}`; audio.load(); }
|
||||||
|
// Trigger beat analysis in background
|
||||||
|
fetch(`/api/audio/beats?path=${encodeURIComponent(path)}`).catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadSelectedFile(absPath) {
|
// ── Animation tiles ──────────────────────────────────────────────────────────
|
||||||
const audio = document.getElementById('player');
|
|
||||||
if (!audio) return;
|
|
||||||
|
|
||||||
// Derive the URL path from the absolute server path
|
let selectedAnim = 'beat_pulse';
|
||||||
// /app/shows/song.mp3 -> /shows/song.mp3
|
|
||||||
const urlPath = absPath.replace(/^\/app/, '');
|
|
||||||
audio.src = urlPath;
|
|
||||||
audio.load();
|
|
||||||
|
|
||||||
currentFilePath = absPath;
|
document.getElementById('animation-grid')?.addEventListener('click', (e) => {
|
||||||
document.getElementById('show-name').textContent = absPath.split('/').pop();
|
const tile = e.target.closest('.anim-tile');
|
||||||
|
if (!tile) return;
|
||||||
|
document.querySelectorAll('.anim-tile').forEach(t => t.classList.remove('active'));
|
||||||
|
tile.classList.add('active');
|
||||||
|
selectedAnim = tile.dataset.anim;
|
||||||
|
client.send({ type: 'select_animation', animation: selectedAnim });
|
||||||
|
});
|
||||||
|
|
||||||
// Tell WebSocket which file is loaded for beat lookup
|
// ── Devices ──────────────────────────────────────────────────────────────────
|
||||||
client.send({ type: 'load', path: absPath });
|
|
||||||
|
|
||||||
// Fetch waveform + beat markers
|
|
||||||
loadWaveformAndBeats(absPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Device management ---
|
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
|
const list = document.getElementById('device-list');
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/devices");
|
const devices = await (await fetch('/api/devices')).json();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!devices.length) {
|
||||||
const devices = await res.json();
|
list.innerHTML = '<span class="text-dim">no devices</span>';
|
||||||
const list = document.getElementById("device-list");
|
|
||||||
if (devices.length === 0) {
|
|
||||||
list.innerHTML = '<span class="text-dim">no devices registered</span>';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = devices.map(d =>
|
list.innerHTML = devices.map(d => `
|
||||||
`<div class="device-row">
|
<div class="device-row">
|
||||||
<div class="device-row-info">
|
<div class="device-row-info">
|
||||||
<span class="text-accent">${escapeHtml(d.name)}</span>
|
<span>${d.name}</span>
|
||||||
<span class="text-dim">${escapeHtml(d.strip_type)} / ${d.led_count} LEDs / ${escapeHtml(d.ip)}:${d.port}</span>
|
<span class="text-dim" style="font-size:11px">${d.ip}:${d.port} · ${d.led_count} LEDs</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="device-remove" onclick="removeDevice('${d.id}')" title="Remove device">×</button>
|
<button class="device-remove" data-id="${d.id}" title="Remove">×</button>
|
||||||
</div>`
|
</div>`).join('');
|
||||||
).join("");
|
list.querySelectorAll('.device-remove').forEach(btn => {
|
||||||
} catch (err) {
|
btn.addEventListener('click', async () => {
|
||||||
const list = document.getElementById("device-list");
|
await apiDelete(`/api/devices/${btn.dataset.id}`);
|
||||||
list.innerHTML = `<span class="text-dim">error loading devices</span>`;
|
|
||||||
console.error("[devices]", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeDevice(id) {
|
|
||||||
try {
|
|
||||||
await apiDelete(`/api/devices/${id}`);
|
|
||||||
await loadDevices();
|
await loadDevices();
|
||||||
} catch (err) {
|
});
|
||||||
console.error("[devices] remove failed:", err);
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<span class="text-dim">error loading devices</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.removeDevice = removeDevice;
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.appendChild(document.createTextNode(String(str)));
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Waveform click-to-seek ---
|
|
||||||
document.querySelector('.waveform-container')?.addEventListener('click', (e) => {
|
|
||||||
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;
|
|
||||||
audio.currentTime = pct * audio.duration;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Resize redraws ---
|
|
||||||
window.addEventListener('resize', redrawCanvas);
|
|
||||||
|
|
||||||
// --- Init ---
|
|
||||||
const client = new LightSyncClient();
|
|
||||||
client.connect();
|
|
||||||
wireAudioElement();
|
|
||||||
loadDevices();
|
loadDevices();
|
||||||
refreshFileList();
|
|
||||||
|
|
||||||
document.getElementById("add-device-form").addEventListener("submit", async (e) => {
|
document.getElementById('add-device-form')?.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
const data = {
|
const data = {
|
||||||
name: form.elements["name"].value.trim(),
|
name: form.elements['name'].value.trim(),
|
||||||
strip_type: form.strip_type.value,
|
strip_type: form.strip_type.value,
|
||||||
led_count: parseInt(form.led_count.value, 10),
|
led_count: parseInt(form.led_count.value, 10),
|
||||||
ip: form.ip.value.trim(),
|
ip: form.ip.value.trim(),
|
||||||
port: parseInt(form.port.value, 10),
|
port: parseInt(form.port.value, 10),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await apiPost("/api/devices", data);
|
await apiPost('/api/devices', data);
|
||||||
form.reset();
|
form.reset();
|
||||||
form.port.value = "21324";
|
|
||||||
await loadDevices();
|
await loadDevices();
|
||||||
} catch (err) {
|
} catch (e) { console.error('[device]', e); }
|
||||||
console.error("[devices] add failed:", err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
wireAudio();
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>LIGHTSYNC</title>
|
<title>LIGHTSYNC</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<!-- JetBrains Mono via Google Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -16,20 +13,23 @@
|
|||||||
<span class="header-title">■ LIGHTSYNC</span>
|
<span class="header-title">■ LIGHTSYNC</span>
|
||||||
<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 class="header-sep">|</span>
|
||||||
<div id="beat-indicator" class="beat-indicator" title="Beat pulse"></div>
|
<span id="show-name" class="text-dim">— no file loaded —</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div id="beat-indicator" class="beat-indicator" title="Beat"></div>
|
||||||
|
<span id="beat-label" class="text-dim beat-label">BEAT</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="panel" id="devices-panel" style="flex: 0 0 auto;">
|
<div class="panel" style="flex: 1; min-height: 0;">
|
||||||
<div class="panel-header">DEVICES</div>
|
<div class="panel-header">DEVICES</div>
|
||||||
<div class="panel-content" id="device-list">
|
<div class="panel-content" id="device-list">
|
||||||
<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</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="Name" required>
|
||||||
<select name="strip_type" required>
|
<select name="strip_type" required>
|
||||||
<option value="sk6812">SK6812</option>
|
<option value="sk6812">SK6812</option>
|
||||||
<option value="ws2801">WS2801</option>
|
<option value="ws2801">WS2801</option>
|
||||||
@@ -38,47 +38,71 @@
|
|||||||
<input type="number" name="led_count" placeholder="LED count" min="1" max="1000" required>
|
<input type="number" name="led_count" placeholder="LED count" min="1" max="1000" required>
|
||||||
<input type="text" name="ip" placeholder="IP address" required>
|
<input type="text" name="ip" placeholder="IP address" required>
|
||||||
<input type="number" name="port" placeholder="Port" min="1" max="65535" value="21324" required>
|
<input type="number" name="port" placeholder="Port" min="1" max="65535" value="21324" required>
|
||||||
<button type="submit">+ ADD DEVICE</button>
|
<button type="submit">+ ADD</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" id="animations-panel" style="flex: 1; min-height: 0;">
|
|
||||||
<div class="panel-header">ANIMATIONS</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>
|
</aside>
|
||||||
|
|
||||||
<main class="main-area">
|
<main class="main-area">
|
||||||
<div class="panel" id="timeline-panel" style="flex: 1; display: flex; flex-direction: column;">
|
<div class="section-header">ANIMATIONS</div>
|
||||||
<div class="panel-header">TIMELINE</div>
|
<div class="animation-list" id="animation-grid">
|
||||||
<div class="waveform-container" style="flex: 1; position: relative; min-height: 100px;">
|
<button class="anim-tile active" data-anim="beat_pulse">
|
||||||
<canvas id="waveform-canvas" style="width: 100%; height: 100%;"></canvas>
|
<span class="anim-cursor">></span>
|
||||||
<div id="playback-cursor" class="playback-cursor"></div>
|
<span class="anim-name">BEAT_PULSE</span>
|
||||||
</div>
|
<span class="anim-desc">flash on every beat</span>
|
||||||
|
</button>
|
||||||
|
<button class="anim-tile" data-anim="strobe">
|
||||||
|
<span class="anim-cursor">></span>
|
||||||
|
<span class="anim-name">STROBE</span>
|
||||||
|
<span class="anim-desc">rapid flash burst</span>
|
||||||
|
</button>
|
||||||
|
<button class="anim-tile" data-anim="color_wave">
|
||||||
|
<span class="anim-cursor">></span>
|
||||||
|
<span class="anim-name">COLOR_WAVE</span>
|
||||||
|
<span class="anim-desc">rolling color sweep</span>
|
||||||
|
</button>
|
||||||
|
<button class="anim-tile" data-anim="rainbow">
|
||||||
|
<span class="anim-cursor">></span>
|
||||||
|
<span class="anim-name">RAINBOW</span>
|
||||||
|
<span class="anim-desc">cycling hue rotation</span>
|
||||||
|
</button>
|
||||||
|
<button class="anim-tile" data-anim="fire">
|
||||||
|
<span class="anim-cursor">></span>
|
||||||
|
<span class="anim-name">FIRE</span>
|
||||||
|
<span class="anim-desc">warm flickering flames</span>
|
||||||
|
</button>
|
||||||
|
<button class="anim-tile" data-anim="chase">
|
||||||
|
<span class="anim-cursor">></span>
|
||||||
|
<span class="anim-name">CHASE</span>
|
||||||
|
<span class="anim-desc">single pixel chase</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="transport-bar" id="transport-panel">
|
<footer class="transport-bar">
|
||||||
<!-- Hidden native audio element — browser handles playback -->
|
|
||||||
<audio id="player" style="display:none;"></audio>
|
<audio id="player" style="display:none;"></audio>
|
||||||
|
|
||||||
<div class="transport-file">
|
<button id="btn-play" class="transport-btn transport-playpause" title="Play/Pause">▶</button>
|
||||||
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;">
|
|
||||||
|
<span id="time-current" class="transport-time">0:00</span>
|
||||||
|
<span class="text-dim">/</span>
|
||||||
|
<span id="time-duration" class="transport-time">0:00</span>
|
||||||
|
|
||||||
|
<input type="range" id="seek-bar" min="0" max="100" value="0" step="0.1" class="seek-slider">
|
||||||
|
|
||||||
|
<div class="transport-sep"></div>
|
||||||
|
|
||||||
|
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG" 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">
|
|
||||||
<option value="">— no files yet, upload first —</option>
|
<select id="audio-select" class="transport-file-select">
|
||||||
|
<option value="">— no files —</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>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,8 @@
|
|||||||
--text-primary: #cccccc;
|
--text-primary: #cccccc;
|
||||||
--text-dim: #555555;
|
--text-dim: #555555;
|
||||||
--text-accent: var(--accent);
|
--text-accent: var(--accent);
|
||||||
--text-warning: #ff6600;
|
|
||||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--font-size: 13px;
|
--font-size: 13px;
|
||||||
--panel-gap: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
@@ -30,75 +28,145 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* DAW layout — header / (sidebar + main) / transport */
|
/* Layout: header / (sidebar + main) / transport */
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 40px 1fr 48px;
|
grid-template-rows: 36px 1fr 52px;
|
||||||
grid-template-columns: 280px 1fr;
|
grid-template-columns: 200px 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"header header"
|
"header header"
|
||||||
"sidebar main"
|
"sidebar main"
|
||||||
"transport transport";
|
"transport transport";
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
gap: var(--panel-gap);
|
gap: 1px;
|
||||||
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────── */
|
||||||
.header {
|
.header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
background: var(--bg-panel-dark);
|
background: var(--bg-panel-dark);
|
||||||
border-bottom: 1px solid var(--border-dim);
|
border-bottom: 1px solid var(--border-dim);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 16px;
|
padding: 0 12px;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-sep {
|
||||||
|
color: var(--border-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.connected { background: #00ff88; }
|
||||||
|
.status-dot.error { background: #ff3333; }
|
||||||
|
|
||||||
|
.beat-indicator {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.05s;
|
||||||
|
}
|
||||||
|
.beat-indicator.beat-flash {
|
||||||
|
background: #ffcc00;
|
||||||
|
box-shadow: 0 0 10px #ffcc00;
|
||||||
|
}
|
||||||
|
.beat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ─────────────────────────────────── */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: var(--bg-panel);
|
||||||
border-right: 1px solid var(--border-dim);
|
border-right: 1px solid var(--border-dim);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
gap: var(--panel-gap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
background: var(--bg-panel);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
background: var(--bg-panel-dark);
|
background: var(--bg-panel-dark);
|
||||||
border-bottom: 1px solid var(--border-dim);
|
border-bottom: 1px solid var(--border-dim);
|
||||||
padding: 6px 12px;
|
padding: 5px 10px;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.14em;
|
||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 3px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-dim); }
|
||||||
|
|
||||||
|
.device-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid #111;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.device-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.device-row-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Device manage section — collapsible via <details> */
|
.device-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.device-remove:hover { color: #ff3333; border: none; }
|
||||||
|
|
||||||
.device-manage-section {
|
.device-manage-section {
|
||||||
border-top: 1px solid var(--border-dim);
|
border-top: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-manage-section > summary {
|
.device-manage-section > summary {
|
||||||
padding: 5px 12px;
|
padding: 5px 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -107,98 +175,167 @@ body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-manage-section > summary::-webkit-details-marker { display: none; }
|
.device-manage-section > summary::-webkit-details-marker { display: none; }
|
||||||
|
.device-manage-section > summary::before { content: "▶ "; font-size: 8px; }
|
||||||
|
.device-manage-section[open] > summary { color: var(--text-accent); }
|
||||||
|
.device-manage-section[open] > summary::before { content: "▼ "; }
|
||||||
|
|
||||||
.device-manage-section > summary::before {
|
.device-form {
|
||||||
content: "▶ ";
|
display: flex;
|
||||||
font-size: 8px;
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.device-form button[type="submit"] {
|
||||||
|
margin-top: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-manage-section[open] > summary {
|
/* ── Main area — Animation grid ─────────────── */
|
||||||
color: var(--text-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-manage-section[open] > summary::before {
|
|
||||||
content: "▼ ";
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-manage-section > summary:hover {
|
|
||||||
color: var(--text-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px 12px;
|
|
||||||
overflow-y: auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar — terminal feel */
|
|
||||||
::-webkit-scrollbar { width: 4px; }
|
|
||||||
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--border-dim); }
|
|
||||||
|
|
||||||
.main-area {
|
.main-area {
|
||||||
grid-area: main;
|
grid-area: main;
|
||||||
background: var(--bg-panel-dark);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-dim);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
overflow: hidden;
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 14px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--text-accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--bg-panel-dark);
|
||||||
|
border-bottom: 1px solid var(--border-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-tile {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #111;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-tile:hover {
|
||||||
|
background: #0d0d0d;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-tile.active {
|
||||||
|
background: #0a1a1a;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-cursor {
|
||||||
|
color: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-tile.active .anim-cursor {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-name {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transport bar ───────────────────────────── */
|
||||||
.transport-bar {
|
.transport-bar {
|
||||||
grid-area: transport;
|
grid-area: transport;
|
||||||
background: var(--bg-panel-dark);
|
background: var(--bg-panel-dark);
|
||||||
border-top: 1px solid var(--border-dim);
|
border-top: 1px solid var(--border-dim);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 16px;
|
gap: 8px;
|
||||||
color: var(--text-dim);
|
padding: 0 12px;
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status dot */
|
.transport-btn {
|
||||||
.status-dot {
|
background: var(--bg-panel);
|
||||||
width: 8px;
|
color: var(--accent);
|
||||||
height: 8px;
|
border: 1px solid var(--border-dim);
|
||||||
border-radius: 50%;
|
padding: 5px 10px;
|
||||||
background: var(--text-dim);
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.transport-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-playpause {
|
||||||
|
min-width: 38px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-time {
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.status-dot.connected { background: #00ff88; }
|
|
||||||
.status-dot.error { background: #ff3333; }
|
|
||||||
|
|
||||||
/* Text helpers */
|
.seek-slider {
|
||||||
.text-dim { color: var(--text-dim); }
|
flex: 1;
|
||||||
.text-accent { color: var(--text-accent); }
|
min-width: 60px;
|
||||||
|
accent-color: var(--accent);
|
||||||
/* Device list rows */
|
cursor: pointer;
|
||||||
.device-row {
|
height: 3px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 6px 0;
|
|
||||||
border-bottom: 1px solid var(--bg-panel-dark);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.device-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-row-info {
|
.transport-sep {
|
||||||
display: flex;
|
width: 1px;
|
||||||
flex-direction: column;
|
height: 20px;
|
||||||
gap: 2px;
|
background: var(--border-dim);
|
||||||
min-width: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form elements */
|
.transport-file-select {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
max-width: 200px;
|
||||||
|
min-width: 100px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form base */
|
||||||
input, select, button {
|
input, select, button {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--border-dim);
|
border: 1px solid var(--border-dim);
|
||||||
@@ -207,165 +344,13 @@ input, select, button {
|
|||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
border-color: var(--border-bright);
|
border-color: var(--border-bright);
|
||||||
color: var(--text-accent);
|
color: var(--text-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Device form */
|
.text-dim { color: var(--text-dim); }
|
||||||
.device-form {
|
.text-accent { color: var(--text-accent); }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-top: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-form button[type="submit"] {
|
|
||||||
margin-top: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Device remove button */
|
|
||||||
.device-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-remove: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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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