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(); };