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>
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
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();
|
||||
};
|
||||
Reference in New Issue
Block a user