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:
Paul Lipscomb
2026-06-29 23:44:30 -04:00
parent 22e4fa770e
commit 4db7165797
23 changed files with 851 additions and 1 deletions
+292
View File
@@ -0,0 +1,292 @@
import json
import js
js.document.getElementById("loader").className = "hidden"
WS_PORT = 8765
_state = {
"widgets": {},
"layout": {},
"locked": True,
"preset_uuid": None,
"preset_name": "",
}
# --- WebSocket ---
_host = js.window.location.hostname
_ws = js.WebSocket.new(f"ws://{_host}:{WS_PORT}")
def _on_open(e):
js.document.getElementById("status").innerText = "connected"
def _on_close(e):
js.document.getElementById("status").innerText = "disconnected"
def _on_error(e):
js.document.getElementById("status").innerText = "error"
def _on_message(e):
msg = json.loads(e.data)
ev = msg.get("event")
if ev == "preset":
_render_preset(msg["data"], msg.get("layout") or {})
elif ev == "widget_update":
_update_widget(msg["uid"], msg["value"])
elif ev == "daw_state":
_update_daw(msg)
_ws.onopen = _on_open
_ws.onclose = _on_close
_ws.onerror = _on_error
_ws.onmessage = _on_message
def _send(data):
_ws.send(json.dumps(data))
# --- Rendering ---
_canvas = js.document.getElementById("canvas")
def _render_preset(data, saved_layout):
_state["layout"] = saved_layout
_state["preset_uuid"] = data.get("preset_uuid")
_state["preset_name"] = data.get("preset_name", "")
_state["widgets"].clear()
_canvas.innerHTML = ""
js.document.getElementById("preset-name").innerText = _state["preset_name"]
idx = 0
for w in data.get("faders", []):
if w.get("visible", True):
_make_fader(w, idx)
idx += 1
for w in data.get("toggles", []):
if w.get("visible", True):
_make_toggle(w, idx)
idx += 1
for w in data.get("transports", []):
if w.get("visible", True):
_make_transport(w, idx)
idx += 1
_state["locked"] = bool(saved_layout)
_refresh_toolbar()
def _default_pos(idx):
cols = 4
return {"x": 16 + (idx % cols) * 90, "y": 48 + (idx // cols) * 220, "w": 70, "h": 200}
def _make_container(uid, label, idx):
pos = _state["layout"].get(uid) or _default_pos(idx)
el = js.document.createElement("div")
el.className = "widget"
el.style.left = f"{pos['x']}px"
el.style.top = f"{pos['y']}px"
el.style.width = f"{pos.get('w', 70)}px"
lbl = js.document.createElement("div")
lbl.className = "widget-label"
lbl.innerText = label or " "
el.appendChild(lbl)
_canvas.appendChild(el)
_state["widgets"][uid] = {"el": el, "pos": dict(pos)}
_setup_drag(el, lbl, uid)
return el
def _make_fader(data, idx):
uid = data["uid"]
color = data.get("color", "#00e676")
el = _make_container(uid, data.get("label", ""), idx)
el.style.borderColor = color
inp = js.document.createElement("input")
inp.type = "range"
inp.min = "0"
inp.max = "127"
inp.value = str(data.get("value", 0))
inp.style.accentColor = color
readout = js.document.createElement("div")
readout.className = "val-readout"
readout.innerText = str(data.get("value", 0))
readout.id = f"ro-{uid}"
def on_input(e, _uid=uid, _ro=readout):
v = int(e.target.value)
_ro.innerText = str(v)
_send({"event": "control", "uid": _uid, "value": v})
inp.oninput = on_input
el.appendChild(inp)
el.appendChild(readout)
_state["widgets"][uid]["input"] = inp
_state["widgets"][uid]["color"] = color
def _make_toggle(data, idx):
uid = data["uid"]
color = data.get("color", "#00e676")
el = _make_container(uid, data.get("label", ""), idx)
el.style.borderColor = color
btn = js.document.createElement("button")
on = bool(data.get("state"))
btn.className = "ctrl-btn" + (" on" if on else "")
btn.innerText = "ON" if on else "OFF"
if on:
btn.style.background = color
btn.style.color = "#000"
btn.style.borderColor = color
def on_click(e, _uid=uid):
_send({"event": "control", "uid": _uid, "value": 127})
btn.onclick = on_click
el.appendChild(btn)
_state["widgets"][uid]["btn"] = btn
_state["widgets"][uid]["kind"] = "toggle"
_state["widgets"][uid]["color"] = color
def _make_transport(data, idx):
uid = data["uid"]
el = _make_container(uid, "", idx)
btn = js.document.createElement("button")
btn.className = "ctrl-btn"
btn.innerText = data.get("label", "")
def on_click(e, _uid=uid):
_send({"event": "control", "uid": _uid, "value": 127})
btn.onclick = on_click
el.appendChild(btn)
_state["widgets"][uid]["btn"] = btn
_state["widgets"][uid]["kind"] = "transport"
# --- Widget updates from hardware ---
def _update_widget(uid, value):
w = _state["widgets"].get(uid)
if not w:
return
if "input" in w:
w["input"].value = str(value)
ro = js.document.getElementById(f"ro-{uid}")
if ro:
ro.innerText = str(value)
if "btn" in w and w.get("kind") == "toggle":
is_on = value > 63
color = w.get("color", "#00e676")
btn = w["btn"]
btn.className = "ctrl-btn" + (" on" if is_on else "")
btn.innerText = "ON" if is_on else "OFF"
if is_on:
btn.style.background = color
btn.style.color = "#000"
btn.style.borderColor = color
else:
btn.style.background = ""
btn.style.color = ""
btn.style.borderColor = ""
def _update_daw(msg):
parts = []
if "track" in msg:
parts.append(msg["track"])
if "bar" in msg:
parts.append(f"Bar {msg['bar']}")
if "playing" in msg:
parts.append("" if msg["playing"] else "")
text = " ".join(p for p in parts if p)
if text:
js.document.getElementById("status").innerText = text
# --- Drag to arrange ---
def _setup_drag(el, handle, uid):
drag = {"active": False, "sx": 0, "sy": 0, "ox": 0, "oy": 0}
def on_down(e, _d=drag, _el=el, _uid=uid):
if _state["locked"]:
return
_d["active"] = True
_d["sx"] = e.clientX
_d["sy"] = e.clientY
_d["ox"] = int(_el.style.left.replace("px", "") or 0)
_d["oy"] = int(_el.style.top.replace("px", "") or 0)
handle.setPointerCapture(e.pointerId)
e.preventDefault()
def on_move(e, _d=drag, _el=el, _uid=uid):
if not _d["active"]:
return
x = _d["ox"] + e.clientX - _d["sx"]
y = _d["oy"] + e.clientY - _d["sy"]
_el.style.left = f"{x}px"
_el.style.top = f"{y}px"
if _uid in _state["widgets"]:
_state["widgets"][_uid]["pos"] = {
"x": x, "y": y,
"w": int(_el.offsetWidth),
"h": int(_el.offsetHeight),
}
def on_up(e, _d=drag):
_d["active"] = False
handle.addEventListener("pointerdown", on_down)
handle.addEventListener("pointermove", on_move)
handle.addEventListener("pointerup", on_up)
# --- Toolbar ---
def _save_layout():
uuid = _state["preset_uuid"]
if not uuid:
return
layout = {uid: w["pos"] for uid, w in _state["widgets"].items()}
_send({"event": "save_layout", "preset_uuid": uuid, "layout": layout})
def _refresh_toolbar():
locked = _state["locked"]
js.document.getElementById("btn-lock").className = "tbtn" + (" active" if locked else "")
js.document.getElementById("btn-arrange").className = "tbtn" + (" active" if not locked else "")
hint = js.document.getElementById("arrange-hint")
hint.style.display = "none" if locked else "block"
def _on_arrange(e):
_state["locked"] = False
_refresh_toolbar()
def _on_lock(e):
_state["locked"] = True
_refresh_toolbar()
_save_layout()
js.document.getElementById("btn-arrange").onclick = _on_arrange
js.document.getElementById("btn-lock").onclick = _on_lock