4db7165797
- 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>
293 lines
7.7 KiB
Python
293 lines
7.7 KiB
Python
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
|