From d6201c59b43e162ab63f1302cb20489265b875ac Mon Sep 17 00:00:00 2001 From: Paul Lipscomb Date: Thu, 2 Jul 2026 21:56:35 -0400 Subject: [PATCH] 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 --- app/faderwidget.py | 15 +- app/main.py | 60 +++- app/togglewidget.py | 15 +- app/transportwidget.py | 17 +- presets/presets.json | 261 ++++++++-------- .../remote-client-ios/Core/Functions.swift | 283 ------------------ .../Core/LoggingVC/LoggingFunctions.swift | 24 -- .../Models/PresetLoadToRemoteDevice.swift | 6 +- .../VC-Arrange/Arrange-Functions-Alerts.swift | 14 + .../Arrange-Functions-ButtonActions.swift | 55 ++++ .../Arrange-Functions-Gestures.swift | 54 ++++ .../Arrange-Functions-InputValidation.swift | 67 +++++ .../Arrange-Functions-PresetLayout.swift | 115 +++++++ .../Arrange-Functions-UIUpdates.swift | 86 ++++++ .../Arrange-Functions-WebSocket.swift | 128 ++++++++ .../Arrange-Objects.swift} | 65 +++- .../Arrange-UILayout.swift} | 26 +- .../Arrange-View.swift} | 4 +- .../Core/VC-Logging/Logging-Functions.swift | 51 ++++ .../Logging-Objects.swift} | 21 +- .../Logging-UILayout.swift} | 8 +- .../Logging-View.swift} | 0 .../Core/Widgets-Reusable-UI/BaseWidget.swift | 90 ++++++ .../FaderWidget.swift | 9 +- .../FeedbackWidget.swift | 0 .../LogWidget.swift | 0 .../TitleWidget.swift | 0 .../ToggleWidget.swift | 12 +- .../TransportWidget.swift | 10 +- .../Core/ui-reusable/BaseWidget.swift | 35 --- .../remote-client-ios/SceneDelegate.swift | 2 +- server/ws_server.py | 10 + 32 files changed, 1042 insertions(+), 501 deletions(-) delete mode 100644 remote-client-ios/remote-client-ios/Core/Functions.swift delete mode 100644 remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingFunctions.swift create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Alerts.swift create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-ButtonActions.swift create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Gestures.swift create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-InputValidation.swift create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-PresetLayout.swift create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-UIUpdates.swift create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-WebSocket.swift rename remote-client-ios/remote-client-ios/Core/{ObjectsViewController.swift => VC-Arrange/Arrange-Objects.swift} (67%) rename remote-client-ios/remote-client-ios/Core/{UILayout.swift => VC-Arrange/Arrange-UILayout.swift} (69%) rename remote-client-ios/remote-client-ios/Core/{View.swift => VC-Arrange/Arrange-View.swift} (80%) create mode 100644 remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-Functions.swift rename remote-client-ios/remote-client-ios/Core/{LoggingVC/LoggingObjects.swift => VC-Logging/Logging-Objects.swift} (53%) rename remote-client-ios/remote-client-ios/Core/{LoggingVC/LoggingUILayout.swift => VC-Logging/Logging-UILayout.swift} (53%) rename remote-client-ios/remote-client-ios/Core/{LoggingVC/LoggingView.swift => VC-Logging/Logging-View.swift} (100%) create mode 100644 remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/BaseWidget.swift rename remote-client-ios/remote-client-ios/Core/{ui-reusable => Widgets-Reusable-UI}/FaderWidget.swift (90%) rename remote-client-ios/remote-client-ios/Core/{ui-reusable => Widgets-Reusable-UI}/FeedbackWidget.swift (100%) rename remote-client-ios/remote-client-ios/Core/{ui-reusable => Widgets-Reusable-UI}/LogWidget.swift (100%) rename remote-client-ios/remote-client-ios/Core/{ui-reusable => Widgets-Reusable-UI}/TitleWidget.swift (100%) rename remote-client-ios/remote-client-ios/Core/{ui-reusable => Widgets-Reusable-UI}/ToggleWidget.swift (84%) rename remote-client-ios/remote-client-ios/Core/{ui-reusable => Widgets-Reusable-UI}/TransportWidget.swift (82%) delete mode 100644 remote-client-ios/remote-client-ios/Core/ui-reusable/BaseWidget.swift diff --git a/app/faderwidget.py b/app/faderwidget.py index 124be98..dc00187 100644 --- a/app/faderwidget.py +++ b/app/faderwidget.py @@ -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()) diff --git a/app/main.py b/app/main.py index 3a313a6..256111b 100644 --- a/app/main.py +++ b/app/main.py @@ -135,11 +135,21 @@ containerMain = QWidget() #MARK: PRESET ROW hstack_preset_row = QHBoxLayout() -#MARK: PRESENT +#MARK: PRESENT 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) diff --git a/app/togglewidget.py b/app/togglewidget.py index d3a4d01..833f672 100644 --- a/app/togglewidget.py +++ b/app/togglewidget.py @@ -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()) diff --git a/app/transportwidget.py b/app/transportwidget.py index 329f532..6249d87 100644 --- a/app/transportwidget.py +++ b/app/transportwidget.py @@ -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): @@ -403,4 +413,7 @@ class TransportWidget(QWidget): self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") 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")) \ No newline at end of file + 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) \ No newline at end of file diff --git a/presets/presets.json b/presets/presets.json index b39a392..43ea33a 100644 --- a/presets/presets.json +++ b/presets/presets.json @@ -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, diff --git a/remote-client-ios/remote-client-ios/Core/Functions.swift b/remote-client-ios/remote-client-ios/Core/Functions.swift deleted file mode 100644 index b60264a..0000000 --- a/remote-client-ios/remote-client-ios/Core/Functions.swift +++ /dev/null @@ -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) - } - -} diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingFunctions.swift b/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingFunctions.swift deleted file mode 100644 index 48ff07b..0000000 --- a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingFunctions.swift +++ /dev/null @@ -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 = "" - } - -} diff --git a/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift b/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift index a9940d7..32f96f9 100644 --- a/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift +++ b/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift @@ -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 } diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Alerts.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Alerts.swift new file mode 100644 index 0000000..b2fd839 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Alerts.swift @@ -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) + } + } +} diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-ButtonActions.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-ButtonActions.swift new file mode 100644 index 0000000..1f83b48 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-ButtonActions.swift @@ -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) } + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Gestures.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Gestures.swift new file mode 100644 index 0000000..005fe6c --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Gestures.swift @@ -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]) + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-InputValidation.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-InputValidation.swift new file mode 100644 index 0000000..14ad849 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-InputValidation.swift @@ -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 (1–65535).") + 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 + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-PresetLayout.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-PresetLayout.swift new file mode 100644 index 0000000..caf5141 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-PresetLayout.swift @@ -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) + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-UIUpdates.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-UIUpdates.swift new file mode 100644 index 0000000..ebc9331 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-UIUpdates.swift @@ -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()))" + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-WebSocket.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-WebSocket.swift new file mode 100644 index 0000000..a1c4435 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-WebSocket.swift @@ -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) + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/ObjectsViewController.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Objects.swift similarity index 67% rename from remote-client-ios/remote-client-ios/Core/ObjectsViewController.swift rename to remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Objects.swift index d1eede7..453ceb9 100644 --- a/remote-client-ios/remote-client-ios/Core/ObjectsViewController.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Objects.swift @@ -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() diff --git a/remote-client-ios/remote-client-ios/Core/UILayout.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-UILayout.swift similarity index 69% rename from remote-client-ios/remote-client-ios/Core/UILayout.swift rename to remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-UILayout.swift index ef60028..7a943a4 100644 --- a/remote-client-ios/remote-client-ios/Core/UILayout.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-UILayout.swift @@ -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) } } diff --git a/remote-client-ios/remote-client-ios/Core/View.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-View.swift similarity index 80% rename from remote-client-ios/remote-client-ios/Core/View.swift rename to remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-View.swift index e9289b3..2cb81b0 100644 --- a/remote-client-ios/remote-client-ios/Core/View.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-View.swift @@ -7,13 +7,11 @@ import UIKit -class ViewController: ObjectsViewController { +class ArrangeView: ArrangeObjects { override func viewDidLoad() { super.viewDidLoad() setupUI() } - - } diff --git a/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-Functions.swift b/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-Functions.swift new file mode 100644 index 0000000..e81c8cd --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-Functions.swift @@ -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") + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingObjects.swift b/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-Objects.swift similarity index 53% rename from remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingObjects.swift rename to remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-Objects.swift index efe7408..11943e1 100644 --- a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingObjects.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-Objects.swift @@ -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 }() diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingUILayout.swift b/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-UILayout.swift similarity index 53% rename from remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingUILayout.swift rename to remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-UILayout.swift index b4e0662..87ce729 100644 --- a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingUILayout.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-UILayout.swift @@ -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 diff --git a/remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingView.swift b/remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-View.swift similarity index 100% rename from remote-client-ios/remote-client-ios/Core/LoggingVC/LoggingView.swift rename to remote-client-ios/remote-client-ios/Core/VC-Logging/Logging-View.swift diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/BaseWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/BaseWidget.swift new file mode 100644 index 0000000..165c0b3 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/BaseWidget.swift @@ -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 + ) + } +} diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/FaderWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FaderWidget.swift similarity index 90% rename from remote-client-ios/remote-client-ios/Core/ui-reusable/FaderWidget.swift rename to remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FaderWidget.swift index 2532c23..048ad5f 100644 --- a/remote-client-ios/remote-client-ios/Core/ui-reusable/FaderWidget.swift +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FaderWidget.swift @@ -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)) diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/FeedbackWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FeedbackWidget.swift similarity index 100% rename from remote-client-ios/remote-client-ios/Core/ui-reusable/FeedbackWidget.swift rename to remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FeedbackWidget.swift diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/LogWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/LogWidget.swift similarity index 100% rename from remote-client-ios/remote-client-ios/Core/ui-reusable/LogWidget.swift rename to remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/LogWidget.swift diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/TitleWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TitleWidget.swift similarity index 100% rename from remote-client-ios/remote-client-ios/Core/ui-reusable/TitleWidget.swift rename to remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TitleWidget.swift diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/ToggleWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/ToggleWidget.swift similarity index 84% rename from remote-client-ios/remote-client-ios/Core/ui-reusable/ToggleWidget.swift rename to remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/ToggleWidget.swift index 56973ef..187978a 100644 --- a/remote-client-ios/remote-client-ios/Core/ui-reusable/ToggleWidget.swift +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/ToggleWidget.swift @@ -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) diff --git a/remote-client-ios/remote-client-ios/Core/ui-reusable/TransportWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TransportWidget.swift similarity index 82% rename from remote-client-ios/remote-client-ios/Core/ui-reusable/TransportWidget.swift rename to remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TransportWidget.swift index 15bfa22..dcc7d55 100644 --- a/remote-client-ios/remote-client-ios/Core/ui-reusable/TransportWidget.swift +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TransportWidget.swift @@ -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) } 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 deleted file mode 100644 index 3e56f94..0000000 --- a/remote-client-ios/remote-client-ios/Core/ui-reusable/BaseWidget.swift +++ /dev/null @@ -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 - ) - } -} diff --git a/remote-client-ios/remote-client-ios/SceneDelegate.swift b/remote-client-ios/remote-client-ios/SceneDelegate.swift index 519dba9..df4d5ce 100644 --- a/remote-client-ios/remote-client-ios/SceneDelegate.swift +++ b/remote-client-ios/remote-client-ios/SceneDelegate.swift @@ -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() } diff --git a/server/ws_server.py b/server/ws_server.py index 61b7293..526024f 100644 --- a/server/ws_server.py +++ b/server/ws_server.py @@ -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