Multi-iPad dest_id routing, activity ring, context menu hide, live label/visibility sync

Desktop:
- Replace Remote checkbox with T: dest_spin (0=Off, 1-9=tablet) on all widgets
- dest_id persisted in get_state/set_state, broadcast in tablet snapshot
- dest_spin.valueChanged triggers save + broadcast to iPad
- _on_widget_visibility_from_tablet updates dest_spin from iPad hide event
- _broadcast_preset_update shared helper; ws_server.has_clients guard
- label editingFinished triggers live label sync to iPad

iOS:
- VC-Arrange/VC-Logging/Widgets-Reusable-UI file reorganisation
- ArrangeObjects/ArrangeView rename, 7-bucket Arrange-Functions split
- myTabletId (UserDefaults) + tabletIdField next to port in toolbar
- Preset filter: destId == myTabletId instead of visible bool
- BaseWidget activity ring: long press gesture (touch) + final update() flash
- Context menu long press (arrange mode): hide widget, save layout, send dest_id:0
- Same-UUID preset check skips Preset Switched alert on visibility/label updates
- autoSwitchEnabled, toolbarStatusLabel, presetLabel, statusToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-02 21:56:35 -04:00
parent 7a74cfa75e
commit d6201c59b4
32 changed files with 1042 additions and 501 deletions
+14 -1
View File
@@ -155,6 +155,15 @@ class FaderWidget(QWidget):
self.pickup_checkbox.stateChanged.connect(self.on_pickup_changed)
inner.addWidget(self.pickup_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
# Remote checkbox
self.dest_spin = QSpinBox()
self.dest_spin.setRange(0, 9)
self.dest_spin.setSpecialValueText("Off")
self.dest_spin.setPrefix("T:")
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
inner.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
# Scribble strip
self.title_edit = QLineEdit(label)
self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
@@ -437,7 +446,8 @@ class FaderWidget(QWidget):
"hw_channel": self.hw_channel,
"zone": self.zone,
"center_index": self.center_index,
"zone_index": self.zone_index
"zone_index": self.zone_index,
"dest_id": self.dest_spin.value(),
}
def set_state(self, state):
@@ -464,6 +474,9 @@ class FaderWidget(QWidget):
self.zone_btn.set_state(self.zone == "left", self.zone == "right")
self.hw_cc = state.get("hw_cc", None)
self.hw_channel = state.get("hw_channel", None)
self.dest_spin.blockSignals(True)
self.dest_spin.setValue(state.get("dest_id", 1))
self.dest_spin.blockSignals(False)
if self.hw_cc is not None:
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
self.cc_input_lbl.setText(self._cc_input_label())
+54 -4
View File
@@ -140,6 +140,16 @@ menuPresets = QComboBox()
menuPresets.setMinimumWidth(160)
menuPresets.currentTextChanged.connect(lambda name: on_preset_selected(menuPresets, app_window, ready, loaded_presets, on_load=preset_load))
buttonPresetPrev = QPushButton("")
buttonPresetPrev.setFixedWidth(28)
buttonPresetPrev.setToolTip("Previous preset")
buttonPresetPrev.clicked.connect(lambda: menuPresets.setCurrentIndex(max(0, menuPresets.currentIndex() - 1)))
buttonPresetNext = QPushButton("")
buttonPresetNext.setFixedWidth(28)
buttonPresetNext.setToolTip("Next preset")
buttonPresetNext.clicked.connect(lambda: menuPresets.setCurrentIndex(min(menuPresets.count() - 1, menuPresets.currentIndex() + 1)))
#button new blank preset
buttonNew = QPushButton("New")
@@ -781,6 +791,8 @@ for _label, _cc in [("Play", 117), ("Stop", 116), ("Record", 118), ("Arm", 119)]
_t.on_select_callback = lambda strip: check_and_update_selected_strip(strip)
_t.on_context_menu_callback = lambda strip, pos: strip_context_menu(strip, pos)
_t.on_zone_change_callback = lambda strip: handle_transport_zone_change(strip)
_t.dest_spin.valueChanged.connect(lambda val: _on_remote_checkbox_changed())
_t.title_edit.editingFinished.connect(lambda: _broadcast_preset_update())
del _t, _label, _cc
# MARK: SELECTED CHANNEL ROW MODULE
@@ -964,6 +976,8 @@ class MainWindow(QMainWindow):
main_layout.addLayout(hstack_preset_row)
hstack_preset_row.addWidget(menuPresets) #dropdown
hstack_preset_row.addWidget(buttonPresetPrev)
hstack_preset_row.addWidget(buttonPresetNext)
hstack_preset_row.addWidget(buttonNew)
hstack_preset_row.addWidget(buttonSave) #save button
hstack_preset_row.addWidget(buttonSaveAs) #save as button
@@ -2181,6 +2195,8 @@ def toggle_add(insert_after=None):
toggle.on_context_menu_callback = strip_context_menu
toggle.on_zone_change_callback = handle_toggle_zone_change
toggle.learn_btn.clicked.connect(lambda: check_and_start_midi_learn(toggle))
toggle.dest_spin.valueChanged.connect(lambda val: _on_remote_checkbox_changed())
toggle.title_edit.editingFinished.connect(_broadcast_preset_update)
update_window_width()
check_and_index_strip_labels()
_update_toggle_placeholder()
@@ -2214,6 +2230,8 @@ def fader_add(insert_after=None):
fader.on_select_callback = check_and_update_selected_strip
fader.on_context_menu_callback = strip_context_menu
fader.on_zone_change_callback = handle_fader_zone_change
fader.dest_spin.valueChanged.connect(lambda val: _on_remote_checkbox_changed())
fader.title_edit.editingFinished.connect(_broadcast_preset_update)
update_window_width()
check_and_index_strip_labels()
_update_fader_placeholder()
@@ -2238,6 +2256,8 @@ def transport_toggle_add(label, default_cc, insert_after=None):
t.on_zone_change_callback = lambda strip: handle_transport_zone_change(strip)
t.plus_btn.clicked.connect(lambda: transport_toggle_add(f"T{len(transports)+1}", 20, insert_after=t))
t.minus_btn.clicked.connect(lambda: transport_remove(t))
t.dest_spin.valueChanged.connect(lambda val: _on_remote_checkbox_changed())
t.title_edit.editingFinished.connect(_broadcast_preset_update)
params_visible = not transport_show_hide_config
for attr in ['grp_learn', 'arrow_lbl', 'grp_cc', 'zone_btn', 'minus_btn',
'plus_btn', 'title_edit', 'id_sep', 'id_label', 'color_swatch_btn']:
@@ -2351,9 +2371,9 @@ def _build_tablet_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],
"faders": [{"uid": f.uid, "label": f.title_edit.text(), "value": f.fader.value(), "dest_id": f.dest_spin.value(), "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, "dest_id": t.dest_spin.value(), "color": PALETTE_HEX.get(t.color_name, "#00e676")} for t in toggles],
"transports": [{"uid": t.uid, "label": t.title_edit.text(), "dest_id": t.dest_spin.value()} for t in transports],
}
return snapshot, saved_layout
@@ -2365,6 +2385,35 @@ def _on_tablet_connected():
log_ws_outgoing(snapshot)
def _on_widget_visibility_from_tablet(uid, dest_id):
for w in faders + toggles + transports:
if w.uid == uid:
w.dest_spin.blockSignals(True)
w.dest_spin.setValue(dest_id)
w.dest_spin.blockSignals(False)
name = menuPresets.currentText()
if name and name in loaded_presets.get("presets", {}):
loaded_presets["presets"][name].update(preset_build_snapshot(name))
save_presets_file(loaded_presets)
show_save_status()
break
def _broadcast_preset_update():
name = menuPresets.currentText()
if name and name in loaded_presets.get("presets", {}):
loaded_presets["presets"][name].update(preset_build_snapshot(name))
save_presets_file(loaded_presets)
if ws_server and ws_server.has_clients:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
log_ws_outgoing(snapshot)
def _on_remote_checkbox_changed():
_broadcast_preset_update()
def _on_tablet_layout_saved(preset_uuid, layout):
name = menuPresets.currentText()
if name and name in loaded_presets.get("presets", {}):
@@ -2498,7 +2547,7 @@ def preset_load(preset):
check_and_index_strip_labels()
if ws_server:
if ws_server and ws_server.has_clients:
snapshot, layout = _build_tablet_snapshot()
ws_server.broadcast_preset(snapshot, layout)
log_ws_outgoing(snapshot)
@@ -2865,6 +2914,7 @@ def _start_tablet_server():
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.widget_visibility_received.connect(_on_widget_visibility_from_tablet)
ws_server.client_connected.connect(_on_tablet_connected)
try:
s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
+14 -1
View File
@@ -2,7 +2,7 @@ import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy, QCheckBox
)
from PyQt6.QtCore import Qt, QEvent, QTimer
@@ -180,6 +180,15 @@ class ToggleWidget(QWidget):
toggle_btn_row.addWidget(self.plus_btn)
inner.addLayout(toggle_btn_row)
# Remote checkbox
self.dest_spin = QSpinBox()
self.dest_spin.setRange(0, 9)
self.dest_spin.setSpecialValueText("Off")
self.dest_spin.setPrefix("T:")
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
inner.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
# Zone button
self.zone_btn = ZoneButton()
self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active)
@@ -370,6 +379,7 @@ class ToggleWidget(QWidget):
"hw_cc": self.hw_cc,
"hw_channel": self.hw_channel,
"trigger_mode": self.trigger_mode,
"dest_id": self.dest_spin.value(),
}
def set_state(self, state):
@@ -394,6 +404,9 @@ class ToggleWidget(QWidget):
self.hw_cc = state.get("hw_cc", None)
self.hw_channel = state.get("hw_channel", None)
self.set_trigger_mode(state.get("trigger_mode", "toggle"))
self.dest_spin.blockSignals(True)
self.dest_spin.setValue(state.get("dest_id", 1))
self.dest_spin.blockSignals(False)
if self.hw_cc is not None:
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
self.cc_input_lbl.setText(self._cc_input_label())
+14 -1
View File
@@ -2,7 +2,7 @@ import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy, QCheckBox
)
from PyQt6.QtCore import Qt, QEvent, QTimer
@@ -195,6 +195,15 @@ class TransportWidget(QWidget):
btn_row.addWidget(self.plus_btn)
inner.addLayout(btn_row)
# Remote checkbox
self.dest_spin = QSpinBox()
self.dest_spin.setRange(0, 9)
self.dest_spin.setSpecialValueText("Off")
self.dest_spin.setPrefix("T:")
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
inner.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
# Zone button
self.zone_btn = ZoneButton()
self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active)
@@ -378,6 +387,7 @@ class TransportWidget(QWidget):
"output_mode": self.output_mode,
"osc_addr": self.osc_addr_edit.text(),
"trigger_mode": self.trigger_mode,
"dest_id": self.dest_spin.value(),
}
def set_state(self, state):
@@ -404,3 +414,6 @@ class TransportWidget(QWidget):
self.osc_addr_edit.setText(state.get("osc_addr", "/play"))
self.set_output_mode(state.get("output_mode", "midi"))
self.set_trigger_mode(state.get("trigger_mode", "momentary"))
self.dest_spin.blockSignals(True)
self.dest_spin.setValue(state.get("dest_id", 1))
self.dest_spin.blockSignals(False)
+138 -123
View File
@@ -1,5 +1,5 @@
{
"__last_used__": "preset3",
"__last_used__": "rename whatever",
"presets": {
"preset1": {
"faders": [
@@ -16,22 +16,24 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
"label": "Fader 2",
"cc": 1,
"ch": 1,
"value": 83,
"value": 96,
"color": "Gray",
"pickup": false,
"last_sent": 83,
"last_sent": 96,
"hw_cc": null,
"hw_channel": null,
"zone": "right",
"center_index": 1,
"zone_index": -1
"zone_index": -1,
"remote": true
},
{
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
@@ -46,7 +48,8 @@
"hw_channel": null,
"zone": "left",
"center_index": 2,
"zone_index": -1
"zone_index": -1,
"remote": true
}
],
"toggles": [
@@ -62,7 +65,8 @@
"zone_index": -1,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
},
{
"uid": "73962e37-53b3-400f-a7e2-25ef7f1ea583",
@@ -76,7 +80,8 @@
"zone_index": 0,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
},
{
"uid": "b3cf419a-5183-4272-bd6f-44f858afbec8",
@@ -90,7 +95,8 @@
"zone_index": 1,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "momentary"
"trigger_mode": "momentary",
"remote": true
},
{
"uid": "c179bfea-deec-416a-b4aa-74e81b5cbe03",
@@ -104,7 +110,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
},
{
"uid": "a5a50618-fc4c-446e-868e-e4eafa6254e7",
@@ -118,7 +125,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
},
{
"uid": "f1775380-3390-4fbb-bd8b-8c30ff18f72a",
@@ -132,7 +140,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
},
{
"uid": "2064f2ca-6d18-468b-bf45-9bcb1fa1247d",
@@ -146,7 +155,8 @@
"zone_index": -1,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
}
],
"browser": {
@@ -170,7 +180,7 @@
"big_title": "STRING THING",
"preset_uuid": "8cbceb6b-ba5d-4b2c-8989-f921e4e3150d",
"show_hide": {
"fader": true,
"fader": false,
"toggle": true,
"transport": true,
"browser": true
@@ -215,7 +225,7 @@
"faders": [
{
"uid": "c98d2009-d186-489a-8883-ccbd612cb4af",
"label": "Fader 1",
"label": "trumpet whatever",
"cc": 1,
"ch": 1,
"value": 43,
@@ -226,7 +236,8 @@
"hw_channel": null,
"zone": "left",
"center_index": 0,
"zone_index": -1
"zone_index": -1,
"remote": true
},
{
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
@@ -241,7 +252,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
@@ -256,7 +268,8 @@
"hw_channel": null,
"zone": "right",
"center_index": 2,
"zone_index": -1
"zone_index": -1,
"remote": true
}
],
"toggles": [
@@ -272,7 +285,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
}
],
"browser": {
@@ -307,79 +321,44 @@
"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": [],
"toggles": [],
"preset_uuid": "",
"browser": {},
"show_hide": {}
"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": "whatever shit",
"preset_uuid": "772728ff-6654-46b6-89ce-b7b073b29643",
"show_hide": {
"fader": false,
"toggle": false,
"transport": true,
"browser": false
},
"sections": {
"preset_browser": true,
"big_title": true,
"fader_row": true,
"toggle_row": true,
"transport_row": true
}
},
"preset5": {
"faders": [
@@ -388,30 +367,32 @@
"label": "Fader 1",
"cc": 90,
"ch": 15,
"value": 85,
"value": 103,
"color": "Gray",
"pickup": false,
"last_sent": 85,
"last_sent": 103,
"hw_cc": null,
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "5aaecf41-3455-4fa2-bef1-ca02f5933a69",
"label": "Fader 2",
"cc": 1,
"ch": 1,
"value": 64,
"value": 89,
"color": "Gray",
"pickup": false,
"last_sent": 64,
"last_sent": 89,
"hw_cc": null,
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "e8e8cca8-3bad-49a5-8c91-d3a78d25a1eb",
@@ -426,7 +407,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "ae9cd47a-96af-45a4-83e6-816916a65e0e",
@@ -441,7 +423,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "fee53fde-2f59-4800-8873-3d75ca619366",
@@ -456,11 +439,12 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "1783a98d-16c9-4a06-ae4d-0f6ab36d4421",
"label": "Fader 6",
"label": "WOOOOW",
"cc": 1,
"ch": 1,
"value": 64,
@@ -471,7 +455,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
},
{
"uid": "2a55bdef-83aa-458f-a37d-e12a61e3e6a0",
@@ -486,7 +471,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"remote": true
}
],
"toggles": [
@@ -502,7 +488,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"remote": true
}
],
"browser": {
@@ -542,7 +529,6 @@
"new preset browser shit": {
"faders": [],
"toggles": [],
"preset_uuid": "",
"browser": {
"back": {
"label": "Back",
@@ -561,39 +547,55 @@
"trigger_mode": "momentary"
}
},
"show_hide": {}
"big_title": "",
"preset_uuid": "a2cf2b56-b18f-435c-a340-0ff22e9074f4",
"show_hide": {
"fader": false,
"toggle": false,
"transport": true,
"browser": false
},
"sections": {
"preset_browser": true,
"big_title": true,
"fader_row": true,
"toggle_row": true,
"transport_row": true
}
},
"rename whatever": {
"faders": [
{
"uid": "14ec66c2-6a0a-418e-9240-776cb13f6e2e",
"label": "Fader 1",
"label": "toots",
"cc": 90,
"ch": 15,
"value": 85,
"value": 59,
"color": "Blue",
"pickup": false,
"last_sent": 85,
"last_sent": 59,
"hw_cc": null,
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"dest_id": 2
},
{
"uid": "5aaecf41-3455-4fa2-bef1-ca02f5933a69",
"label": "Fader 2",
"cc": 1,
"ch": 1,
"value": 79,
"value": 66,
"color": "Blue",
"pickup": false,
"last_sent": 79,
"last_sent": 66,
"hw_cc": null,
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"dest_id": 1
},
{
"uid": "e8e8cca8-3bad-49a5-8c91-d3a78d25a1eb",
@@ -608,7 +610,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"dest_id": 1
},
{
"uid": "ae9cd47a-96af-45a4-83e6-816916a65e0e",
@@ -623,7 +626,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"dest_id": 1
},
{
"uid": "fee53fde-2f59-4800-8873-3d75ca619366",
@@ -638,7 +642,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"dest_id": 1
},
{
"uid": "1783a98d-16c9-4a06-ae4d-0f6ab36d4421",
@@ -653,7 +658,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"dest_id": 1
},
{
"uid": "2a55bdef-83aa-458f-a37d-e12a61e3e6a0",
@@ -668,7 +674,8 @@
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null
"zone_index": null,
"dest_id": 1
}
],
"toggles": [
@@ -684,7 +691,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"dest_id": 1
},
{
"uid": "dbf062b0-6dfe-4a7b-af6a-cf47a943bdec",
@@ -698,7 +706,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"dest_id": 1
},
{
"uid": "93d02fb4-5327-4f0c-8cbd-6f0eb24f7195",
@@ -712,7 +721,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"dest_id": 1
},
{
"uid": "3147496a-7380-401b-9b95-947dc5458886",
@@ -726,7 +736,8 @@
"zone_index": null,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle"
"trigger_mode": "toggle",
"dest_id": 1
}
],
"browser": {
@@ -777,7 +788,8 @@
"hw_cc": null,
"output_mode": "osc",
"osc_addr": "t/1/play",
"trigger_mode": "momentary"
"trigger_mode": "momentary",
"dest_id": 1
},
{
"uid": "e2e8eb84-c2c9-4060-a818-b087ebd5aabd",
@@ -791,7 +803,8 @@
"hw_cc": null,
"output_mode": "osc",
"osc_addr": "t/1/stop",
"trigger_mode": "momentary"
"trigger_mode": "momentary",
"dest_id": 1
},
{
"uid": "6b5bcaf7-1d01-4ccd-b23a-ebae8da57a7f",
@@ -805,7 +818,8 @@
"hw_cc": null,
"output_mode": "osc",
"osc_addr": "t/1/record",
"trigger_mode": "momentary"
"trigger_mode": "momentary",
"dest_id": 1
},
{
"uid": "2a1fcb50-b9f2-4c33-bce0-5bd5cadd9c46",
@@ -819,7 +833,8 @@
"hw_cc": null,
"output_mode": "osc",
"osc_addr": "t/1/recenable/1/@",
"trigger_mode": "momentary"
"trigger_mode": "momentary",
"dest_id": 1
}
],
"transport_show_hide": true,
@@ -1,283 +0,0 @@
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)
}
}
@@ -1,24 +0,0 @@
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 = ""
}
}
@@ -17,7 +17,7 @@ struct ModelFaderPayload: Codable {
let uid: String
let label: String
let value: Int
let visible: Bool
let destId: Int
let color: String
}
@@ -25,12 +25,12 @@ struct ModelTogglePayload: Codable {
let uid: String
let label: String
let state: Bool
let visible: Bool
let destId: Int
let color: String
}
struct ModelTransportPayload: Codable {
let uid: String
let label: String
let visible: Bool
let destId: Int
}
@@ -0,0 +1,14 @@
import UIKit
extension ArrangeObjects {
// MARK: - Alerts
func presentAlert(title: String, message: String) {
DispatchQueue.main.async {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
}
@@ -0,0 +1,55 @@
import UIKit
extension ArrangeObjects {
// MARK: - Button Actions
@objc func buttonTappedConnectButton() {
if self.connectionState == .connected {
//disconnect path
self.wsClient?.cancel(with: .goingAway, reason: nil)
self.wsClient = nil
self.updateUIFromConnectionState(.disconnected)
} else {
//connect path
let ip = self.ipField.text?.trimmingCharacters(in: .whitespaces) ?? ""
let port = self.portField.text?.trimmingCharacters(in: .whitespaces) ?? ""
if let idText = self.tabletIdField.text?.trimmingCharacters(in: .whitespaces),
let idVal = Int(idText), idVal > 0 {
self.myTabletId = idVal
}
guard checkerIPAddressInput(ip: ip) else { return }
guard checkerPortInput(port: port) else { return }
guard let url = buildWebSocketURL(ip: ip, port: port) else { return }
self.connectWebSocket(url: url)
}
}
@objc func buttonTappedShowLogging() {
let visible = (self.logWidget.isHidden == false)
self.logWidget.isHidden = visible
self.showLoggingButton.setTitle(visible ? "Hide Logging" : "Show Logging", for: .normal)
}
@objc func buttonTappedAutoSwitchButton() {
autoSwitchEnabled.toggle()
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
autoSwitchButton.backgroundColor = autoSwitchEnabled ? green : UIColor(white: 0.15, alpha: 1)
autoSwitchButton.setTitleColor(autoSwitchEnabled ? .black : .lightGray, for: .normal)
loggingView.log("auto switch \(autoSwitchEnabled ? "enabled" : "disabled")", category: .local)
}
@objc func buttonTappedArrangeButton() {
self.isLocked = false
self.updateUIArrangeLockButtons()
self.widgets.values.forEach { $0.setArrangeMode(true) }
}
@objc func buttonTappedLockButton() {
self.isLocked = true
self.updateUIArrangeLockButtons()
self.saveLayout()
self.widgets.values.forEach { $0.setArrangeMode(false) }
}
}
@@ -0,0 +1,54 @@
import UIKit
extension ArrangeObjects {
// MARK: - Gestures
func addDragGesture(to view: UIView) {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGesturePan(_:)))
view.addGestureRecognizer(pan)
}
@objc func handleGesturePan(_ gesture: UIPanGestureRecognizer) {
guard self.isLocked == false, 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)
}
func addContextMenuGesture(to view: UIView) {
let press = UILongPressGestureRecognizer(target: self, action: #selector(handleGestureLongPress(_:)))
press.minimumPressDuration = 0.5
press.cancelsTouchesInView = false
view.addGestureRecognizer(press)
}
@objc func handleGestureLongPress(_ gesture: UILongPressGestureRecognizer) {
guard self.isLocked == false else { return }
guard gesture.state == .began else { return }
guard let widget = gesture.view as? BaseWidget else { return }
let alert = UIAlertController(title: widget.displayName, message: nil, preferredStyle: .actionSheet)
alert.overrideUserInterfaceStyle = .dark
alert.addAction(UIAlertAction(title: "Hide", style: .destructive) { [weak self] _ in
self?.hideWidget(widget)
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = widget
popover.sourceRect = widget.bounds
}
self.present(alert, animated: true)
}
func hideWidget(_ widget: BaseWidget) {
widget.removeFromSuperview()
self.widgets.removeValue(forKey: widget.uid)
self.saveLayout()
self.wsClientSendJSON(["event": "widget_visibility", "uid": widget.uid, "dest_id": 0])
}
}
@@ -0,0 +1,67 @@
import UIKit
extension ArrangeObjects {
// MARK: - Input Validation
func checkerIPAddressInput(ip: String) -> Bool {
// Step 1: make sure the field is not empty
if ip.isEmpty {
presentAlert(title: "Missing IP Address", message: "Please enter an IP address.")
return false
}
// Step 2: an IPv4 address is four numbers separated by dots e.g. 192.168.1.1
// Split the string on "." and check we got exactly 4 parts
let parts = ip.split(separator: ".")
if parts.count != 4 {
presentAlert(title: "Invalid IP Address", message: "\"\(ip)\" doesn't look like a valid IP address.")
return false
}
// Step 3: each part must be a number between 0 and 255
for part in parts {
if UInt8(part) == nil {
presentAlert(title: "Invalid IP Address", message: "\"\(ip)\" doesn't look like a valid IP address.")
return false
}
}
return true
}
func checkerPortInput(port: String) -> Bool {
// Step 1: make sure the field is not empty
if port.isEmpty {
presentAlert(title: "Missing Port", message: "Please enter a port number.")
return false
}
// Step 2: the port must be a whole number
guard let number = Int(port) else {
presentAlert(title: "Invalid Port", message: "\"\(port)\" is not a valid port number.")
return false
}
// Step 3: port numbers must be between 1 and 65535
if number < 1 || number > 65535 {
presentAlert(title: "Invalid Port", message: "\"\(port)\" is not a valid port number (165535).")
return false
}
return true
}
func buildWebSocketURL(ip: String, port: String) -> URL? {
let urlString = "ws://\(ip):\(port)"
guard let url = URL(string: urlString) else {
presentAlert(title: "Invalid URL", message: "Could not build a valid WebSocket URL from \"\(urlString)\".")
return nil
}
return url
}
}
@@ -0,0 +1,115 @@
import UIKit
extension ArrangeObjects {
// MARK: - Preset & Layout
func buildUIFromPreset(_ preset: ModelPresetLoadToRemoteDevice) {
let savedLayout = UserDefaults.standard.dictionary(forKey: "layout_\(preset.presetUuid)") ?? [:]
if savedLayout.isEmpty {
self.loggingView.log("buildUIFromPreset: no saved layout for key=layout_\(preset.presetUuid) — using defaults", category: .local)
} else {
self.loggingView.log("buildUIFromPreset: restored \(savedLayout.count) positions from key=layout_\(preset.presetUuid)", category: .local)
if let data = try? JSONSerialization.data(withJSONObject: savedLayout),
let jsonStr = String(data: data, encoding: .utf8) {
self.loggingView.log(jsonStr, category: .local)
}
}
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.addDragGesture(to: titleWidget)
self.containerView.addSubview(titleWidget)
self.widgets[titleUid] = titleWidget
var faderIdx = 0
var squareIdx = 0
for f in preset.faders where f.destId == self.myTabletId {
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?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[f.uid] = w
faderIdx += 1
}
for t in preset.toggles where t.destId == self.myTabletId {
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?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
squareIdx += 1
}
for t in preset.transports where t.destId == self.myTabletId {
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?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
squareIdx += 1
}
self.isLocked = savedLayout.count > 0
self.updateUIArrangeLockButtons()
self.showStatus("[ \(preset.presetName) \(self.statusTimestamp()) ]")
}
func saveLayout() {
guard self.presetUuid.isEmpty == false else {
self.loggingView.log("saveLayout: aborted — presetUuid is empty", category: .local)
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.showStatus("[ Saved \(self.statusTimestamp()) ]")
self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=layout_\(self.presetUuid)", category: .local)
if let data = try? JSONSerialization.data(withJSONObject: layoutDict),
let jsonStr = String(data: data, encoding: .utf8) {
self.loggingView.log(jsonStr, category: .local)
}
}
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)
}
}
@@ -0,0 +1,86 @@
import UIKit
extension ArrangeObjects {
// MARK: - UI Updates
func updateUIFromConnectionState(_ 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
}
}
func updateUIArrangeLockButtons() {
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)
}
func updateUIFromDawState(_ 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.count > 0 { self.statusLabel.text = parts.joined(separator: " ") }
}
func updateWidget(uid: String, value: Int) {
self.widgets[uid]?.update(value: value)
}
func showStatus(_ message: String) {
DispatchQueue.main.async {
self.statusToken += 1
let token = self.statusToken
self.toolbarStatusLabel.text = message
DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) {
if self.statusToken == token {
self.toolbarStatusLabel.text = ""
}
}
}
}
func statusTimestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm:ss a"
return "Today \(formatter.string(from: Date()))"
}
}
@@ -0,0 +1,128 @@
import UIKit
extension ArrangeObjects {
// MARK: - WebSocket
func connectWebSocket(url: URL) {
self.urlSession = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
self.wsClient = self.urlSession?.webSocketTask(with: url)
self.wsClient?.resume()
self.updateUIFromConnectionState(.connected)
self.wsClientReceiveMessage()
}
func wsClientReceiveMessage() {
self.wsClient?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text): self.wsClientHandleIncomingJSON(text)
default: break
}
self.wsClientReceiveMessage()
case .failure:
DispatchQueue.main.async {
self.updateUIFromConnectionState(.disconnected)
}
}
}
}
func wsClientHandleIncomingJSON(_ text: String) {
self.loggingView.log(text, category: .incoming)
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":
guard self.autoSwitchEnabled else {
self.loggingView.log("preset event ignored — auto switch is off", category: .local)
return
}
guard let presetData = json["data"] as? [String: Any],
let presetBytes = try? JSONSerialization.data(withJSONObject: presetData) else {
let msg = "preset: missing or malformed 'data' field in envelope"
self.loggingView.log(msg, category: .local)
DispatchQueue.main.async {
let alert = UIAlertController(title: "Preset Load Error", message: msg, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
return
}
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let preset = try decoder.decode(ModelPresetLoadToRemoteDevice.self, from: presetBytes)
self.loggingView.log(
"✓ preset loaded: '\(preset.presetName)' uuid=\(preset.presetUuid) faders=\(preset.faders.count) toggles=\(preset.toggles.count) transports=\(preset.transports.count)",
category: .local
)
if let encoded = try? JSONEncoder().encode(preset),
let jsonStr = String(data: encoded, encoding: .utf8) {
self.loggingView.log(jsonStr, category: .local)
}
DispatchQueue.main.async {
let isNewPreset = preset.presetUuid != self.presetUuid
if self.isLocked == false && isNewPreset {
let alert = UIAlertController(
title: "Preset Switched",
message: "Switching to:\n\"\(preset.presetName)\"\(preset.displayTitle)\n\nSave your current layout before switching?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Save & Switch", style: .default) { _ in
self.saveLayout()
self.buildUIFromPreset(preset)
})
alert.addAction(UIAlertAction(title: "Discard & Switch", style: .destructive) { _ in
self.buildUIFromPreset(preset)
})
self.present(alert, animated: true)
} else {
self.buildUIFromPreset(preset)
}
}
} catch {
let msg = "preset decode failed: \(error.localizedDescription)"
self.loggingView.log(msg, category: .local)
DispatchQueue.main.async {
let alert = UIAlertController(title: "Preset Load Error", message: msg, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
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.updateUIFromDawState(json) }
default:
break
}
}
func wsClientSendJSON(_ dict: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: dict),
let text = String(data: data, encoding: .utf8) else { return }
self.loggingView.log(text, category: .outgoing)
self.wsClient?.send(.string(text)) { _ in }
}
}
// MARK: - WebSocket Delegate
extension ArrangeObjects: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
self.updateUIFromConnectionState(.connected)
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
self.updateUIFromConnectionState(.disconnected)
}
}
@@ -1,9 +1,10 @@
import UIKit
class ObjectsViewController: UIViewController {
class ArrangeObjects: UIViewController {
// MARK: - WebSocket
var wsTask: URLSessionWebSocketTask?
var wsClient: URLSessionWebSocketTask?
var urlSession: URLSession?
// MARK: - Connection State
@@ -16,6 +17,14 @@ class ObjectsViewController: UIViewController {
var presetName: String = ""
var presetUuid: String = ""
var isLocked: Bool = true
var autoSwitchEnabled: Bool = true
var myTabletId: Int {
get {
let stored = UserDefaults.standard.integer(forKey: "tablet_id")
return stored == 0 ? 1 : stored
}
set { UserDefaults.standard.set(newValue, forKey: "tablet_id") }
}
// MARK: - Widgets
var widgets: [String: BaseWidget] = [:]
@@ -40,7 +49,7 @@ class ObjectsViewController: UIViewController {
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)
btn.addTarget(self, action: #selector(self.buttonTappedConnectButton), for: .touchUpInside)
return btn
}()
@@ -54,7 +63,19 @@ class ObjectsViewController: UIViewController {
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)
btn.addTarget(self, action: #selector(self.buttonTappedArrangeButton), for: .touchUpInside)
return btn
}()
lazy var autoSwitchButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Auto Switch", 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.buttonTappedAutoSwitchButton), for: .touchUpInside)
return btn
}()
@@ -66,7 +87,7 @@ class ObjectsViewController: UIViewController {
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)
btn.addTarget(self, action: #selector(self.buttonTappedLockButton), for: .touchUpInside)
return btn
}()
@@ -90,6 +111,25 @@ class ObjectsViewController: UIViewController {
return tf
}()
let tabletIdField: UITextField = {
let tf = UITextField()
tf.translatesAutoresizingMaskIntoConstraints = false
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.placeholder = "ID"
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
@@ -127,10 +167,23 @@ class ObjectsViewController: UIViewController {
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)
btn.addTarget(self, action: #selector(self.buttonTappedShowLogging), for: .touchUpInside)
return btn
}()
// MARK: - Toolbar Status
var statusToken: Int = 0
let toolbarStatusLabel: UILabel = {
let lbl = UILabel()
lbl.translatesAutoresizingMaskIntoConstraints = false
lbl.text = ""
lbl.textColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
lbl.font = .italicSystemFont(ofSize: 11)
lbl.textAlignment = .right
return lbl
}()
// MARK: - Labels
let statusLabel: UILabel = {
let lbl = UILabel()
@@ -1,9 +1,10 @@
import UIKit
extension ObjectsViewController {
extension ArrangeObjects {
func setupUI() {
DispatchQueue.main.async {
self.view.backgroundColor = UIColor(white: 0.1, alpha: 1)
// containerView
@@ -20,7 +21,7 @@ extension ObjectsViewController {
// presetLabel
self.view.addSubview(self.presetLabel)
self.presetLabel.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -8).isActive = true
self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true
self.presetLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
// toolbar buttons (top-right)
@@ -28,13 +29,24 @@ extension ObjectsViewController {
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.toolbarStatusLabel)
self.toolbarStatusLabel.topAnchor.constraint(equalTo: self.showLoggingButton.bottomAnchor, constant: 4).isActive = true
self.toolbarStatusLabel.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.tabletIdField)
self.tabletIdField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.tabletIdField.trailingAnchor.constraint(equalTo: self.connectButton.leadingAnchor, constant: -6).isActive = true
self.tabletIdField.widthAnchor.constraint(equalToConstant: 40).isActive = true
self.tabletIdField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
self.tabletIdField.text = "\(self.myTabletId)"
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.trailingAnchor.constraint(equalTo: self.tabletIdField.leadingAnchor, constant: -6).isActive = true
self.portField.widthAnchor.constraint(equalToConstant: 60).isActive = true
self.portField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
@@ -48,12 +60,16 @@ extension ObjectsViewController {
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.autoSwitchButton)
self.autoSwitchButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.autoSwitchButton.trailingAnchor.constraint(equalTo: self.lockButton.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.arrangeButton.trailingAnchor.constraint(equalTo: self.autoSwitchButton.leadingAnchor, constant: -6).isActive = true
self.containerView.addSubview(self.logWidget)
self.setupDrag(on: self.logWidget)
self.addDragGesture(to: self.logWidget)
self.logWidget.attach(to: self, loggingView: self.loggingView)
}
}
@@ -7,13 +7,11 @@
import UIKit
class ViewController: ObjectsViewController {
class ArrangeView: ArrangeObjects {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
}
@@ -0,0 +1,51 @@
import UIKit
extension LoggingObjects {
func log(_ message: String, category: LogCategory = .local) {
var entry = 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) {
entry = str
}
switch category {
case .incoming: logIncoming.insert(entry, at: 0)
case .outgoing: logOutgoing.insert(entry, at: 0)
case .local: logLocal.insert(entry, at: 0)
}
if category == activeCategory {
DispatchQueue.main.async { self.rebuildTextView() }
}
}
@objc func categoryChanged(_ sender: UISegmentedControl) {
activeCategory = LogCategory(rawValue: sender.selectedSegmentIndex) ?? .local
rebuildTextView()
}
@objc func buttonTappedClearButton() {
switch activeCategory {
case .incoming: logIncoming.removeAll()
case .outgoing: logOutgoing.removeAll()
case .local: logLocal.removeAll()
}
textView.text = ""
}
private func activeLog() -> [String] {
switch activeCategory {
case .incoming: return logIncoming
case .outgoing: return logOutgoing
case .local: return logLocal
}
}
private func rebuildTextView() {
textView.text = activeLog().joined(separator: "\n--- ✦ ---\n")
}
}
@@ -1,7 +1,26 @@
import UIKit
enum LogCategory: Int {
case incoming = 0
case outgoing = 1
case local = 2
}
class LoggingObjects: UIViewController {
var logIncoming: [String] = []
var logOutgoing: [String] = []
var logLocal: [String] = []
var activeCategory: LogCategory = .local
lazy var segmentedControl: UISegmentedControl = {
let sc = UISegmentedControl(items: ["In", "Out", "Local"])
sc.translatesAutoresizingMaskIntoConstraints = false
sc.selectedSegmentIndex = LogCategory.local.rawValue
sc.addTarget(self, action: #selector(self.categoryChanged(_:)), for: .valueChanged)
return sc
}()
lazy var textView: UITextView = {
let tv = UITextView()
tv.translatesAutoresizingMaskIntoConstraints = false
@@ -19,7 +38,7 @@ class LoggingObjects: UIViewController {
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)
btn.addTarget(self, action: #selector(self.buttonTappedClearButton), for: .touchUpInside)
return btn
}()
@@ -7,11 +7,15 @@ extension LoggingObjects {
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.view.addSubview(self.segmentedControl)
self.segmentedControl.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 6).isActive = true
self.segmentedControl.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
self.segmentedControl.trailingAnchor.constraint(equalTo: self.clearButton.leadingAnchor, constant: -8).isActive = true
self.clearButton.centerYAnchor.constraint(equalTo: self.segmentedControl.centerYAnchor).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.topAnchor.constraint(equalTo: self.segmentedControl.bottomAnchor, constant: 6).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
@@ -0,0 +1,90 @@
import UIKit
class BaseWidget: UIView {
let uid: String
var displayName: String = ""
var onSend: ((String, Int) -> Void)?
var restingBorderColor: CGColor = UIColor(white: 0.27, alpha: 1).cgColor
private var isTouching = false
private let activityGreen = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1).cgColor
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
self.setupActivityRingGesture()
}
required init?(coder: NSCoder) { fatalError() }
// Intercepts all incoming value updates so the ring can flash.
// Subclasses override applyValue(_:), not this.
final func update(value: Int) {
applyValue(value)
flashActivityRing()
}
func applyValue(_ value: Int) {}
func setArrangeMode(_ enabled: Bool) {}
// MARK: - Activity Ring
private func setupActivityRingGesture() {
let press = UILongPressGestureRecognizer(target: self, action: #selector(self.handleTouchStateChanged(_:)))
press.minimumPressDuration = 0
press.allowableMovement = 10000
press.cancelsTouchesInView = false
press.delegate = self
self.addGestureRecognizer(press)
}
@objc private func handleTouchStateChanged(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
self.isTouching = true
self.layer.borderColor = self.activityGreen
self.layer.borderWidth = 3
case .ended, .cancelled, .failed:
self.isTouching = false
self.layer.borderColor = self.restingBorderColor
self.layer.borderWidth = 1
default:
break
}
}
private func flashActivityRing() {
self.layer.borderColor = self.activityGreen
self.layer.borderWidth = 3
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
guard let self = self, self.isTouching == false else { return }
self.layer.borderColor = self.restingBorderColor
self.layer.borderWidth = 1
}
}
}
extension BaseWidget: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
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
)
}
}
@@ -37,6 +37,8 @@ class FaderWidget: BaseWidget {
init(uid: String, label: String, value: Int, color: UIColor, frame: CGRect) {
self.color = color
super.init(uid: uid, frame: frame)
self.displayName = label
self.restingBorderColor = color.cgColor
self.layer.borderColor = color.cgColor
self.nameLabel.text = label
self.slider.value = Float(value)
@@ -69,10 +71,15 @@ class FaderWidget: BaseWidget {
}
// MARK: - Update
override func update(value: Int) {
override func applyValue(_ value: Int) {
self.slider.value = Float(value)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.slider.isUserInteractionEnabled = (enabled == false)
}
// MARK: - Action
@objc private func sliderChanged() {
self.onSend?(self.uid, Int(self.slider.value))
@@ -31,6 +31,7 @@ class ToggleWidget: BaseWidget {
self.color = color
self.isOn = isOn
super.init(uid: uid, frame: frame)
self.displayName = label
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
self.button.setTitle(label, for: .normal)
setupUI()
@@ -47,7 +48,7 @@ class ToggleWidget: BaseWidget {
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)
self.button.addTarget(self, action: #selector(self.buttonTappedToggleButton), for: .touchUpInside)
}
// MARK: - State
@@ -56,13 +57,18 @@ class ToggleWidget: BaseWidget {
self.button.setTitleColor(self.isOn ? .black : .lightGray, for: .normal)
}
override func update(value: Int) {
override func applyValue(_ value: Int) {
self.isOn = value > 63
applyState()
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.button.isUserInteractionEnabled = (enabled == false)
}
// MARK: - Action
@objc private func tapped() {
@objc private func buttonTappedToggleButton() {
self.isOn = !self.isOn
applyState()
self.onSend?(self.uid, self.isOn ? 127 : 0)
@@ -27,6 +27,7 @@ class TransportWidget: BaseWidget {
// MARK: - Init
init(uid: String, label: String, frame: CGRect) {
super.init(uid: uid, frame: frame)
self.displayName = label
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
self.button.setTitle(label, for: .normal)
setupUI()
@@ -42,11 +43,16 @@ class TransportWidget: BaseWidget {
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)
self.button.addTarget(self, action: #selector(self.buttonTappedTransportButton), for: .touchUpInside)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.button.isUserInteractionEnabled = (enabled == false)
}
// MARK: - Action
@objc private func tapped() {
@objc private func buttonTappedTransportButton() {
self.onSend?(self.uid, 127)
}
@@ -1,35 +0,0 @@
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
)
}
}
@@ -15,7 +15,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
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?.rootViewController = ArrangeView()
window?.makeKeyAndVisible()
}
+10
View File
@@ -7,6 +7,7 @@ from PyQt6.QtNetwork import QHostAddress
class WSServer(QObject):
control_received = pyqtSignal(str, int)
layout_saved = pyqtSignal(str, object)
widget_visibility_received = pyqtSignal(str, int)
client_connected = pyqtSignal()
log_signal = pyqtSignal(str)
raw_in_signal = pyqtSignal(str)
@@ -51,11 +52,20 @@ class WSServer(QObject):
n = len(data.get("layout", {}))
self.log_signal.emit(f"layout saved preset={data['preset_uuid'][:8]}{n} widgets")
self.layout_saved.emit(data["preset_uuid"], data["layout"])
elif ev == "widget_visibility":
uid = data["uid"]
dest_id = int(data.get("dest_id", 0))
self.log_signal.emit(f"widget visibility uid={uid[:8]}… dest_id={dest_id}")
self.widget_visibility_received.emit(uid, dest_id)
else:
self.log_signal.emit(f"unknown event: {ev}")
except Exception as e:
self.log_signal.emit(f"bad message: {e}")
@property
def has_clients(self) -> bool:
return len(self._clients) > 0
def broadcast(self, data: dict):
if not self._clients:
return