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:
@@ -71,6 +71,9 @@ from osc_sender import (
|
|||||||
set_osc_target,
|
set_osc_target,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ws_server import WSServer
|
||||||
|
from http_server import start_http_server, stop_http_server
|
||||||
|
|
||||||
def set_window(w):
|
def set_window(w):
|
||||||
global _window
|
global _window
|
||||||
_window = w
|
_window = w
|
||||||
@@ -79,6 +82,9 @@ _osc_server = None
|
|||||||
|
|
||||||
_osc_thread = 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 = QPushButton("Start OSC")
|
||||||
button_osc_toggle.setFixedWidth(90)
|
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
|
# Widget: button_osc_messages_log
|
||||||
button_osc_messages_log = QPushButton("Messages")
|
button_osc_messages_log = QPushButton("Messages")
|
||||||
button_osc_messages_log.setFixedWidth(80)
|
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
|
# MARK: LAYOUT PANEL
|
||||||
container_layout_panel = QWidget(None, Qt.WindowType.Window)
|
container_layout_panel = QWidget(None, Qt.WindowType.Window)
|
||||||
container_layout_panel.setWindowTitle("Layout")
|
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_toggle)
|
||||||
hstack_osc_buttons.addWidget(button_osc_messages_log)
|
hstack_osc_buttons.addWidget(button_osc_messages_log)
|
||||||
hstack_osc_buttons.addWidget(button_layout_panel)
|
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()
|
hstack_osc_buttons.addStretch()
|
||||||
|
|
||||||
# add all rows to vstack
|
# add all rows to vstack
|
||||||
@@ -1126,6 +1177,18 @@ class MainWindow(QMainWindow):
|
|||||||
button_midi_out_clear.clicked.connect(lambda: textedit_midi_out_log.clear())
|
button_midi_out_clear.clicked.connect(lambda: textedit_midi_out_log.clear())
|
||||||
container_midi_out_log.setVisible(False)
|
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
|
#MARK: LAYOUT PANEL
|
||||||
container_layout_panel.setVisible(True)
|
container_layout_panel.setVisible(True)
|
||||||
button_layout_panel.setChecked(True)
|
button_layout_panel.setChecked(True)
|
||||||
@@ -1396,6 +1459,8 @@ class MainWindow(QMainWindow):
|
|||||||
log_ui_window_calc()
|
log_ui_window_calc()
|
||||||
if container_layout_panel.isVisible():
|
if container_layout_panel.isVisible():
|
||||||
layout_panel_window_calc()
|
layout_panel_window_calc()
|
||||||
|
if container_tablet_log.isVisible():
|
||||||
|
tablet_log_window_calc()
|
||||||
|
|
||||||
def moveEvent(self, event):
|
def moveEvent(self, event):
|
||||||
super().moveEvent(event)
|
super().moveEvent(event)
|
||||||
@@ -1403,6 +1468,8 @@ class MainWindow(QMainWindow):
|
|||||||
log_ui_window_calc()
|
log_ui_window_calc()
|
||||||
if container_layout_panel.isVisible():
|
if container_layout_panel.isVisible():
|
||||||
layout_panel_window_calc()
|
layout_panel_window_calc()
|
||||||
|
if container_tablet_log.isVisible():
|
||||||
|
tablet_log_window_calc()
|
||||||
|
|
||||||
def make_separator():
|
def make_separator():
|
||||||
sep = QWidget()
|
sep = QWidget()
|
||||||
@@ -1693,6 +1760,8 @@ def update_ui_on_osc_track_received(name):
|
|||||||
label_osc_status.setText("")
|
label_osc_status.setText("")
|
||||||
_track_uuid_check_pending = True
|
_track_uuid_check_pending = True
|
||||||
QTimer.singleShot(400, _check_track_uuid_arrived)
|
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):
|
def update_ui_on_osc_fx_info_received(name):
|
||||||
label_osc_preset.setText(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):
|
def update_ui_on_osc_bar_received(bar):
|
||||||
label_osc_bar.setText(f"Bar: {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):
|
def update_ui_on_osc_playhead_received(pos):
|
||||||
label_osc_position.setText(f"Time: {pos}")
|
label_osc_position.setText(f"Time: {pos}")
|
||||||
@@ -1711,6 +1782,8 @@ def update_ui_on_osc_play_transport_received(state):
|
|||||||
else:
|
else:
|
||||||
label_osc_play_state.setText("■ STOPPED")
|
label_osc_play_state.setText("■ STOPPED")
|
||||||
label_osc_play_state.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;")
|
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):
|
def update_ui_on_osc_record_transport_received(state):
|
||||||
if state:
|
if state:
|
||||||
@@ -1719,6 +1792,8 @@ def update_ui_on_osc_record_transport_received(state):
|
|||||||
else:
|
else:
|
||||||
label_osc_record_state.setText("● REC")
|
label_osc_record_state.setText("● REC")
|
||||||
label_osc_record_state.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;")
|
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):
|
def check_and_start_midi_learn(strip):
|
||||||
global learning_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):
|
def preset_load(preset):
|
||||||
fader_states = preset.get("faders", [])
|
fader_states = preset.get("faders", [])
|
||||||
while len(faders) < len(fader_states):
|
while len(faders) < len(fader_states):
|
||||||
@@ -2286,6 +2388,10 @@ def preset_load(preset):
|
|||||||
|
|
||||||
check_and_index_strip_labels()
|
check_and_index_strip_labels()
|
||||||
|
|
||||||
|
if ws_server:
|
||||||
|
snapshot, layout = _build_tablet_snapshot()
|
||||||
|
ws_server.broadcast_preset(snapshot, layout)
|
||||||
|
|
||||||
def export_preset():
|
def export_preset():
|
||||||
import json as _json
|
import json as _json
|
||||||
from PyQt6.QtWidgets import QFileDialog, QMessageBox
|
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.last_sent = value
|
||||||
fader.prev_position = value
|
fader.prev_position = value
|
||||||
send_cc(out_cc, value, channel=out_ch)
|
send_cc(out_cc, value, channel=out_ch)
|
||||||
|
if ws_server:
|
||||||
|
ws_server.broadcast_widget_update(fader_uid, value)
|
||||||
return
|
return
|
||||||
for toggle in toggles:
|
for toggle in toggles:
|
||||||
if toggle.uid == fader_uid:
|
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)
|
send_cc(out_cc, out_val, channel=out_ch)
|
||||||
toggle.cc_output_lbl.setText(toggle._cc_output_label(out_val))
|
toggle.cc_output_lbl.setText(toggle._cc_output_label(out_val))
|
||||||
toggle._update_btn_style()
|
toggle._update_btn_style()
|
||||||
|
if ws_server:
|
||||||
|
ws_server.broadcast_widget_update(fader_uid, out_val)
|
||||||
return
|
return
|
||||||
for t in transports:
|
for t in transports:
|
||||||
if t.uid == fader_uid:
|
if t.uid == fader_uid:
|
||||||
@@ -2591,6 +2701,68 @@ def layout_panel_window_calc():
|
|||||||
container_layout_panel.move(geo.left() - 224, geo.top())
|
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):
|
def start_osc_server(port=9000):
|
||||||
global _osc_server, _osc_thread
|
global _osc_server, _osc_thread
|
||||||
stop_osc_server()
|
stop_osc_server()
|
||||||
@@ -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_DIR = os.path.join(BASE_DIR, "presets")
|
||||||
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
|
PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json")
|
||||||
os.makedirs(PRESETS_DIR, exist_ok=True)
|
os.makedirs(PRESETS_DIR, exist_ok=True)
|
||||||
+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();
|
||||||
|
};
|
||||||
+292
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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__")
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user