Files
virtual-controller/client/app.js
T
Paul Lipscomb 4db7165797 Initial tablet POC: project restructure + WebSocket/HTTP server + JS client
- Restructured project into /app, /server, /client with run.py entry point
- WSServer (QWebSocketServer) with log_signal, start/stop, client tracking
- HTTP server serves /client on port 8080
- Vanilla JS client: faders, toggles, transport, drag-to-arrange, lock/save layout
- Start Tablet button + floating Tablet log window in app UI
- Preset broadcast on load/switch, DAW state relay, hardware sync via widget_update
- Widget colors synced from app palette, toggle state fixed (sends 0/127 correctly)
- Layout saved per preset UUID back to presets.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 23:44:30 -04:00

237 lines
6.6 KiB
JavaScript

const WS_PORT = 8765;
const state = {
widgets: {},
layout: {},
locked: true,
presetUuid: null,
presetName: '',
};
// --- WebSocket ---
const ws = new WebSocket(`ws://${window.location.hostname}:${WS_PORT}`);
ws.onopen = () => {
document.getElementById('status').innerText = 'connected';
};
ws.onclose = () => {
document.getElementById('status').innerText = 'disconnected';
};
ws.onerror = () => {
document.getElementById('status').innerText = 'error';
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.event === 'preset') renderPreset(msg.data, msg.layout || {});
else if (msg.event === 'widget_update') updateWidget(msg.uid, msg.value);
else if (msg.event === 'daw_state') updateDaw(msg);
};
function send(data) {
ws.send(JSON.stringify(data));
}
// --- Rendering ---
const canvas = document.getElementById('canvas');
function renderPreset(data, savedLayout) {
state.layout = savedLayout;
state.presetUuid = data.preset_uuid;
state.presetName = data.preset_name || '';
state.widgets = {};
canvas.innerHTML = '';
document.getElementById('preset-name').innerText = state.presetName;
let idx = 0;
(data.faders || []).filter(w => w.visible !== false).forEach(w => makeFader(w, idx++));
(data.toggles || []).filter(w => w.visible !== false).forEach(w => makeToggle(w, idx++));
(data.transports|| []).filter(w => w.visible !== false).forEach(w => makeTransport(w, idx++));
state.locked = Object.keys(savedLayout).length > 0;
refreshToolbar();
}
function defaultPos(idx) {
const cols = 3;
return { x: 16 + (idx % cols) * 170, y: 48 + Math.floor(idx / cols) * 420, w: 140, h: 380 };
}
function makeContainer(uid, label, idx) {
const pos = state.layout[uid] || defaultPos(idx);
const el = document.createElement('div');
el.className = 'widget';
el.style.left = `${pos.x}px`;
el.style.top = `${pos.y}px`;
el.style.width = `${pos.w || 70}px`;
const lbl = document.createElement('div');
lbl.className = 'widget-label';
lbl.innerText = label || ' ';
el.appendChild(lbl);
canvas.appendChild(el);
state.widgets[uid] = { el, pos: { ...pos } };
setupDrag(el, lbl, uid);
return el;
}
function makeFader(data, idx) {
const { uid, color = '#00e676' } = data;
const el = makeContainer(uid, data.label || '', idx);
el.style.borderColor = color;
const inp = document.createElement('input');
inp.type = 'range';
inp.min = '0'; inp.max = '127';
inp.value = String(data.value ?? 0);
inp.style.accentColor = color;
const readout = document.createElement('div');
readout.className = 'val-readout';
readout.innerText = String(data.value ?? 0);
readout.id = `ro-${uid}`;
inp.oninput = () => {
const v = parseInt(inp.value);
readout.innerText = String(v);
send({ event: 'control', uid, value: v });
};
el.appendChild(inp);
el.appendChild(readout);
state.widgets[uid].input = inp;
state.widgets[uid].color = color;
}
function makeToggle(data, idx) {
const { uid, color = '#00e676' } = data;
const el = makeContainer(uid, data.label || '', idx);
el.style.borderColor = color;
const btn = document.createElement('button');
const on = !!data.state;
btn.className = 'ctrl-btn' + (on ? ' on' : '');
btn.innerText = on ? 'ON' : 'OFF';
if (on) { btn.style.background = color; btn.style.color = '#000'; btn.style.borderColor = color; }
btn.onclick = () => {
const isOn = !!state.widgets[uid].isOn;
send({ event: 'control', uid, value: isOn ? 0 : 127 });
};
el.appendChild(btn);
state.widgets[uid].btn = btn;
state.widgets[uid].kind = 'toggle';
state.widgets[uid].color = color;
state.widgets[uid].isOn = on;
}
function makeTransport(data, idx) {
const { uid } = data;
const el = makeContainer(uid, '', idx);
const btn = document.createElement('button');
btn.className = 'ctrl-btn';
btn.innerText = data.label || '';
btn.onclick = () => send({ event: 'control', uid, value: 127 });
el.appendChild(btn);
state.widgets[uid].btn = btn;
state.widgets[uid].kind = 'transport';
}
// --- Widget updates from hardware ---
function updateWidget(uid, value) {
const w = state.widgets[uid];
if (!w) return;
if (w.input) {
w.input.value = String(value);
const ro = document.getElementById(`ro-${uid}`);
if (ro) ro.innerText = String(value);
}
if (w.btn && w.kind === 'toggle') {
const on = value > 63;
const color = w.color || '#00e676';
w.isOn = on;
w.btn.className = 'ctrl-btn' + (on ? ' on' : '');
w.btn.innerText = on ? 'ON' : 'OFF';
if (on) { w.btn.style.background = color; w.btn.style.color = '#000'; w.btn.style.borderColor = color; }
else { w.btn.style.background = ''; w.btn.style.color = ''; w.btn.style.borderColor = ''; }
}
}
function updateDaw(msg) {
const parts = [];
if (msg.track) parts.push(msg.track);
if (msg.bar) parts.push(`Bar ${msg.bar}`);
if (msg.playing !== undefined) parts.push(msg.playing ? '▶' : '■');
const text = parts.filter(Boolean).join(' ');
if (text) document.getElementById('status').innerText = text;
}
// --- Drag to arrange ---
function setupDrag(el, handle, uid) {
let active = false, sx = 0, sy = 0, ox = 0, oy = 0;
handle.addEventListener('pointerdown', (e) => {
if (state.locked) return;
active = true;
sx = e.clientX; sy = e.clientY;
ox = parseInt(el.style.left) || 0;
oy = parseInt(el.style.top) || 0;
handle.setPointerCapture(e.pointerId);
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!active) return;
const x = ox + e.clientX - sx;
const y = oy + e.clientY - sy;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
if (state.widgets[uid]) {
state.widgets[uid].pos = { x, y, w: el.offsetWidth, h: el.offsetHeight };
}
});
handle.addEventListener('pointerup', () => { active = false; });
}
// --- Toolbar ---
function saveLayout() {
if (!state.presetUuid) return;
const layout = {};
Object.entries(state.widgets).forEach(([uid, w]) => { layout[uid] = w.pos; });
send({ event: 'save_layout', preset_uuid: state.presetUuid, layout });
}
function refreshToolbar() {
document.getElementById('btn-lock').className = 'tbtn' + (state.locked ? ' active' : '');
document.getElementById('btn-arrange').className = 'tbtn' + (state.locked ? '' : ' active');
document.getElementById('arrange-hint').style.display = state.locked ? 'none' : 'block';
}
document.getElementById('btn-arrange').onclick = () => {
state.locked = false;
refreshToolbar();
};
document.getElementById('btn-lock').onclick = () => {
state.locked = true;
refreshToolbar();
saveLayout();
};