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
+172
View File
@@ -71,6 +71,9 @@ from osc_sender import (
set_osc_target,
)
from ws_server import WSServer
from http_server import start_http_server, stop_http_server
def set_window(w):
global _window
_window = w
@@ -79,6 +82,9 @@ _osc_server = None
_osc_thread = None
ws_server = None
_http_server = None
@@ -351,6 +357,19 @@ spinner_osc_send_port.setFixedWidth(65)
button_osc_toggle = QPushButton("Start OSC")
button_osc_toggle.setFixedWidth(90)
# Widget: button_tablet_server
button_tablet_server = QPushButton("Start Tablet")
button_tablet_server.setFixedWidth(90)
# Widget: button_tablet_log
button_tablet_log = QPushButton("Tablet")
button_tablet_log.setFixedWidth(65)
button_tablet_log.setCheckable(True)
# Widget: label_tablet_status
label_tablet_status = QLabel("")
label_tablet_status.setStyleSheet("color: #8e24aa; font-size: 11px; font-family: monospace;")
# Widget: button_osc_messages_log
button_osc_messages_log = QPushButton("Messages")
button_osc_messages_log.setFixedWidth(80)
@@ -568,6 +587,34 @@ textedit_midi_out_log.setStyleSheet("""
}
""")
# Container: container_tablet_log
container_tablet_log = QWidget(None, Qt.WindowType.Window)
container_tablet_log.setWindowTitle("Tablet Server")
container_tablet_log.setStyleSheet("background-color: #1a1a1a;")
container_tablet_log.setFixedWidth(360)
vstack_tablet_log = QVBoxLayout(container_tablet_log)
vstack_tablet_log.setContentsMargins(6, 6, 6, 6)
vstack_tablet_log.setSpacing(4)
hstack_tablet_log_header = QHBoxLayout()
label_tablet_log_title = QLabel("Tablet Server")
label_tablet_log_title.setStyleSheet("color: #8e24aa; font-weight: bold; font-size: 11px;")
button_tablet_log_clear = QPushButton("Clear")
button_tablet_log_clear.setFixedSize(45, 18)
textedit_tablet_log = QTextEdit()
textedit_tablet_log.setReadOnly(True)
textedit_tablet_log.setStyleSheet("""
QTextEdit {
background-color: #111111;
color: #8e24aa;
font-family: monospace;
font-size: 10px;
border: none;
}
""")
# MARK: LAYOUT PANEL
container_layout_panel = QWidget(None, Qt.WindowType.Window)
container_layout_panel.setWindowTitle("Layout")
@@ -995,6 +1042,10 @@ class MainWindow(QMainWindow):
hstack_osc_buttons.addWidget(button_osc_toggle)
hstack_osc_buttons.addWidget(button_osc_messages_log)
hstack_osc_buttons.addWidget(button_layout_panel)
hstack_osc_buttons.addSpacing(16)
hstack_osc_buttons.addWidget(button_tablet_server)
hstack_osc_buttons.addWidget(button_tablet_log)
hstack_osc_buttons.addWidget(label_tablet_status)
hstack_osc_buttons.addStretch()
# add all rows to vstack
@@ -1126,6 +1177,18 @@ class MainWindow(QMainWindow):
button_midi_out_clear.clicked.connect(lambda: textedit_midi_out_log.clear())
container_midi_out_log.setVisible(False)
#MARK: TABLET LOG CONTAINER
hstack_tablet_log_header.addWidget(label_tablet_log_title)
hstack_tablet_log_header.addStretch()
hstack_tablet_log_header.addWidget(button_tablet_log_clear)
vstack_tablet_log.addLayout(hstack_tablet_log_header)
vstack_tablet_log.addWidget(textedit_tablet_log)
button_tablet_log_clear.clicked.connect(lambda: textedit_tablet_log.clear())
container_tablet_log.setVisible(False)
button_tablet_server.clicked.connect(toggle_tablet_server)
button_tablet_log.clicked.connect(tablet_log_show_hide)
#MARK: LAYOUT PANEL
container_layout_panel.setVisible(True)
button_layout_panel.setChecked(True)
@@ -1396,6 +1459,8 @@ class MainWindow(QMainWindow):
log_ui_window_calc()
if container_layout_panel.isVisible():
layout_panel_window_calc()
if container_tablet_log.isVisible():
tablet_log_window_calc()
def moveEvent(self, event):
super().moveEvent(event)
@@ -1403,6 +1468,8 @@ class MainWindow(QMainWindow):
log_ui_window_calc()
if container_layout_panel.isVisible():
layout_panel_window_calc()
if container_tablet_log.isVisible():
tablet_log_window_calc()
def make_separator():
sep = QWidget()
@@ -1693,6 +1760,8 @@ def update_ui_on_osc_track_received(name):
label_osc_status.setText("")
_track_uuid_check_pending = True
QTimer.singleShot(400, _check_track_uuid_arrived)
if ws_server:
ws_server.broadcast_daw_state(track=name)
def update_ui_on_osc_fx_info_received(name):
label_osc_preset.setText(name)
@@ -1700,6 +1769,8 @@ def update_ui_on_osc_fx_info_received(name):
def update_ui_on_osc_bar_received(bar):
label_osc_bar.setText(f"Bar: {bar}")
if ws_server:
ws_server.broadcast_daw_state(bar=bar)
def update_ui_on_osc_playhead_received(pos):
label_osc_position.setText(f"Time: {pos}")
@@ -1711,6 +1782,8 @@ def update_ui_on_osc_play_transport_received(state):
else:
label_osc_play_state.setText("■ STOPPED")
label_osc_play_state.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;")
if ws_server:
ws_server.broadcast_daw_state(playing=bool(state))
def update_ui_on_osc_record_transport_received(state):
if state:
@@ -1719,6 +1792,8 @@ def update_ui_on_osc_record_transport_received(state):
else:
label_osc_record_state.setText("● REC")
label_osc_record_state.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;")
if ws_server:
ws_server.broadcast_daw_state(recording=bool(state))
def check_and_start_midi_learn(strip):
global learning_strip
@@ -2164,6 +2239,33 @@ def preset_build_snapshot(name):
}
def _build_tablet_snapshot():
name = menuPresets.currentText()
saved_layout = loaded_presets.get("presets", {}).get(name, {}).get("tablet_layout", {})
snapshot = {
"preset_uuid": label_preset_uuid.text().strip(),
"preset_name": name,
"faders": [{"uid": f.uid, "label": f.title_edit.text(), "value": f.fader.value(), "visible": True, "color": PALETTE_HEX.get(f.color_name, "#00e676")} for f in faders],
"toggles": [{"uid": t.uid, "label": t.title_edit.text(), "state": t.toggle_state, "visible": True, "color": PALETTE_HEX.get(t.color_name, "#00e676")} for t in toggles],
"transports": [{"uid": t.uid, "label": t.title_edit.text(), "visible": True} for t in transports],
}
return snapshot, saved_layout
def _on_tablet_connected():
if ws_server:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
def _on_tablet_layout_saved(preset_uuid, layout):
name = menuPresets.currentText()
if name and name in loaded_presets.get("presets", {}):
if loaded_presets["presets"][name].get("preset_uuid") == preset_uuid:
loaded_presets["presets"][name]["tablet_layout"] = layout
save_presets_file(loaded_presets)
def preset_load(preset):
fader_states = preset.get("faders", [])
while len(faders) < len(fader_states):
@@ -2286,6 +2388,10 @@ def preset_load(preset):
check_and_index_strip_labels()
if ws_server:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
def export_preset():
import json as _json
from PyQt6.QtWidgets import QFileDialog, QMessageBox
@@ -2503,6 +2609,8 @@ def midi_message_route_hw_to_output_cc(fader_uid, value):
fader.last_sent = value
fader.prev_position = value
send_cc(out_cc, value, channel=out_ch)
if ws_server:
ws_server.broadcast_widget_update(fader_uid, value)
return
for toggle in toggles:
if toggle.uid == fader_uid:
@@ -2523,6 +2631,8 @@ def midi_message_route_hw_to_output_cc(fader_uid, value):
send_cc(out_cc, out_val, channel=out_ch)
toggle.cc_output_lbl.setText(toggle._cc_output_label(out_val))
toggle._update_btn_style()
if ws_server:
ws_server.broadcast_widget_update(fader_uid, out_val)
return
for t in transports:
if t.uid == fader_uid:
@@ -2591,6 +2701,68 @@ def layout_panel_window_calc():
container_layout_panel.move(geo.left() - 224, geo.top())
def tablet_log_show_hide(checked):
container_tablet_log.setVisible(checked)
if checked:
tablet_log_window_calc()
def tablet_log_window_calc():
geo = app_window.frameGeometry()
container_tablet_log.setFixedWidth(360)
container_tablet_log.setFixedHeight(geo.height())
container_tablet_log.move(geo.right() + 732, geo.top())
def log_tablet(line):
from datetime import datetime
ts = datetime.now().strftime("%H:%M:%S")
full = f"<span style='color:#555'>{ts}</span> <span style='color:#8e24aa'>{line}</span>"
_append_log(textedit_tablet_log, full)
def toggle_tablet_server():
if ws_server is not None:
_stop_tablet_server()
else:
_start_tablet_server()
def _start_tablet_server():
global ws_server, _http_server
import socket as _socket
ws_server = WSServer(port=8765, parent=app_window)
ws_server.log_signal.connect(log_tablet)
ws_server.control_received.connect(lambda uid, val: midi_receiver.hw_cc_signal.emit(uid, val))
ws_server.layout_saved.connect(_on_tablet_layout_saved)
ws_server.client_connected.connect(_on_tablet_connected)
_http_server = start_http_server(port=8080)
try:
s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
except Exception:
ip = "localhost"
button_tablet_server.setText("Stop Tablet")
label_tablet_status.setText(f"http://{ip}:8080")
log_tablet(f"HTTP http://{ip}:8080")
log_tablet(f"WS ws://{ip}:8765")
def _stop_tablet_server():
global ws_server, _http_server
if ws_server:
ws_server.stop()
ws_server = None
if _http_server:
stop_http_server(_http_server)
_http_server = None
button_tablet_server.setText("Start Tablet")
label_tablet_status.setText("")
log_tablet("server stopped")
def start_osc_server(port=9000):
global _osc_server, _osc_thread
stop_osc_server()
View File
View File
View File
+1 -1
View File
@@ -9,7 +9,7 @@ from PyQt6.QtWidgets import (
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PRESETS_DIR = os.path.join(BASE_DIR, "presets")
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
os.makedirs(PRESETS_DIR, exist_ok=True)
View File
View File
+236
View File
@@ -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();
};
+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
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Virtual Controller</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1a1a1a; color: #fff; font-family: sans-serif; overflow: hidden; touch-action: none; }
#canvas { position: relative; width: 100vw; height: 100vh; }
#toolbar { position: fixed; top: 8px; right: 8px; z-index: 1000; display: flex; gap: 6px; align-items: center; }
.tbtn { background: #2a2a2a; color: #ccc; border: 1px solid #444; padding: 6px 14px; border-radius: 4px; font-size: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.tbtn.active { background: #00e676; color: #000; border-color: #00e676; }
#status { position: fixed; top: 8px; left: 8px; font-size: 11px; color: #555; }
#preset-name { position: fixed; bottom: 8px; left: 8px; font-size: 11px; color: #555; }
.widget { position: absolute; background: #222; border: 1px solid #3a3a3a; border-radius: 6px; padding: 12px; min-width: 140px; }
.widget-label { font-size: 16px; color: #888; text-align: center; margin-bottom: 10px; padding: 4px 0; cursor: grab; user-select: none; -webkit-user-select: none; }
input[type=range] { accent-color: #00e676; cursor: pointer; writing-mode: vertical-lr; direction: rtl; width: 64px; height: 240px; display: block; margin: 0 auto; }
.val-readout { font-size: 14px; color: #555; text-align: center; margin-top: 6px; }
.ctrl-btn { width: 100%; padding: 24px 16px; background: #2a2a2a; color: #ccc; border: 1px solid #444; border-radius: 4px; font-size: 20px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.ctrl-btn.on { background: #1a472a; border-color: #00e676; color: #00e676; }
.arrange-hint { position: fixed; bottom: 8px; right: 8px; font-size: 11px; color: #555; display: none; }
</style>
</head>
<body>
<div id="canvas"></div>
<div id="toolbar">
<button class="tbtn" id="btn-arrange">Arrange</button>
<button class="tbtn active" id="btn-lock">Lock</button>
</div>
<div id="status">connecting...</div>
<div id="preset-name"></div>
<div class="arrange-hint" id="arrange-hint">drag to reposition · lock to save</div>
<script src="./app.js"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import sys
import os
_root = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_root, "app"))
sys.path.insert(0, os.path.join(_root, "server"))
import runpy
runpy.run_path(os.path.join(_root, "app", "main.py"), run_name="__main__")
View File
+25
View File
@@ -0,0 +1,25 @@
import os
import threading
from http.server import SimpleHTTPRequestHandler, HTTPServer
_CLIENT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "client")
class _Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=_CLIENT_DIR, **kwargs)
def log_message(self, fmt, *args):
pass
def start_http_server(port=8080):
server = HTTPServer(("0.0.0.0", port), _Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
def stop_http_server(server):
if server:
threading.Thread(target=server.shutdown, daemon=True).start()
+80
View File
@@ -0,0 +1,80 @@
import json
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWebSockets import QWebSocketServer
from PyQt6.QtNetwork import QHostAddress
class WSServer(QObject):
control_received = pyqtSignal(str, int)
layout_saved = pyqtSignal(str, object)
client_connected = pyqtSignal()
log_signal = pyqtSignal(str)
def __init__(self, port=8765, parent=None):
super().__init__(parent)
self._clients = []
self._port = port
self._server = QWebSocketServer("VC", QWebSocketServer.SslMode.NonSecureMode, self)
if self._server.listen(QHostAddress.SpecialAddress.AnyIPv4, port):
self.log_signal.emit(f"WS listening on :{port}")
else:
self.log_signal.emit(f"WS failed to bind :{port}")
self._server.newConnection.connect(self._on_new_connection)
def _on_new_connection(self):
client = self._server.nextPendingConnection()
peer = client.peerAddress().toString()
self._clients.append(client)
client.textMessageReceived.connect(self._on_message)
client.disconnected.connect(lambda c=client, p=peer: self._on_disconnect(c, p))
self.log_signal.emit(f"client connected {peer} ({len(self._clients)} total)")
self.client_connected.emit()
def _on_disconnect(self, client, peer):
if client in self._clients:
self._clients.remove(client)
client.deleteLater()
self.log_signal.emit(f"client disconnected {peer} ({len(self._clients)} remaining)")
def _on_message(self, message):
try:
data = json.loads(message)
ev = data.get("event")
if ev == "control":
uid = data["uid"]
val = int(data["value"])
self.log_signal.emit(f"control uid={uid[:8]}… val={val}")
self.control_received.emit(uid, val)
elif ev == "save_layout":
n = len(data.get("layout", {}))
self.log_signal.emit(f"layout saved preset={data['preset_uuid'][:8]}{n} widgets")
self.layout_saved.emit(data["preset_uuid"], data["layout"])
else:
self.log_signal.emit(f"unknown event: {ev}")
except Exception as e:
self.log_signal.emit(f"bad message: {e}")
def broadcast(self, data: dict):
if not self._clients:
return
msg = json.dumps(data)
for client in list(self._clients):
client.sendTextMessage(msg)
def broadcast_preset(self, snapshot: dict, layout: dict):
name = snapshot.get("preset_name", "")
self.log_signal.emit(f"broadcast preset '{name}'{len(self._clients)} client(s)")
self.broadcast({"event": "preset", "data": snapshot, "layout": layout})
def broadcast_widget_update(self, uid: str, value: int):
self.broadcast({"event": "widget_update", "uid": uid, "value": value})
def broadcast_daw_state(self, **kwargs):
self.broadcast({"event": "daw_state", **kwargs})
def stop(self):
self._server.close()
for client in list(self._clients):
client.close()
self._clients.clear()
self.log_signal.emit("WS server stopped")