diff --git a/colorswatchpopup.py b/app/colorswatchpopup.py similarity index 100% rename from colorswatchpopup.py rename to app/colorswatchpopup.py diff --git a/faderwidget.py b/app/faderwidget.py similarity index 100% rename from faderwidget.py rename to app/faderwidget.py diff --git a/main.py b/app/main.py similarity index 92% rename from main.py rename to app/main.py index dd45a6a..525943d 100644 --- a/main.py +++ b/app/main.py @@ -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"{ts} {line}" + _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() diff --git a/midi_ports.py b/app/midi_ports.py similarity index 100% rename from midi_ports.py rename to app/midi_ports.py diff --git a/midi_receiver.py b/app/midi_receiver.py similarity index 100% rename from midi_receiver.py rename to app/midi_receiver.py diff --git a/midi_sender.py b/app/midi_sender.py similarity index 100% rename from midi_sender.py rename to app/midi_sender.py diff --git a/osc_manager.py b/app/osc_manager.py similarity index 100% rename from osc_manager.py rename to app/osc_manager.py diff --git a/osc_receiver.py b/app/osc_receiver.py similarity index 100% rename from osc_receiver.py rename to app/osc_receiver.py diff --git a/osc_sender.py b/app/osc_sender.py similarity index 100% rename from osc_sender.py rename to app/osc_sender.py diff --git a/palette.py b/app/palette.py similarity index 100% rename from palette.py rename to app/palette.py diff --git a/presets.py b/app/presets.py similarity index 95% rename from presets.py rename to app/presets.py index 2034ea8..04d13af 100644 --- a/presets.py +++ b/app/presets.py @@ -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) diff --git a/presetwidget.py b/app/presetwidget.py similarity index 100% rename from presetwidget.py rename to app/presetwidget.py diff --git a/styles.py b/app/styles.py similarity index 100% rename from styles.py rename to app/styles.py diff --git a/togglewidget.py b/app/togglewidget.py similarity index 100% rename from togglewidget.py rename to app/togglewidget.py diff --git a/transportwidget.py b/app/transportwidget.py similarity index 100% rename from transportwidget.py rename to app/transportwidget.py diff --git a/zonebutton.py b/app/zonebutton.py similarity index 100% rename from zonebutton.py rename to app/zonebutton.py diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..715eada --- /dev/null +++ b/client/app.js @@ -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(); +}; diff --git a/client/app.py b/client/app.py new file mode 100644 index 0000000..ace1180 --- /dev/null +++ b/client/app.py @@ -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 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..28ed166 --- /dev/null +++ b/client/index.html @@ -0,0 +1,36 @@ + + +
+ + +