diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..8c1f328
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git add *)"
+ ]
+ }
+}
diff --git a/.gitignore b/.gitignore
index 1acd030..8d5e8f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,12 @@ __pycache__/
*.pyc
.DS_Store
+
+# Xcode
+*.xcuserstate
+xcuserdata/
+*.xccheckout
+*.moved-aside
+DerivedData/
+*.hmap
+*.ipa
diff --git a/app/main.py b/app/main.py
index 525943d..3a313a6 100644
--- a/app/main.py
+++ b/app/main.py
@@ -72,7 +72,6 @@ from osc_sender import (
)
from ws_server import WSServer
-from http_server import start_http_server, stop_http_server
def set_window(w):
global _window
@@ -83,7 +82,6 @@ _osc_server = None
_osc_thread = None
ws_server = None
-_http_server = None
@@ -366,6 +364,11 @@ button_tablet_log = QPushButton("Tablet")
button_tablet_log.setFixedWidth(65)
button_tablet_log.setCheckable(True)
+# Widget: button_ws_log
+button_ws_log = QPushButton("WS")
+button_ws_log.setFixedWidth(50)
+button_ws_log.setCheckable(True)
+
# Widget: label_tablet_status
label_tablet_status = QLabel("")
label_tablet_status.setStyleSheet("color: #8e24aa; font-size: 11px; font-family: monospace;")
@@ -587,6 +590,62 @@ textedit_midi_out_log.setStyleSheet("""
}
""")
+# Container: container_ws_in_log
+container_ws_in_log = QWidget(None, Qt.WindowType.Window)
+container_ws_in_log.setWindowTitle("WS In — Incoming from Tablet")
+container_ws_in_log.setStyleSheet("background-color: #1a1a1a;")
+container_ws_in_log.setFixedWidth(420)
+
+vstack_ws_in_log = QVBoxLayout(container_ws_in_log)
+vstack_ws_in_log.setContentsMargins(6, 6, 6, 6)
+vstack_ws_in_log.setSpacing(4)
+
+hstack_ws_in_log_header = QHBoxLayout()
+label_ws_in_log_title = QLabel("WS In — Incoming from Tablet")
+label_ws_in_log_title.setStyleSheet("color: #00acc1; font-weight: bold; font-size: 11px;")
+button_ws_in_clear = QPushButton("Clear")
+button_ws_in_clear.setFixedSize(45, 18)
+
+textedit_ws_in_log = QTextEdit()
+textedit_ws_in_log.setReadOnly(True)
+textedit_ws_in_log.setStyleSheet("""
+ QTextEdit {
+ background-color: #111111;
+ color: #00e5ff;
+ font-family: monospace;
+ font-size: 10px;
+ border: none;
+ }
+""")
+
+# Container: container_ws_out_log
+container_ws_out_log = QWidget(None, Qt.WindowType.Window)
+container_ws_out_log.setWindowTitle("WS Out — Outgoing to Tablet")
+container_ws_out_log.setStyleSheet("background-color: #1a1a1a;")
+container_ws_out_log.setFixedWidth(420)
+
+vstack_ws_out_log = QVBoxLayout(container_ws_out_log)
+vstack_ws_out_log.setContentsMargins(6, 6, 6, 6)
+vstack_ws_out_log.setSpacing(4)
+
+hstack_ws_out_log_header = QHBoxLayout()
+label_ws_out_log_title = QLabel("WS Out — Outgoing to Tablet")
+label_ws_out_log_title.setStyleSheet("color: #ff6f00; font-weight: bold; font-size: 11px;")
+button_ws_out_clear = QPushButton("Clear")
+button_ws_out_clear.setFixedSize(45, 18)
+
+textedit_ws_out_log = QTextEdit()
+textedit_ws_out_log.setReadOnly(True)
+textedit_ws_out_log.setStyleSheet("""
+ QTextEdit {
+ background-color: #111111;
+ color: #ffb300;
+ font-family: monospace;
+ font-size: 10px;
+ border: none;
+ }
+""")
+
# Container: container_tablet_log
container_tablet_log = QWidget(None, Qt.WindowType.Window)
container_tablet_log.setWindowTitle("Tablet Server")
@@ -1045,6 +1104,7 @@ class MainWindow(QMainWindow):
hstack_osc_buttons.addSpacing(16)
hstack_osc_buttons.addWidget(button_tablet_server)
hstack_osc_buttons.addWidget(button_tablet_log)
+ hstack_osc_buttons.addWidget(button_ws_log)
hstack_osc_buttons.addWidget(label_tablet_status)
hstack_osc_buttons.addStretch()
@@ -1177,6 +1237,24 @@ class MainWindow(QMainWindow):
button_midi_out_clear.clicked.connect(lambda: textedit_midi_out_log.clear())
container_midi_out_log.setVisible(False)
+ #MARK: WS IN LOG CONTAINER
+ hstack_ws_in_log_header.addWidget(label_ws_in_log_title)
+ hstack_ws_in_log_header.addStretch()
+ hstack_ws_in_log_header.addWidget(button_ws_in_clear)
+ vstack_ws_in_log.addLayout(hstack_ws_in_log_header)
+ vstack_ws_in_log.addWidget(textedit_ws_in_log)
+ button_ws_in_clear.clicked.connect(lambda: textedit_ws_in_log.clear())
+ container_ws_in_log.setVisible(False)
+
+ #MARK: WS OUT LOG CONTAINER
+ hstack_ws_out_log_header.addWidget(label_ws_out_log_title)
+ hstack_ws_out_log_header.addStretch()
+ hstack_ws_out_log_header.addWidget(button_ws_out_clear)
+ vstack_ws_out_log.addLayout(hstack_ws_out_log_header)
+ vstack_ws_out_log.addWidget(textedit_ws_out_log)
+ button_ws_out_clear.clicked.connect(lambda: textedit_ws_out_log.clear())
+ container_ws_out_log.setVisible(False)
+
#MARK: TABLET LOG CONTAINER
hstack_tablet_log_header.addWidget(label_tablet_log_title)
hstack_tablet_log_header.addStretch()
@@ -1188,6 +1266,7 @@ class MainWindow(QMainWindow):
button_tablet_server.clicked.connect(toggle_tablet_server)
button_tablet_log.clicked.connect(tablet_log_show_hide)
+ button_ws_log.clicked.connect(ws_log_show_hide)
#MARK: LAYOUT PANEL
container_layout_panel.setVisible(True)
@@ -1701,6 +1780,32 @@ def log_osc_outgoing(addr, val):
line = f"{ts} {addr} {val}"
_append_log(textedit_osc_out_log, line)
+def log_ws_incoming(message):
+ import json
+ from datetime import datetime
+ ts = datetime.now().strftime("%H:%M:%S")
+ try:
+ obj = json.loads(message)
+ pretty = json.dumps(obj, indent=2)
+ except Exception:
+ pretty = message
+ header = f"{ts} ◀ WS In"
+ body = f"
{pretty}"
+ textedit_ws_in_log.append(header)
+ textedit_ws_in_log.append(body)
+ textedit_ws_in_log.append("───────────────────")
+
+def log_ws_outgoing(data):
+ import json
+ from datetime import datetime
+ ts = datetime.now().strftime("%H:%M:%S")
+ pretty = json.dumps(data, indent=2)
+ header = f"{ts} ▶ WS Out"
+ body = f"{pretty}"
+ textedit_ws_out_log.append(header)
+ textedit_ws_out_log.append(body)
+ textedit_ws_out_log.append("───────────────────")
+
def update_osc_server_ip(ip):
set_osc_target(ip=ip.strip() or "127.0.0.1")
save_io_config()
@@ -2245,6 +2350,7 @@ def _build_tablet_snapshot():
snapshot = {
"preset_uuid": label_preset_uuid.text().strip(),
"preset_name": name,
+ "big_title": big_title_edit.text() if big_title_edit else "",
"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],
@@ -2256,6 +2362,7 @@ def _on_tablet_connected():
if ws_server:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
+ log_ws_outgoing(snapshot)
def _on_tablet_layout_saved(preset_uuid, layout):
@@ -2267,6 +2374,9 @@ def _on_tablet_layout_saved(preset_uuid, layout):
def preset_load(preset):
+ global selected_strips
+ selected_strips = []
+
fader_states = preset.get("faders", [])
while len(faders) < len(fader_states):
fader_add()
@@ -2391,6 +2501,7 @@ def preset_load(preset):
if ws_server:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
+ log_ws_outgoing(snapshot)
def export_preset():
import json as _json
@@ -2714,6 +2825,24 @@ def tablet_log_window_calc():
container_tablet_log.move(geo.right() + 732, geo.top())
+def ws_log_show_hide(checked):
+ container_ws_in_log.setVisible(checked)
+ container_ws_out_log.setVisible(checked)
+ if checked:
+ ws_log_window_calc()
+
+
+def ws_log_window_calc():
+ geo = app_window.frameGeometry()
+ half = geo.height() // 2 - 2
+ container_ws_in_log.setFixedWidth(360)
+ container_ws_in_log.setFixedHeight(half)
+ container_ws_in_log.move(geo.right() + 4, geo.top())
+ container_ws_out_log.setFixedWidth(360)
+ container_ws_out_log.setFixedHeight(half)
+ container_ws_out_log.move(geo.right() + 4, geo.top() + half + 4)
+
+
def log_tablet(line):
from datetime import datetime
ts = datetime.now().strftime("%H:%M:%S")
@@ -2729,14 +2858,14 @@ def toggle_tablet_server():
def _start_tablet_server():
- global ws_server, _http_server
+ global ws_server
import socket as _socket
ws_server = WSServer(port=8765, parent=app_window)
ws_server.log_signal.connect(log_tablet)
+ ws_server.raw_in_signal.connect(log_ws_incoming)
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))
@@ -2745,19 +2874,15 @@ def _start_tablet_server():
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")
+ label_tablet_status.setText(f"ws://{ip}:8765")
log_tablet(f"WS ws://{ip}:8765")
def _stop_tablet_server():
- global ws_server, _http_server
+ global ws_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")
diff --git a/client/app.js b/client/app.js
deleted file mode 100644
index 715eada..0000000
--- a/client/app.js
+++ /dev/null
@@ -1,236 +0,0 @@
-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
deleted file mode 100644
index ace1180..0000000
--- a/client/app.py
+++ /dev/null
@@ -1,292 +0,0 @@
-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
deleted file mode 100644
index 28ed166..0000000
--- a/client/index.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
- Virtual Controller
-
-
-
-
-
-
-
-
- connecting...
-
- drag to reposition · lock to save
-
-
-
diff --git a/presets/presets.json b/presets/presets.json
index a7e97e4..b39a392 100644
--- a/presets/presets.json
+++ b/presets/presets.json
@@ -1,5 +1,5 @@
{
- "__last_used__": "preset5",
+ "__last_used__": "preset3",
"presets": {
"preset1": {
"faders": [
@@ -8,10 +8,10 @@
"label": "Fader 1",
"cc": 1,
"ch": 1,
- "value": 38,
+ "value": 69,
"color": "Red",
"pickup": false,
- "last_sent": 38,
+ "last_sent": 69,
"hw_cc": null,
"hw_channel": null,
"zone": null,
@@ -23,10 +23,10 @@
"label": "Fader 2",
"cc": 1,
"ch": 1,
- "value": 64,
+ "value": 83,
"color": "Gray",
"pickup": false,
- "last_sent": 64,
+ "last_sent": 83,
"hw_cc": null,
"hw_channel": null,
"zone": "right",
@@ -38,10 +38,10 @@
"label": "Fader 3",
"cc": 1,
"ch": 1,
- "value": 64,
+ "value": 56,
"color": "Orange",
"pickup": false,
- "last_sent": 64,
+ "last_sent": 56,
"hw_cc": null,
"hw_channel": null,
"zone": "left",
@@ -61,20 +61,22 @@
"center_index": 0,
"zone_index": -1,
"hw_cc": null,
- "hw_channel": null
+ "hw_channel": null,
+ "trigger_mode": "toggle"
},
{
"uid": "73962e37-53b3-400f-a7e2-25ef7f1ea583",
"label": "Toggle 2",
"cc": 20,
"ch": 1,
- "state": false,
+ "state": true,
"color": "Blue",
"zone": "left",
"center_index": 1,
"zone_index": 0,
"hw_cc": null,
- "hw_channel": null
+ "hw_channel": null,
+ "trigger_mode": "toggle"
},
{
"uid": "b3cf419a-5183-4272-bd6f-44f858afbec8",
@@ -87,7 +89,8 @@
"center_index": 2,
"zone_index": 1,
"hw_cc": null,
- "hw_channel": null
+ "hw_channel": null,
+ "trigger_mode": "momentary"
},
{
"uid": "c179bfea-deec-416a-b4aa-74e81b5cbe03",
@@ -100,20 +103,22 @@
"center_index": null,
"zone_index": null,
"hw_cc": null,
- "hw_channel": null
+ "hw_channel": null,
+ "trigger_mode": "toggle"
},
{
"uid": "a5a50618-fc4c-446e-868e-e4eafa6254e7",
"label": "Toggle 6",
"cc": 20,
"ch": 1,
- "state": false,
+ "state": true,
"color": "Gray",
"zone": null,
"center_index": null,
"zone_index": null,
"hw_cc": null,
- "hw_channel": null
+ "hw_channel": null,
+ "trigger_mode": "toggle"
},
{
"uid": "f1775380-3390-4fbb-bd8b-8c30ff18f72a",
@@ -126,7 +131,8 @@
"center_index": null,
"zone_index": null,
"hw_cc": null,
- "hw_channel": null
+ "hw_channel": null,
+ "trigger_mode": "toggle"
},
{
"uid": "2064f2ca-6d18-468b-bf45-9bcb1fa1247d",
@@ -139,7 +145,8 @@
"center_index": 4,
"zone_index": -1,
"hw_cc": null,
- "hw_channel": null
+ "hw_channel": null,
+ "trigger_mode": "toggle"
}
],
"browser": {
@@ -160,12 +167,20 @@
"trigger_mode": "momentary"
}
},
+ "big_title": "STRING THING",
"preset_uuid": "8cbceb6b-ba5d-4b2c-8989-f921e4e3150d",
"show_hide": {
- "fader": false,
- "toggle": false,
- "transport": false,
- "browser": false
+ "fader": true,
+ "toggle": true,
+ "transport": true,
+ "browser": true
+ },
+ "sections": {
+ "preset_browser": true,
+ "big_title": true,
+ "fader_row": true,
+ "toggle_row": true,
+ "transport_row": true
}
},
"preset2": {
@@ -203,10 +218,10 @@
"label": "Fader 1",
"cc": 1,
"ch": 1,
- "value": 64,
- "color": "Gray",
+ "value": 43,
+ "color": "Orange",
"pickup": false,
- "last_sent": 64,
+ "last_sent": 43,
"hw_cc": null,
"hw_channel": null,
"zone": "left",
@@ -218,10 +233,10 @@
"label": "Fader 2",
"cc": 1,
"ch": 1,
- "value": 64,
- "color": "Gray",
+ "value": 48,
+ "color": "Pink",
"pickup": false,
- "last_sent": 64,
+ "last_sent": 48,
"hw_cc": null,
"hw_channel": null,
"zone": null,
@@ -233,10 +248,10 @@
"label": "Fader 3",
"cc": 1,
"ch": 1,
- "value": 64,
- "color": "Gray",
+ "value": 93,
+ "color": "Yellow",
"pickup": false,
- "last_sent": 64,
+ "last_sent": 93,
"hw_cc": null,
"hw_channel": null,
"zone": "right",
@@ -247,7 +262,7 @@
"toggles": [
{
"uid": "e291394c-cc09-4d3c-9ac3-ed5fe2803a46",
- "label": "Toggle 1",
+ "label": "whatever",
"cc": 20,
"ch": 1,
"state": false,
@@ -278,13 +293,86 @@
"trigger_mode": "momentary"
}
},
+ "big_title": "",
"preset_uuid": "7483fb5b-f47f-49e4-bccb-e8d20195078c",
"show_hide": {
"fader": false,
"toggle": false,
- "transport": false,
+ "transport": true,
"browser": true
- }
+ },
+ "sections": {
+ "preset_browser": true,
+ "big_title": true,
+ "fader_row": true,
+ "toggle_row": true,
+ "transport_row": true
+ },
+ "tablet_layout": [
+ {
+ "x": 550.3499755859375,
+ "uid": "9ee09db7-2cf5-4ae6-a154-b25bb262565c",
+ "w": 140,
+ "h": 119,
+ "y": 896.0333251953125
+ },
+ {
+ "h": 119,
+ "y": 885.0333251953125,
+ "x": 368.2833251953125,
+ "w": 140,
+ "uid": "2a1fcb50-b9f2-4c33-bce0-5bd5cadd9c46"
+ },
+ {
+ "y": 16,
+ "uid": "title-big",
+ "x": 16,
+ "w": 300,
+ "h": 44
+ },
+ {
+ "uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
+ "h": 329,
+ "x": 542.9166259765625,
+ "y": 359.79998779296875,
+ "w": 140
+ },
+ {
+ "h": 329,
+ "uid": "c98d2009-d186-489a-8883-ccbd612cb4af",
+ "x": 39.433349609375,
+ "y": 345.5,
+ "w": 140
+ },
+ {
+ "uid": "e2e8eb84-c2c9-4060-a818-b087ebd5aabd",
+ "x": 714.5833740234375,
+ "y": 892.8333740234375,
+ "h": 119,
+ "w": 140
+ },
+ {
+ "w": 140,
+ "h": 329,
+ "y": 322.2833251953125,
+ "x": 299.04998779296875,
+ "uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095"
+ },
+ {
+ "x": 1138.566650390625,
+ "y": 915.4666748046875,
+ "h": 140,
+ "uid": "e291394c-cc09-4d3c-9ac3-ed5fe2803a46",
+ "w": 140
+ },
+ {
+ "x": 158.23332977294922,
+ "w": 140,
+ "h": 119,
+ "uid": "6b5bcaf7-1d01-4ccd-b23a-ebae8da57a7f",
+ "y": 885.6500244140625
+ }
+ ]
},
"whatever": {
"faders": [],
@@ -304,8 +392,8 @@
"color": "Gray",
"pickup": false,
"last_sent": 85,
- "hw_cc": 67,
- "hw_channel": 1,
+ "hw_cc": null,
+ "hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
@@ -439,9 +527,16 @@
"preset_uuid": "3e9d51d2-cf55-49f4-80a4-2f7f4466f959",
"show_hide": {
"fader": false,
- "toggle": false,
- "transport": false,
+ "toggle": true,
+ "transport": true,
"browser": true
+ },
+ "sections": {
+ "preset_browser": true,
+ "big_title": true,
+ "fader_row": true,
+ "toggle_row": true,
+ "transport_row": true
}
},
"new preset browser shit": {
@@ -467,6 +562,206 @@
}
},
"show_hide": {}
+ },
+ "rename whatever": {
+ "faders": [
+ {
+ "uid": "14ec66c2-6a0a-418e-9240-776cb13f6e2e",
+ "label": "Fader 1",
+ "cc": 90,
+ "ch": 15,
+ "value": 85,
+ "color": "Blue",
+ "pickup": false,
+ "last_sent": 85,
+ "hw_cc": null,
+ "hw_channel": null,
+ "zone": null,
+ "center_index": null,
+ "zone_index": null
+ },
+ {
+ "uid": "5aaecf41-3455-4fa2-bef1-ca02f5933a69",
+ "label": "Fader 2",
+ "cc": 1,
+ "ch": 1,
+ "value": 79,
+ "color": "Blue",
+ "pickup": false,
+ "last_sent": 79,
+ "hw_cc": null,
+ "hw_channel": null,
+ "zone": null,
+ "center_index": null,
+ "zone_index": null
+ },
+ {
+ "uid": "e8e8cca8-3bad-49a5-8c91-d3a78d25a1eb",
+ "label": "Fader 3",
+ "cc": 1,
+ "ch": 1,
+ "value": 64,
+ "color": "Blue",
+ "pickup": false,
+ "last_sent": 64,
+ "hw_cc": null,
+ "hw_channel": null,
+ "zone": null,
+ "center_index": null,
+ "zone_index": null
+ },
+ {
+ "uid": "ae9cd47a-96af-45a4-83e6-816916a65e0e",
+ "label": "Fader 4",
+ "cc": 1,
+ "ch": 1,
+ "value": 64,
+ "color": "Green",
+ "pickup": false,
+ "last_sent": 64,
+ "hw_cc": null,
+ "hw_channel": null,
+ "zone": null,
+ "center_index": null,
+ "zone_index": null
+ },
+ {
+ "uid": "fee53fde-2f59-4800-8873-3d75ca619366",
+ "label": "Fader 5",
+ "cc": 1,
+ "ch": 1,
+ "value": 64,
+ "color": "Green",
+ "pickup": false,
+ "last_sent": 64,
+ "hw_cc": null,
+ "hw_channel": null,
+ "zone": null,
+ "center_index": null,
+ "zone_index": null
+ },
+ {
+ "uid": "1783a98d-16c9-4a06-ae4d-0f6ab36d4421",
+ "label": "Fader 6",
+ "cc": 1,
+ "ch": 1,
+ "value": 64,
+ "color": "Green",
+ "pickup": false,
+ "last_sent": 64,
+ "hw_cc": null,
+ "hw_channel": null,
+ "zone": null,
+ "center_index": null,
+ "zone_index": null
+ },
+ {
+ "uid": "2a55bdef-83aa-458f-a37d-e12a61e3e6a0",
+ "label": "Fader 7",
+ "cc": 1,
+ "ch": 1,
+ "value": 127,
+ "color": "Green",
+ "pickup": false,
+ "last_sent": 127,
+ "hw_cc": null,
+ "hw_channel": null,
+ "zone": null,
+ "center_index": null,
+ "zone_index": null
+ }
+ ],
+ "toggles": [
+ {
+ "uid": "46f17f40-1bb2-4527-b6c5-fd26e535beed",
+ "label": "Toggle 1",
+ "cc": 12,
+ "ch": 1,
+ "state": false,
+ "color": "Green",
+ "zone": null,
+ "center_index": null,
+ "zone_index": null,
+ "hw_cc": null,
+ "hw_channel": null,
+ "trigger_mode": "toggle"
+ },
+ {
+ "uid": "dbf062b0-6dfe-4a7b-af6a-cf47a943bdec",
+ "label": "Toggle 2",
+ "cc": 13,
+ "ch": 1,
+ "state": false,
+ "color": "Teal",
+ "zone": null,
+ "center_index": null,
+ "zone_index": null,
+ "hw_cc": null,
+ "hw_channel": null,
+ "trigger_mode": "toggle"
+ },
+ {
+ "uid": "93d02fb4-5327-4f0c-8cbd-6f0eb24f7195",
+ "label": "Toggle 3",
+ "cc": 14,
+ "ch": 1,
+ "state": false,
+ "color": "Purple",
+ "zone": null,
+ "center_index": null,
+ "zone_index": null,
+ "hw_cc": null,
+ "hw_channel": null,
+ "trigger_mode": "toggle"
+ },
+ {
+ "uid": "3147496a-7380-401b-9b95-947dc5458886",
+ "label": "Toggle 4",
+ "cc": 15,
+ "ch": 1,
+ "state": false,
+ "color": "Pink",
+ "zone": null,
+ "center_index": null,
+ "zone_index": null,
+ "hw_cc": null,
+ "hw_channel": null,
+ "trigger_mode": "toggle"
+ }
+ ],
+ "browser": {
+ "back": {
+ "label": "Back",
+ "cc": 22,
+ "ch": 1,
+ "hw_cc": null,
+ "hw_channel": null,
+ "trigger_mode": "momentary"
+ },
+ "fwd": {
+ "label": "Fwd",
+ "cc": 23,
+ "ch": 1,
+ "hw_cc": null,
+ "hw_channel": null,
+ "trigger_mode": "momentary"
+ }
+ },
+ "big_title": "STRING SHORTS",
+ "preset_uuid": "cf0155c7-81ea-4291-8680-6f058b80007f",
+ "show_hide": {
+ "fader": false,
+ "toggle": false,
+ "transport": true,
+ "browser": true
+ },
+ "sections": {
+ "preset_browser": true,
+ "big_title": true,
+ "fader_row": true,
+ "toggle_row": true,
+ "transport_row": true
+ }
}
},
"transport_preset": [
diff --git a/remote-client-ios/remote-client-ios.xcodeproj/project.pbxproj b/remote-client-ios/remote-client-ios.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..18694ec
--- /dev/null
+++ b/remote-client-ios/remote-client-ios.xcodeproj/project.pbxproj
@@ -0,0 +1,578 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXContainerItemProxy section */
+ 979A62DE2FF478D6002D3E46 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 979A62BF2FF478D4002D3E46 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 979A62C62FF478D4002D3E46;
+ remoteInfo = "remote-client-ios";
+ };
+ 979A62E82FF478D6002D3E46 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 979A62BF2FF478D4002D3E46 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 979A62C62FF478D4002D3E46;
+ remoteInfo = "remote-client-ios";
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+ 979A62C72FF478D4002D3E46 /* remote-client-ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "remote-client-ios.app"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 979A62DD2FF478D6002D3E46 /* remote-client-iosTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "remote-client-iosTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 979A62E72FF478D6002D3E46 /* remote-client-iosUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "remote-client-iosUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 979A62EF2FF478D6002D3E46 /* Exceptions for "remote-client-ios" folder in "remote-client-ios" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 979A62C62FF478D4002D3E46 /* remote-client-ios */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 979A62C92FF478D4002D3E46 /* remote-client-ios */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 979A62EF2FF478D6002D3E46 /* Exceptions for "remote-client-ios" folder in "remote-client-ios" target */,
+ );
+ path = "remote-client-ios";
+ sourceTree = "";
+ };
+ 979A62E02FF478D6002D3E46 /* remote-client-iosTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "remote-client-iosTests";
+ sourceTree = "";
+ };
+ 979A62EA2FF478D6002D3E46 /* remote-client-iosUITests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = "remote-client-iosUITests";
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 979A62C42FF478D4002D3E46 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 979A62DA2FF478D6002D3E46 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 979A62E42FF478D6002D3E46 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 979A62BE2FF478D4002D3E46 = {
+ isa = PBXGroup;
+ children = (
+ 979A62C92FF478D4002D3E46 /* remote-client-ios */,
+ 979A62E02FF478D6002D3E46 /* remote-client-iosTests */,
+ 979A62EA2FF478D6002D3E46 /* remote-client-iosUITests */,
+ 979A62C82FF478D4002D3E46 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 979A62C82FF478D4002D3E46 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 979A62C72FF478D4002D3E46 /* remote-client-ios.app */,
+ 979A62DD2FF478D6002D3E46 /* remote-client-iosTests.xctest */,
+ 979A62E72FF478D6002D3E46 /* remote-client-iosUITests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 979A62C62FF478D4002D3E46 /* remote-client-ios */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 979A62F02FF478D6002D3E46 /* Build configuration list for PBXNativeTarget "remote-client-ios" */;
+ buildPhases = (
+ 979A62C32FF478D4002D3E46 /* Sources */,
+ 979A62C42FF478D4002D3E46 /* Frameworks */,
+ 979A62C52FF478D4002D3E46 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 979A62C92FF478D4002D3E46 /* remote-client-ios */,
+ );
+ name = "remote-client-ios";
+ packageProductDependencies = (
+ );
+ productName = "remote-client-ios";
+ productReference = 979A62C72FF478D4002D3E46 /* remote-client-ios.app */;
+ productType = "com.apple.product-type.application";
+ };
+ 979A62DC2FF478D6002D3E46 /* remote-client-iosTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 979A62F52FF478D6002D3E46 /* Build configuration list for PBXNativeTarget "remote-client-iosTests" */;
+ buildPhases = (
+ 979A62D92FF478D6002D3E46 /* Sources */,
+ 979A62DA2FF478D6002D3E46 /* Frameworks */,
+ 979A62DB2FF478D6002D3E46 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 979A62DF2FF478D6002D3E46 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 979A62E02FF478D6002D3E46 /* remote-client-iosTests */,
+ );
+ name = "remote-client-iosTests";
+ packageProductDependencies = (
+ );
+ productName = "remote-client-iosTests";
+ productReference = 979A62DD2FF478D6002D3E46 /* remote-client-iosTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 979A62E62FF478D6002D3E46 /* remote-client-iosUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 979A62F82FF478D6002D3E46 /* Build configuration list for PBXNativeTarget "remote-client-iosUITests" */;
+ buildPhases = (
+ 979A62E32FF478D6002D3E46 /* Sources */,
+ 979A62E42FF478D6002D3E46 /* Frameworks */,
+ 979A62E52FF478D6002D3E46 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 979A62E92FF478D6002D3E46 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 979A62EA2FF478D6002D3E46 /* remote-client-iosUITests */,
+ );
+ name = "remote-client-iosUITests";
+ packageProductDependencies = (
+ );
+ productName = "remote-client-iosUITests";
+ productReference = 979A62E72FF478D6002D3E46 /* remote-client-iosUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 979A62BF2FF478D4002D3E46 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1640;
+ LastUpgradeCheck = 1640;
+ TargetAttributes = {
+ 979A62C62FF478D4002D3E46 = {
+ CreatedOnToolsVersion = 16.4;
+ };
+ 979A62DC2FF478D6002D3E46 = {
+ CreatedOnToolsVersion = 16.4;
+ TestTargetID = 979A62C62FF478D4002D3E46;
+ };
+ 979A62E62FF478D6002D3E46 = {
+ CreatedOnToolsVersion = 16.4;
+ TestTargetID = 979A62C62FF478D4002D3E46;
+ };
+ };
+ };
+ buildConfigurationList = 979A62C22FF478D4002D3E46 /* Build configuration list for PBXProject "remote-client-ios" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 979A62BE2FF478D4002D3E46;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 979A62C82FF478D4002D3E46 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 979A62C62FF478D4002D3E46 /* remote-client-ios */,
+ 979A62DC2FF478D6002D3E46 /* remote-client-iosTests */,
+ 979A62E62FF478D6002D3E46 /* remote-client-iosUITests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 979A62C52FF478D4002D3E46 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 979A62DB2FF478D6002D3E46 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 979A62E52FF478D6002D3E46 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 979A62C32FF478D4002D3E46 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 979A62D92FF478D6002D3E46 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 979A62E32FF478D6002D3E46 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 979A62DF2FF478D6002D3E46 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 979A62C62FF478D4002D3E46 /* remote-client-ios */;
+ targetProxy = 979A62DE2FF478D6002D3E46 /* PBXContainerItemProxy */;
+ };
+ 979A62E92FF478D6002D3E46 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 979A62C62FF478D4002D3E46 /* remote-client-ios */;
+ targetProxy = 979A62E82FF478D6002D3E46 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 979A62F12FF478D6002D3E46 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "remote-client-ios/Info.plist";
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UIMainStoryboardFile = Main;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.japan4.remote-client-ios";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 979A62F22FF478D6002D3E46 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "remote-client-ios/Info.plist";
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UIMainStoryboardFile = Main;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.japan4.remote-client-ios";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ 979A62F32FF478D6002D3E46 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.5;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 979A62F42FF478D6002D3E46 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.5;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 979A62F62FF478D6002D3E46 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.5;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.japan4.remote-client-iosTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/remote-client-ios.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/remote-client-ios";
+ };
+ name = Debug;
+ };
+ 979A62F72FF478D6002D3E46 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 18.5;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.japan4.remote-client-iosTests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/remote-client-ios.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/remote-client-ios";
+ };
+ name = Release;
+ };
+ 979A62F92FF478D6002D3E46 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.japan4.remote-client-iosUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = "remote-client-ios";
+ };
+ name = Debug;
+ };
+ 979A62FA2FF478D6002D3E46 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = LTQ6N8Q7YG;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "com.japan4.remote-client-iosUITests";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_TARGET_NAME = "remote-client-ios";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 979A62C22FF478D4002D3E46 /* Build configuration list for PBXProject "remote-client-ios" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 979A62F32FF478D6002D3E46 /* Debug */,
+ 979A62F42FF478D6002D3E46 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 979A62F02FF478D6002D3E46 /* Build configuration list for PBXNativeTarget "remote-client-ios" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 979A62F12FF478D6002D3E46 /* Debug */,
+ 979A62F22FF478D6002D3E46 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 979A62F52FF478D6002D3E46 /* Build configuration list for PBXNativeTarget "remote-client-iosTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 979A62F62FF478D6002D3E46 /* Debug */,
+ 979A62F72FF478D6002D3E46 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 979A62F82FF478D6002D3E46 /* Build configuration list for PBXNativeTarget "remote-client-iosUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 979A62F92FF478D6002D3E46 /* Debug */,
+ 979A62FA2FF478D6002D3E46 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 979A62BF2FF478D4002D3E46 /* Project object */;
+}
diff --git a/remote-client-ios/remote-client-ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/remote-client-ios/remote-client-ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/remote-client-ios/remote-client-ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/remote-client-ios/remote-client-ios/AppDelegate.swift b/remote-client-ios/remote-client-ios/AppDelegate.swift
new file mode 100644
index 0000000..9a2f760
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/AppDelegate.swift
@@ -0,0 +1,36 @@
+//
+// AppDelegate.swift
+// remote-client-ios
+//
+// Created by p4piwabl0 on 6/30/26.
+//
+
+import UIKit
+
+@main
+class AppDelegate: UIResponder, UIApplicationDelegate {
+
+
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ // Override point for customization after application launch.
+ return true
+ }
+
+ // MARK: UISceneSession Lifecycle
+
+ func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ // Called when a new scene session is being created.
+ // Use this method to select a configuration to create the new scene with.
+ return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
+ }
+
+ func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {
+ // Called when the user discards a scene session.
+ // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
+ // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
+ }
+
+
+}
+
diff --git a/remote-client-ios/remote-client-ios/Assets.xcassets/AccentColor.colorset/Contents.json b/remote-client-ios/remote-client-ios/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/remote-client-ios/remote-client-ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/remote-client-ios/remote-client-ios/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/remote-client-ios/remote-client-ios/Assets.xcassets/Contents.json b/remote-client-ios/remote-client-ios/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/remote-client-ios/remote-client-ios/Base.lproj/LaunchScreen.storyboard b/remote-client-ios/remote-client-ios/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..865e932
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/remote-client-ios/remote-client-ios/Base.lproj/Main.storyboard b/remote-client-ios/remote-client-ios/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..25a7638
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Base.lproj/Main.storyboard
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/remote-client-ios/remote-client-ios/Core/Functions.swift b/remote-client-ios/remote-client-ios/Core/Functions.swift
new file mode 100644
index 0000000..b60264a
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/Functions.swift
@@ -0,0 +1,283 @@
+import UIKit
+
+extension ObjectsViewController {
+
+ // MARK: - Connection State UI
+ func applyConnectionState(_ state: ConnectionState) {
+ self.connectionState = state
+ let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
+ let red = UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1)
+ let gray = UIColor(white: 0.3, alpha: 1)
+
+ switch state {
+ case .notStarted:
+ self.statusLabel.text = "not started"
+ self.connectButton.setTitle("Connect", for: .normal)
+ self.connectButton.backgroundColor = green
+ self.connectButton.setTitleColor(.black, for: .normal)
+ self.connectButton.isEnabled = true
+ case .connecting:
+ self.statusLabel.text = "connecting..."
+ self.connectButton.setTitle("Connecting...", for: .normal)
+ self.connectButton.backgroundColor = gray
+ self.connectButton.setTitleColor(.lightGray, for: .normal)
+ self.connectButton.isEnabled = false
+ case .connected:
+ self.statusLabel.text = "connected"
+ self.connectButton.setTitle("Disconnect", for: .normal)
+ self.connectButton.backgroundColor = red
+ self.connectButton.setTitleColor(.white, for: .normal)
+ self.connectButton.isEnabled = true
+ case .disconnected:
+ self.statusLabel.text = "disconnected"
+ self.connectButton.setTitle("Reconnect", for: .normal)
+ self.connectButton.backgroundColor = green
+ self.connectButton.setTitleColor(.black, for: .normal)
+ self.connectButton.isEnabled = true
+ }
+ }
+
+ // MARK: - WebSocket Connect / Disconnect
+ @objc func connectTapped() {
+ if self.connectionState == .connected {
+ self.wsTask?.cancel(with: .goingAway, reason: nil)
+ self.wsTask = nil
+ self.applyConnectionState(.disconnected)
+ } else {
+ self.connectWebSocket()
+ }
+ }
+
+ func connectWebSocket() {
+ let ip = self.ipField.text?.trimmingCharacters(in: .whitespaces) ?? "127.0.0.1"
+ let port = self.portField.text?.trimmingCharacters(in: .whitespaces) ?? "8765"
+ guard let url = URL(string: "ws://\(ip):\(port)") else { return }
+ self.urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
+ self.wsTask = self.urlSession?.webSocketTask(with: url)
+ self.wsTask?.resume()
+ self.applyConnectionState(.connecting)
+ self.listen()
+ }
+
+ // MARK: - Listen
+ func listen() {
+ self.wsTask?.receive { [weak self] result in
+ guard let self = self else { return }
+ switch result {
+ case .success(let message):
+ switch message {
+ case .string(let text): self.handleMessage(text)
+ default: break
+ }
+ self.listen()
+ case .failure:
+ DispatchQueue.main.async { self.applyConnectionState(.disconnected) }
+ }
+ }
+ }
+
+ // MARK: - Show Logging Toggle
+ @objc func showLoggingTapped() {
+ let nowVisible = self.logWidget.isHidden
+ self.logWidget.isHidden = !nowVisible
+ self.showLoggingButton.setTitle(nowVisible ? "Hide Logging" : "Show Logging", for: .normal)
+ }
+
+ // MARK: - Handle Incoming Message
+ func handleMessage(_ text: String) {
+ self.loggingView.log(text)
+ guard let data = text.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let event = json["event"] as? String else { return }
+
+ switch event {
+ case "preset":
+ if let presetData = json["data"] as? [String: Any],
+ let presetBytes = try? JSONSerialization.data(withJSONObject: presetData) {
+ do {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ let preset = try decoder.decode(ModelPresetLoadToRemoteDevice.self, from: presetBytes)
+ DispatchQueue.main.async { self.renderPreset(preset) }
+ } catch {
+ self.loggingView.log("decode error: \(error)")
+ }
+ }
+ case "widget_update":
+ if let uid = json["uid"] as? String, let value = json["value"] as? Int {
+ DispatchQueue.main.async { self.updateWidget(uid: uid, value: value) }
+ }
+ case "daw_state":
+ DispatchQueue.main.async { self.updateDawState(json) }
+ default:
+ break
+ }
+ }
+
+ // MARK: - Send
+ func send(_ dict: [String: Any]) {
+ guard let data = try? JSONSerialization.data(withJSONObject: dict),
+ let text = String(data: data, encoding: .utf8) else { return }
+ self.wsTask?.send(.string(text)) { _ in }
+ }
+
+ // MARK: - Render Preset
+ func renderPreset(_ preset: ModelPresetLoadToRemoteDevice) {
+ let savedLayout = UserDefaults.standard.dictionary(forKey: "layout_\(preset.presetUuid)") ?? [:]
+ self.presetName = preset.presetName
+ self.presetUuid = preset.presetUuid
+ self.widgets = [:]
+ self.feedbackWidgets = []
+ self.containerView.subviews.forEach { if $0 !== self.logWidget { $0.removeFromSuperview() } }
+ self.presetLabel.text = preset.presetName
+
+ let titleUid = "title-big"
+ let titleFrame = savedFrame(uid: titleUid, in: savedLayout) ?? CGRect(x: 16, y: 16, width: 300, height: 44)
+ let titleWidget = TitleWidget(uid: titleUid, text: preset.displayTitle, frame: titleFrame)
+ self.setupDrag(on: titleWidget)
+ self.containerView.addSubview(titleWidget)
+ self.widgets[titleUid] = titleWidget
+
+ var faderIdx = 0
+ var squareIdx = 0
+
+ for f in preset.faders where f.visible {
+ let frame = savedFrame(uid: f.uid, in: savedLayout) ?? FaderWidget.defaultFrame(idx: faderIdx)
+ let w = FaderWidget(uid: f.uid,
+ label: f.label,
+ value: f.value,
+ color: UIColor.fromHex(f.color),
+ frame: frame)
+ w.onSend = { [weak self] uid, val in self?.send(["event": "control", "uid": uid, "value": val]) }
+ self.setupDrag(on: w)
+ self.containerView.addSubview(w)
+ self.widgets[f.uid] = w
+ faderIdx += 1
+ }
+
+ for t in preset.toggles where t.visible {
+ let frame = savedFrame(uid: t.uid, in: savedLayout) ?? ToggleWidget.defaultFrame(idx: squareIdx)
+ let w = ToggleWidget(uid: t.uid,
+ label: t.label,
+ isOn: t.state,
+ color: UIColor.fromHex(t.color),
+ frame: frame)
+ w.onSend = { [weak self] uid, val in self?.send(["event": "control", "uid": uid, "value": val]) }
+ self.setupDrag(on: w)
+ self.containerView.addSubview(w)
+ self.widgets[t.uid] = w
+ squareIdx += 1
+ }
+
+ for t in preset.transports where t.visible {
+ let frame = savedFrame(uid: t.uid, in: savedLayout) ?? TransportWidget.defaultFrame(idx: squareIdx)
+ let w = TransportWidget(uid: t.uid,
+ label: t.label,
+ frame: frame)
+ w.onSend = { [weak self] uid, val in self?.send(["event": "control", "uid": uid, "value": val]) }
+ self.setupDrag(on: w)
+ self.containerView.addSubview(w)
+ self.widgets[t.uid] = w
+ squareIdx += 1
+ }
+
+ self.isLocked = !savedLayout.isEmpty
+ self.refreshToolbar()
+ }
+
+ // MARK: - Update Widget
+ func updateWidget(uid: String, value: Int) {
+ self.widgets[uid]?.update(value: value)
+ }
+
+ // MARK: - DAW State
+ func updateDawState(_ json: [String: Any]) {
+ let track = json["track"] as? String
+ let bar = json["bar"] as? Int
+ let playing = json["playing"] as? Bool
+
+ self.feedbackWidgets.forEach { $0.updateDaw(track: track, bar: bar, playing: playing) }
+
+ var parts: [String] = []
+ if let track = track { parts.append(track) }
+ if let bar = bar { parts.append("Bar \(bar)") }
+ if let playing = playing { parts.append(playing ? "▶" : "■") }
+ if !parts.isEmpty { self.statusLabel.text = parts.joined(separator: " ") }
+ }
+
+ // MARK: - Toolbar
+ @objc func arrangeTapped() {
+ self.isLocked = false
+ self.refreshToolbar()
+ }
+
+ @objc func lockTapped() {
+ self.isLocked = true
+ self.refreshToolbar()
+ self.saveLayout()
+ }
+
+ func refreshToolbar() {
+ let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
+ self.lockButton.backgroundColor = self.isLocked ? green : UIColor(white: 0.15, alpha: 1)
+ self.lockButton.setTitleColor(self.isLocked ? .black : .lightGray, for: .normal)
+ self.arrangeButton.backgroundColor = self.isLocked ? UIColor(white: 0.15, alpha: 1) : green
+ self.arrangeButton.setTitleColor(self.isLocked ? .lightGray : .black, for: .normal)
+ }
+
+ // MARK: - Save Layout
+ func saveLayout() {
+ guard !self.presetUuid.isEmpty else {
+ self.loggingView.log("saveLayout: aborted — presetUuid is empty")
+ return
+ }
+ var layoutDict: [String: Any] = [:]
+ for (uid, w) in self.widgets {
+ let f = w.frame
+ layoutDict[uid] = [
+ "x": Double(f.origin.x),
+ "y": Double(f.origin.y),
+ "w": Double(f.width),
+ "h": Double(f.height)
+ ]
+ }
+ UserDefaults.standard.set(layoutDict, forKey: "layout_\(self.presetUuid)")
+ self.loggingView.log("saveLayout: \(layoutDict.count) widgets saved locally for \(self.presetUuid)")
+ }
+
+ // MARK: - Drag
+ func setupDrag(on view: UIView) {
+ let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
+ view.addGestureRecognizer(pan)
+ }
+
+ @objc func handlePan(_ gesture: UIPanGestureRecognizer) {
+ guard !self.isLocked, let view = gesture.view else { return }
+ let translation = gesture.translation(in: self.containerView)
+ view.center = CGPoint(x: view.center.x + translation.x, y: view.center.y + translation.y)
+ gesture.setTranslation(.zero, in: self.containerView)
+ }
+
+ // MARK: - Helpers
+ func savedFrame(uid: String, in savedLayout: [String: Any]) -> CGRect? {
+ guard let saved = savedLayout[uid] as? [String: Any],
+ let x = saved["x"] as? CGFloat, let y = saved["y"] as? CGFloat,
+ let w = saved["w"] as? CGFloat, let h = saved["h"] as? CGFloat
+ else { return nil }
+ return CGRect(x: x, y: y, width: w, height: h)
+ }
+
+}
+
+// MARK: - WebSocket Delegate
+extension ObjectsViewController: URLSessionWebSocketDelegate {
+
+ func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
+ self.applyConnectionState(.connected)
+ }
+
+ func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
+ self.applyConnectionState(.disconnected)
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingFunctions.swift b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingFunctions.swift
new file mode 100644
index 0000000..48ff07b
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingFunctions.swift
@@ -0,0 +1,24 @@
+import UIKit
+
+extension LoggingObjects {
+
+ func log(_ message: String) {
+ DispatchQueue.main.async {
+ var display = message
+ if let data = message.data(using: .utf8),
+ let obj = try? JSONSerialization.jsonObject(with: data),
+ let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted),
+ let str = String(data: pretty, encoding: .utf8) {
+ display = str
+ }
+ let current = self.textView.text ?? ""
+ let separator = current.isEmpty ? "" : "\n--- ✦ ---\n"
+ self.textView.text = display + separator + current
+ }
+ }
+
+ @objc func clearTapped() {
+ self.textView.text = ""
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingObjects.swift b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingObjects.swift
new file mode 100644
index 0000000..efe7408
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingObjects.swift
@@ -0,0 +1,26 @@
+import UIKit
+
+class LoggingObjects: UIViewController {
+
+ lazy var textView: UITextView = {
+ let tv = UITextView()
+ tv.translatesAutoresizingMaskIntoConstraints = false
+ tv.backgroundColor = .clear
+ tv.textColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
+ tv.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
+ tv.isEditable = false
+ tv.isScrollEnabled = true
+ return tv
+ }()
+
+ lazy var clearButton: UIButton = {
+ let btn = UIButton(type: .system)
+ btn.translatesAutoresizingMaskIntoConstraints = false
+ btn.setTitle("Clear", for: .normal)
+ btn.setTitleColor(UIColor(white: 0.4, alpha: 1), for: .normal)
+ btn.titleLabel?.font = .systemFont(ofSize: 10)
+ btn.addTarget(self, action: #selector(self.clearTapped), for: .touchUpInside)
+ return btn
+ }()
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingUILayout.swift b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingUILayout.swift
new file mode 100644
index 0000000..b4e0662
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingUILayout.swift
@@ -0,0 +1,21 @@
+import UIKit
+
+extension LoggingObjects {
+
+ func setupUI() {
+ DispatchQueue.main.async {
+ self.view.backgroundColor = UIColor(white: 0.08, alpha: 1)
+
+ self.view.addSubview(self.clearButton)
+ self.clearButton.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 6).isActive = true
+ self.clearButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
+
+ self.view.addSubview(self.textView)
+ self.textView.topAnchor.constraint(equalTo: self.clearButton.bottomAnchor, constant: 2).isActive = true
+ self.textView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 4).isActive = true
+ self.textView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -4).isActive = true
+ self.textView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -4).isActive = true
+ }
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingView.swift b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingView.swift
new file mode 100644
index 0000000..b354193
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingView.swift
@@ -0,0 +1,10 @@
+import UIKit
+
+class LoggingView: LoggingObjects {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupUI()
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift b/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift
new file mode 100644
index 0000000..a9940d7
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+struct ModelPresetLoadToRemoteDevice: Codable {
+ let presetUuid: String
+ let presetName: String
+ let bigTitle: String
+ let faders: [ModelFaderPayload]
+ let toggles: [ModelTogglePayload]
+ let transports: [ModelTransportPayload]
+
+ var displayTitle: String {
+ bigTitle.isEmpty ? "No Title" : bigTitle
+ }
+}
+
+struct ModelFaderPayload: Codable {
+ let uid: String
+ let label: String
+ let value: Int
+ let visible: Bool
+ let color: String
+}
+
+struct ModelTogglePayload: Codable {
+ let uid: String
+ let label: String
+ let state: Bool
+ let visible: Bool
+ let color: String
+}
+
+struct ModelTransportPayload: Codable {
+ let uid: String
+ let label: String
+ let visible: Bool
+}
diff --git a/remote-client-ios/remote-client-ios/Core/ObjectsViewController.swift b/remote-client-ios/remote-client-ios/Core/ObjectsViewController.swift
new file mode 100644
index 0000000..d1eede7
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ObjectsViewController.swift
@@ -0,0 +1,157 @@
+import UIKit
+
+class ObjectsViewController: UIViewController {
+
+ // MARK: - WebSocket
+ var wsTask: URLSessionWebSocketTask?
+ var urlSession: URLSession?
+
+ // MARK: - Connection State
+ enum ConnectionState {
+ case notStarted, connecting, connected, disconnected
+ }
+ var connectionState: ConnectionState = .notStarted
+
+ // MARK: - State
+ var presetName: String = ""
+ var presetUuid: String = ""
+ var isLocked: Bool = true
+
+ // MARK: - Widgets
+ var widgets: [String: BaseWidget] = [:]
+ var feedbackWidgets: [FeedbackWidget] = []
+
+ // MARK: - Layout
+ var layout: [String: CGRect] = [:]
+
+ let containerView: UIView = {
+ let view = UIView()
+ view.translatesAutoresizingMaskIntoConstraints = false
+ view.backgroundColor = UIColor(white: 0.1, alpha: 1)
+ return view
+ }()
+
+ // MARK: - Toolbar
+ lazy var connectButton: UIButton = {
+ let btn = UIButton(type: .system)
+ btn.translatesAutoresizingMaskIntoConstraints = false
+ btn.setTitle("Connect", for: .normal)
+ btn.setTitleColor(.black, for: .normal)
+ btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
+ btn.layer.cornerRadius = 4
+ btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
+ btn.addTarget(self, action: #selector(self.connectTapped), for: .touchUpInside)
+ return btn
+ }()
+
+ lazy var arrangeButton: UIButton = {
+ let btn = UIButton(type: .system)
+ btn.translatesAutoresizingMaskIntoConstraints = false
+ btn.setTitle("Arrange", for: .normal)
+ btn.setTitleColor(.lightGray, for: .normal)
+ btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
+ btn.layer.borderWidth = 1
+ btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
+ btn.layer.cornerRadius = 4
+ btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
+ btn.addTarget(self, action: #selector(self.arrangeTapped), for: .touchUpInside)
+ return btn
+ }()
+
+ lazy var lockButton: UIButton = {
+ let btn = UIButton(type: .system)
+ btn.translatesAutoresizingMaskIntoConstraints = false
+ btn.setTitle("Lock / Save", for: .normal)
+ btn.setTitleColor(.black, for: .normal)
+ btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
+ btn.layer.cornerRadius = 4
+ btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
+ btn.addTarget(self, action: #selector(self.lockTapped), for: .touchUpInside)
+ return btn
+ }()
+
+ // MARK: - Connection Fields
+ let ipField: UITextField = {
+ let tf = UITextField()
+ tf.translatesAutoresizingMaskIntoConstraints = false
+ tf.text = "127.0.0.1"
+ tf.textColor = .lightGray
+ tf.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
+ tf.backgroundColor = UIColor(white: 0.12, alpha: 1)
+ tf.layer.borderWidth = 1
+ tf.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
+ tf.layer.cornerRadius = 4
+ tf.keyboardType = .decimalPad
+ tf.textAlignment = .center
+ tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: 0))
+ tf.leftViewMode = .always
+ tf.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: 0))
+ tf.rightViewMode = .always
+ return tf
+ }()
+
+ let portField: UITextField = {
+ let tf = UITextField()
+ tf.translatesAutoresizingMaskIntoConstraints = false
+ tf.text = "8765"
+ tf.textColor = .lightGray
+ tf.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
+ tf.backgroundColor = UIColor(white: 0.12, alpha: 1)
+ tf.layer.borderWidth = 1
+ tf.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
+ tf.layer.cornerRadius = 4
+ tf.keyboardType = .numberPad
+ tf.textAlignment = .center
+ tf.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: 0))
+ tf.leftViewMode = .always
+ tf.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 8, height: 0))
+ tf.rightViewMode = .always
+ return tf
+ }()
+
+ // MARK: - Logging
+ lazy var loggingView: LoggingView = LoggingView()
+
+ lazy var logWidget: LogWidget = {
+ let w = LogWidget(uid: "log-widget", frame: LogWidget.defaultFrame())
+ return w
+ }()
+
+ lazy var showLoggingButton: UIButton = {
+ let btn = UIButton(type: .system)
+ btn.translatesAutoresizingMaskIntoConstraints = false
+ btn.setTitle("Show Logging", for: .normal)
+ btn.setTitleColor(.lightGray, for: .normal)
+ btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
+ btn.layer.borderWidth = 1
+ btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
+ btn.layer.cornerRadius = 4
+ btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
+ btn.addTarget(self, action: #selector(self.showLoggingTapped), for: .touchUpInside)
+ return btn
+ }()
+
+ // MARK: - Labels
+ let statusLabel: UILabel = {
+ let lbl = UILabel()
+ lbl.translatesAutoresizingMaskIntoConstraints = false
+ lbl.text = "not started"
+ lbl.textColor = UIColor(white: 0.33, alpha: 1)
+ lbl.font = .systemFont(ofSize: 11)
+ return lbl
+ }()
+
+ let presetLabel: UILabel = {
+ let lbl = UILabel()
+ lbl.translatesAutoresizingMaskIntoConstraints = false
+ lbl.text = ""
+ lbl.textColor = UIColor(white: 0.33, alpha: 1)
+ lbl.font = .systemFont(ofSize: 11)
+ return lbl
+ }()
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/UILayout.swift b/remote-client-ios/remote-client-ios/Core/UILayout.swift
new file mode 100644
index 0000000..ef60028
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/UILayout.swift
@@ -0,0 +1,60 @@
+import UIKit
+
+extension ObjectsViewController {
+
+ func setupUI() {
+ DispatchQueue.main.async {
+ self.view.backgroundColor = UIColor(white: 0.1, alpha: 1)
+
+ // containerView
+ self.view.addSubview(self.containerView)
+ self.containerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 0).isActive = true
+ self.containerView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 0).isActive = true
+ self.containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true
+ self.containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
+
+ // statusLabel
+ self.view.addSubview(self.statusLabel)
+ self.statusLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
+ self.statusLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
+
+ // presetLabel
+ self.view.addSubview(self.presetLabel)
+ self.presetLabel.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -8).isActive = true
+ self.presetLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
+
+ // toolbar buttons (top-right)
+ self.view.addSubview(self.showLoggingButton)
+ self.showLoggingButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
+ self.showLoggingButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
+
+ self.view.addSubview(self.connectButton)
+ self.connectButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
+ self.connectButton.trailingAnchor.constraint(equalTo: self.showLoggingButton.leadingAnchor, constant: -6).isActive = true
+
+ self.view.addSubview(self.portField)
+ self.portField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
+ self.portField.trailingAnchor.constraint(equalTo: self.connectButton.leadingAnchor, constant: -6).isActive = true
+ self.portField.widthAnchor.constraint(equalToConstant: 60).isActive = true
+ self.portField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
+
+ self.view.addSubview(self.ipField)
+ self.ipField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
+ self.ipField.trailingAnchor.constraint(equalTo: self.portField.leadingAnchor, constant: -6).isActive = true
+ self.ipField.widthAnchor.constraint(equalToConstant: 120).isActive = true
+ self.ipField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
+
+ self.view.addSubview(self.lockButton)
+ self.lockButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
+ self.lockButton.trailingAnchor.constraint(equalTo: self.ipField.leadingAnchor, constant: -6).isActive = true
+
+ self.view.addSubview(self.arrangeButton)
+ self.arrangeButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
+ self.arrangeButton.trailingAnchor.constraint(equalTo: self.lockButton.leadingAnchor, constant: -6).isActive = true
+
+ self.containerView.addSubview(self.logWidget)
+ self.setupDrag(on: self.logWidget)
+ self.logWidget.attach(to: self, loggingView: self.loggingView)
+ }
+ }
+}
diff --git a/remote-client-ios/remote-client-ios/Core/View.swift b/remote-client-ios/remote-client-ios/Core/View.swift
new file mode 100644
index 0000000..e9289b3
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/View.swift
@@ -0,0 +1,19 @@
+//
+// ViewController.swift
+// remote-client-ios
+//
+// Created by p4piwabl0 on 6/30/26.
+//
+
+import UIKit
+
+class ViewController: ObjectsViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupUI()
+ }
+
+
+}
+
diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/BaseWidget.swift b/remote-client-ios/remote-client-ios/Core/ui-reusable/BaseWidget.swift
new file mode 100644
index 0000000..3e56f94
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ui-reusable/BaseWidget.swift
@@ -0,0 +1,35 @@
+import UIKit
+
+class BaseWidget: UIView {
+
+ let uid: String
+ var onSend: ((String, Int) -> Void)?
+
+ init(uid: String, frame: CGRect) {
+ self.uid = uid
+ super.init(frame: frame)
+ self.backgroundColor = UIColor(white: 0.13, alpha: 1)
+ self.layer.borderWidth = 1
+ self.layer.cornerRadius = 6
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ func update(value: Int) {}
+
+}
+
+extension UIColor {
+ static func fromHex(_ hex: String) -> UIColor {
+ var h = hex.trimmingCharacters(in: .alphanumerics.inverted)
+ if h.count == 6 { h = "FF" + h }
+ var int: UInt64 = 0
+ Scanner(string: h).scanHexInt64(&int)
+ return UIColor(
+ red: CGFloat((int >> 16) & 0xFF) / 255,
+ green: CGFloat((int >> 8) & 0xFF) / 255,
+ blue: CGFloat(int & 0xFF) / 255,
+ alpha: CGFloat((int >> 24) & 0xFF) / 255
+ )
+ }
+}
diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/FaderWidget.swift b/remote-client-ios/remote-client-ios/Core/ui-reusable/FaderWidget.swift
new file mode 100644
index 0000000..2532c23
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ui-reusable/FaderWidget.swift
@@ -0,0 +1,81 @@
+import UIKit
+
+class FaderWidget: BaseWidget {
+
+ static let size = CGSize(width: 140, height: 360)
+
+ static func defaultFrame(idx: Int) -> CGRect {
+ let col = idx % 3
+ let row = idx / 3
+ return CGRect(x: 16 + CGFloat(col) * 170, y: 48 + CGFloat(row) * 380, width: size.width, height: size.height)
+ }
+
+ // MARK: - Properties
+ let color: UIColor
+ private var sliderWidthConstraint: NSLayoutConstraint?
+
+ // MARK: - Subviews
+ lazy var nameLabel: UILabel = {
+ let lbl = UILabel()
+ lbl.translatesAutoresizingMaskIntoConstraints = false
+ lbl.textColor = UIColor(white: 0.53, alpha: 1)
+ lbl.font = .systemFont(ofSize: 14)
+ lbl.textAlignment = .center
+ return lbl
+ }()
+
+ lazy var slider: UISlider = {
+ let s = UISlider()
+ s.translatesAutoresizingMaskIntoConstraints = false
+ s.minimumValue = 0
+ s.maximumValue = 127
+ s.transform = CGAffineTransform(rotationAngle: -.pi / 2)
+ return s
+ }()
+
+ // MARK: - Init
+ init(uid: String, label: String, value: Int, color: UIColor, frame: CGRect) {
+ self.color = color
+ super.init(uid: uid, frame: frame)
+ self.layer.borderColor = color.cgColor
+ self.nameLabel.text = label
+ self.slider.value = Float(value)
+ self.slider.minimumTrackTintColor = color
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ // MARK: - Layout
+ func setupUI() {
+ self.addSubview(self.nameLabel)
+ self.nameLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 8).isActive = true
+ self.nameLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4).isActive = true
+ self.nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4).isActive = true
+
+ self.addSubview(self.slider)
+ self.slider.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
+ self.slider.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: 10).isActive = true
+ let wc = self.slider.widthAnchor.constraint(equalToConstant: 216)
+ wc.isActive = true
+ self.sliderWidthConstraint = wc
+
+ self.slider.addTarget(self, action: #selector(self.sliderChanged), for: .valueChanged)
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ self.sliderWidthConstraint?.constant = bounds.height * 0.6
+ }
+
+ // MARK: - Update
+ override func update(value: Int) {
+ self.slider.value = Float(value)
+ }
+
+ // MARK: - Action
+ @objc private func sliderChanged() {
+ self.onSend?(self.uid, Int(self.slider.value))
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/FeedbackWidget.swift b/remote-client-ios/remote-client-ios/Core/ui-reusable/FeedbackWidget.swift
new file mode 100644
index 0000000..00ac7b4
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ui-reusable/FeedbackWidget.swift
@@ -0,0 +1,81 @@
+import UIKit
+
+class FeedbackWidget: BaseWidget {
+
+ static let defaultSize = CGSize(width: 220, height: 100)
+
+ static func defaultFrame(idx: Int) -> CGRect {
+ let col = idx % 3
+ let row = idx / 3
+ return CGRect(x: 16 + CGFloat(col) * 250, y: 48 + CGFloat(row) * 120,
+ width: defaultSize.width, height: defaultSize.height)
+ }
+
+ // MARK: - Subviews
+ lazy var trackLabel: UILabel = {
+ let lbl = UILabel()
+ lbl.translatesAutoresizingMaskIntoConstraints = false
+ lbl.textColor = UIColor(white: 0.9, alpha: 1)
+ lbl.font = .systemFont(ofSize: 16, weight: .semibold)
+ lbl.textAlignment = .left
+ lbl.text = "—"
+ return lbl
+ }()
+
+ lazy var barLabel: UILabel = {
+ let lbl = UILabel()
+ lbl.translatesAutoresizingMaskIntoConstraints = false
+ lbl.textColor = UIColor(white: 0.6, alpha: 1)
+ lbl.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
+ lbl.textAlignment = .left
+ lbl.text = "Bar —"
+ return lbl
+ }()
+
+ lazy var stateLabel: UILabel = {
+ let lbl = UILabel()
+ lbl.translatesAutoresizingMaskIntoConstraints = false
+ lbl.textColor = UIColor(white: 0.5, alpha: 1)
+ lbl.font = .systemFont(ofSize: 20, weight: .medium)
+ lbl.textAlignment = .right
+ lbl.text = "■"
+ return lbl
+ }()
+
+ // MARK: - Init
+ override init(uid: String, frame: CGRect) {
+ super.init(uid: uid, frame: frame)
+ self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ // MARK: - Layout
+ func setupUI() {
+ self.addSubview(self.trackLabel)
+ self.trackLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 12).isActive = true
+ self.trackLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12).isActive = true
+ self.trackLabel.trailingAnchor.constraint(equalTo: self.stateLabel.leadingAnchor, constant: -8).isActive = true
+
+ self.addSubview(self.stateLabel)
+ self.stateLabel.centerYAnchor.constraint(equalTo: self.trackLabel.centerYAnchor).isActive = true
+ self.stateLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12).isActive = true
+
+ self.addSubview(self.barLabel)
+ self.barLabel.topAnchor.constraint(equalTo: self.trackLabel.bottomAnchor, constant: 8).isActive = true
+ self.barLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12).isActive = true
+ self.barLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12).isActive = true
+ }
+
+ // MARK: - Update
+ func updateDaw(track: String?, bar: Int?, playing: Bool?) {
+ if let track = track { self.trackLabel.text = track }
+ if let bar = bar { self.barLabel.text = "Bar \(bar)" }
+ if let playing = playing {
+ self.stateLabel.text = playing ? "▶" : "■"
+ self.stateLabel.textColor = playing ? UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1) : UIColor(white: 0.5, alpha: 1)
+ }
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/LogWidget.swift b/remote-client-ios/remote-client-ios/Core/ui-reusable/LogWidget.swift
new file mode 100644
index 0000000..202eb23
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ui-reusable/LogWidget.swift
@@ -0,0 +1,49 @@
+import UIKit
+
+class LogWidget: BaseWidget {
+
+ static let defaultSize = CGSize(width: 360, height: 660)
+
+ static func defaultFrame() -> CGRect {
+ CGRect(x: 16, y: 80, width: defaultSize.width, height: defaultSize.height)
+ }
+
+ private weak var hostedView: UIView?
+
+ override init(uid: String, frame: CGRect) {
+ super.init(uid: uid, frame: frame)
+ self.backgroundColor = UIColor(white: 0.08, alpha: 0.95)
+ self.layer.borderColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 0.3).cgColor
+ self.isHidden = true
+ setupPinch()
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ func attach(to parentVC: UIViewController, loggingView: LoggingView) {
+ parentVC.addChild(loggingView)
+ loggingView.view.frame = self.bounds
+ self.addSubview(loggingView.view)
+ loggingView.didMove(toParent: parentVC)
+ self.hostedView = loggingView.view
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ hostedView?.frame = self.bounds
+ }
+
+ private func setupPinch() {
+ let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
+ self.addGestureRecognizer(pinch)
+ }
+
+ @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
+ let scale = gesture.scale
+ let newWidth = max(200, min(800, self.bounds.width * scale))
+ let newHeight = max(100, min(600, self.bounds.height * scale))
+ self.frame = CGRect(origin: self.frame.origin, size: CGSize(width: newWidth, height: newHeight))
+ gesture.scale = 1
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/TitleWidget.swift b/remote-client-ios/remote-client-ios/Core/ui-reusable/TitleWidget.swift
new file mode 100644
index 0000000..f5cb7ab
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ui-reusable/TitleWidget.swift
@@ -0,0 +1,40 @@
+import UIKit
+
+class TitleWidget: BaseWidget {
+
+ static func defaultFrame(idx: Int) -> CGRect {
+ let col = idx % 3
+ let row = idx / 3
+ return CGRect(x: 16 + CGFloat(col) * 220, y: 16 + CGFloat(row) * 60, width: 200, height: 44)
+ }
+
+ // MARK: - Subviews
+ lazy var titleLabel: UILabel = {
+ let lbl = UILabel()
+ lbl.translatesAutoresizingMaskIntoConstraints = false
+ lbl.textColor = UIColor(white: 0.85, alpha: 1)
+ lbl.font = .systemFont(ofSize: 22, weight: .semibold)
+ lbl.textAlignment = .left
+ return lbl
+ }()
+
+ // MARK: - Init
+ init(uid: String, text: String, frame: CGRect) {
+ super.init(uid: uid, frame: frame)
+ self.backgroundColor = .clear
+ self.layer.borderColor = UIColor.clear.cgColor
+ self.titleLabel.text = text
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ // MARK: - Layout
+ func setupUI() {
+ self.addSubview(self.titleLabel)
+ self.titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4).isActive = true
+ self.titleLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4).isActive = true
+ self.titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/ToggleWidget.swift b/remote-client-ios/remote-client-ios/Core/ui-reusable/ToggleWidget.swift
new file mode 100644
index 0000000..56973ef
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ui-reusable/ToggleWidget.swift
@@ -0,0 +1,71 @@
+import UIKit
+
+class ToggleWidget: BaseWidget {
+
+ static let size = CGSize(width: 75, height: 75)
+
+ static func defaultFrame(idx: Int) -> CGRect {
+ let gap: CGFloat = 12
+ let col = idx % 6
+ let row = idx / 6
+ return CGRect(x: 16 + CGFloat(col) * (size.width + gap),
+ y: 420 + CGFloat(row) * (size.height + gap),
+ width: size.width, height: size.height)
+ }
+
+ // MARK: - Properties
+ let color: UIColor
+ var isOn: Bool
+
+ // MARK: - Subviews
+ lazy var button: UIButton = {
+ let btn = UIButton(type: .system)
+ btn.translatesAutoresizingMaskIntoConstraints = false
+ btn.layer.cornerRadius = 4
+ btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold)
+ return btn
+ }()
+
+ // MARK: - Init
+ init(uid: String, label: String, isOn: Bool, color: UIColor, frame: CGRect) {
+ self.color = color
+ self.isOn = isOn
+ super.init(uid: uid, frame: frame)
+ self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
+ self.button.setTitle(label, for: .normal)
+ setupUI()
+ applyState()
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ // MARK: - Layout
+ func setupUI() {
+ self.addSubview(self.button)
+ self.button.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
+ self.button.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
+ self.button.widthAnchor.constraint(equalToConstant: 63).isActive = true
+ self.button.heightAnchor.constraint(equalToConstant: 63).isActive = true
+
+ self.button.addTarget(self, action: #selector(self.tapped), for: .touchUpInside)
+ }
+
+ // MARK: - State
+ func applyState() {
+ self.button.backgroundColor = self.isOn ? self.color : UIColor(white: 0.15, alpha: 1)
+ self.button.setTitleColor(self.isOn ? .black : .lightGray, for: .normal)
+ }
+
+ override func update(value: Int) {
+ self.isOn = value > 63
+ applyState()
+ }
+
+ // MARK: - Action
+ @objc private func tapped() {
+ self.isOn = !self.isOn
+ applyState()
+ self.onSend?(self.uid, self.isOn ? 127 : 0)
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/TransportWidget.swift b/remote-client-ios/remote-client-ios/Core/ui-reusable/TransportWidget.swift
new file mode 100644
index 0000000..15bfa22
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Core/ui-reusable/TransportWidget.swift
@@ -0,0 +1,53 @@
+import UIKit
+
+class TransportWidget: BaseWidget {
+
+ static let size = CGSize(width: 75, height: 75)
+
+ static func defaultFrame(idx: Int) -> CGRect {
+ let gap: CGFloat = 12
+ let col = idx % 6
+ let row = idx / 6
+ return CGRect(x: 16 + CGFloat(col) * (size.width + gap),
+ y: 420 + CGFloat(row) * (size.height + gap),
+ width: size.width, height: size.height)
+ }
+
+ // MARK: - Subviews
+ lazy var button: UIButton = {
+ let btn = UIButton(type: .system)
+ btn.translatesAutoresizingMaskIntoConstraints = false
+ btn.setTitleColor(.lightGray, for: .normal)
+ btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
+ btn.layer.cornerRadius = 4
+ btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold)
+ return btn
+ }()
+
+ // MARK: - Init
+ init(uid: String, label: String, frame: CGRect) {
+ super.init(uid: uid, frame: frame)
+ self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
+ self.button.setTitle(label, for: .normal)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ // MARK: - Layout
+ func setupUI() {
+ self.addSubview(self.button)
+ self.button.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
+ self.button.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
+ self.button.widthAnchor.constraint(equalToConstant: 63).isActive = true
+ self.button.heightAnchor.constraint(equalToConstant: 63).isActive = true
+
+ self.button.addTarget(self, action: #selector(self.tapped), for: .touchUpInside)
+ }
+
+ // MARK: - Action
+ @objc private func tapped() {
+ self.onSend?(self.uid, 127)
+ }
+
+}
diff --git a/remote-client-ios/remote-client-ios/Info.plist b/remote-client-ios/remote-client-ios/Info.plist
new file mode 100644
index 0000000..dd3c9af
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/Info.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+
+
diff --git a/remote-client-ios/remote-client-ios/SceneDelegate.swift b/remote-client-ios/remote-client-ios/SceneDelegate.swift
new file mode 100644
index 0000000..519dba9
--- /dev/null
+++ b/remote-client-ios/remote-client-ios/SceneDelegate.swift
@@ -0,0 +1,52 @@
+//
+// SceneDelegate.swift
+// remote-client-ios
+//
+// Created by p4piwabl0 on 6/30/26.
+//
+
+import UIKit
+
+class SceneDelegate: UIResponder, UIWindowSceneDelegate {
+
+ var window: UIWindow?
+
+
+ func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
+ guard let windowScene = (scene as? UIWindowScene) else { return }
+ window = UIWindow(windowScene: windowScene)
+ window?.rootViewController = ViewController()
+ window?.makeKeyAndVisible()
+ }
+
+ func sceneDidDisconnect(_ scene: UIScene) {
+ // Called as the scene is being released by the system.
+ // This occurs shortly after the scene enters the background, or when its session is discarded.
+ // Release any resources associated with this scene that can be re-created the next time the scene connects.
+ // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
+ }
+
+ func sceneDidBecomeActive(_ scene: UIScene) {
+ // Called when the scene has moved from an inactive state to an active state.
+ // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
+ }
+
+ func sceneWillResignActive(_ scene: UIScene) {
+ // Called when the scene will move from an active state to an inactive state.
+ // This may occur due to temporary interruptions (ex. an incoming phone call).
+ }
+
+ func sceneWillEnterForeground(_ scene: UIScene) {
+ // Called as the scene transitions from the background to the foreground.
+ // Use this method to undo the changes made on entering the background.
+ }
+
+ func sceneDidEnterBackground(_ scene: UIScene) {
+ // Called as the scene transitions from the foreground to the background.
+ // Use this method to save data, release shared resources, and store enough scene-specific state information
+ // to restore the scene back to its current state.
+ }
+
+
+}
+
diff --git a/remote-client-ios/remote-client-iosTests/remote_client_iosTests.swift b/remote-client-ios/remote-client-iosTests/remote_client_iosTests.swift
new file mode 100644
index 0000000..e1cd90a
--- /dev/null
+++ b/remote-client-ios/remote-client-iosTests/remote_client_iosTests.swift
@@ -0,0 +1,17 @@
+//
+// remote_client_iosTests.swift
+// remote-client-iosTests
+//
+// Created by p4piwabl0 on 6/30/26.
+//
+
+import Testing
+@testable import remote_client_ios
+
+struct remote_client_iosTests {
+
+ @Test func example() async throws {
+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+ }
+
+}
diff --git a/remote-client-ios/remote-client-iosUITests/remote_client_iosUITests.swift b/remote-client-ios/remote-client-iosUITests/remote_client_iosUITests.swift
new file mode 100644
index 0000000..1d64ac1
--- /dev/null
+++ b/remote-client-ios/remote-client-iosUITests/remote_client_iosUITests.swift
@@ -0,0 +1,41 @@
+//
+// remote_client_iosUITests.swift
+// remote-client-iosUITests
+//
+// Created by p4piwabl0 on 6/30/26.
+//
+
+import XCTest
+
+final class remote_client_iosUITests: XCTestCase {
+
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+
+ // In UI tests it is usually best to stop immediately when a failure occurs.
+ continueAfterFailure = false
+
+ // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ @MainActor
+ func testExample() throws {
+ // UI tests must launch the application that they test.
+ let app = XCUIApplication()
+ app.launch()
+
+ // Use XCTAssert and related functions to verify your tests produce the correct results.
+ }
+
+ @MainActor
+ func testLaunchPerformance() throws {
+ // This measures how long it takes to launch your application.
+ measure(metrics: [XCTApplicationLaunchMetric()]) {
+ XCUIApplication().launch()
+ }
+ }
+}
diff --git a/remote-client-ios/remote-client-iosUITests/remote_client_iosUITestsLaunchTests.swift b/remote-client-ios/remote-client-iosUITests/remote_client_iosUITestsLaunchTests.swift
new file mode 100644
index 0000000..8c330b0
--- /dev/null
+++ b/remote-client-ios/remote-client-iosUITests/remote_client_iosUITestsLaunchTests.swift
@@ -0,0 +1,33 @@
+//
+// remote_client_iosUITestsLaunchTests.swift
+// remote-client-iosUITests
+//
+// Created by p4piwabl0 on 6/30/26.
+//
+
+import XCTest
+
+final class remote_client_iosUITestsLaunchTests: XCTestCase {
+
+ override class var runsForEachTargetApplicationUIConfiguration: Bool {
+ true
+ }
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ }
+
+ @MainActor
+ func testLaunch() throws {
+ let app = XCUIApplication()
+ app.launch()
+
+ // Insert steps here to perform after app launch but before taking a screenshot,
+ // such as logging into a test account or navigating somewhere in the app
+
+ let attachment = XCTAttachment(screenshot: app.screenshot())
+ attachment.name = "Launch Screen"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+}
diff --git a/server/http_server.py b/server/http_server.py
deleted file mode 100644
index 5ec8071..0000000
--- a/server/http_server.py
+++ /dev/null
@@ -1,25 +0,0 @@
-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()
diff --git a/server/ws_server.py b/server/ws_server.py
index 959e9e3..61b7293 100644
--- a/server/ws_server.py
+++ b/server/ws_server.py
@@ -9,6 +9,7 @@ class WSServer(QObject):
layout_saved = pyqtSignal(str, object)
client_connected = pyqtSignal()
log_signal = pyqtSignal(str)
+ raw_in_signal = pyqtSignal(str)
def __init__(self, port=8765, parent=None):
super().__init__(parent)
@@ -37,6 +38,7 @@ class WSServer(QObject):
self.log_signal.emit(f"client disconnected {peer} ({len(self._clients)} remaining)")
def _on_message(self, message):
+ self.raw_in_signal.emit(message)
try:
data = json.loads(message)
ev = data.get("event")
@@ -64,7 +66,8 @@ class WSServer(QObject):
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})
+ # layout omitted — owned and persisted client-side, keyed by preset_uuid
+ self.broadcast({"event": "preset", "data": snapshot})
def broadcast_widget_update(self, uid: str, value: int):
self.broadcast({"event": "widget_update", "uid": uid, "value": value})