diff --git a/.claude/settings.json b/.claude/settings.json index 8c1f328..f530da8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(git add *)" + "Bash(git add *)", + "Bash(python3 -c \"import ast; ast.parse\\(open\\('main.py'\\).read\\(\\)\\)\")" ] } } diff --git a/app/faderwidget.py b/app/faderwidget.py index dc00187..3546398 100644 --- a/app/faderwidget.py +++ b/app/faderwidget.py @@ -3,7 +3,7 @@ import uuid from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QSlider, QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy, - QPushButton, QCheckBox + QPushButton, QCheckBox, QComboBox ) from PyQt6.QtCore import Qt, QEvent, QTimer @@ -55,6 +55,15 @@ class FaderWidget(QWidget): self.hw_channel = None self.is_learning = False + # Touch sense: some motorized fader hardware (e.g. JL Cooper) sends + # touch on/off using the SAME CC as the fader's value, just on + # hw_channel - 1 (e.g. value on CH16, touch on CH15). "Off" (default) + # ignores this distinction entirely, matching prior behavior. + # is_touching is tracked either way but only acted on by hardware with + # a real feedback path to gate (see FocusFaderWidget.receive_osc_value). + self.touch_sense_mode = "off" + self.is_touching = False + _grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }" # Group 1: Learn + input readout (border always visible; learn_btn hideable) @@ -77,6 +86,12 @@ class FaderWidget(QWidget): self.cc_input_lbl.setFixedHeight(16) _grp1_layout.addWidget(self.cc_input_lbl) + self.touch_sense_combo = QComboBox() + self.touch_sense_combo.addItems(["No Profile", "MIDI JL Cooper CC Mode"]) + self.touch_sense_combo.setStyleSheet("font-size: 10px;") + self.touch_sense_combo.currentIndexChanged.connect(self._on_touch_sense_changed) + _grp1_layout.addWidget(self.touch_sense_combo) + inner.addWidget(self.grp_learn) _arrow_lbl = QLabel("▼") @@ -130,7 +145,25 @@ class FaderWidget(QWidget): inner.addWidget(self.grp_cc) + # Group 3: Remote routing + self.grp_remote = QFrame() + self.grp_remote.setStyleSheet(_grp_style) + _grp_remote_layout = QVBoxLayout(self.grp_remote) + _grp_remote_layout.setContentsMargins(4, 4, 4, 4) + _grp_remote_layout.setSpacing(3) + self.remote_checkbox = QCheckBox("App") + self.remote_checkbox.setChecked(True) + self.remote_checkbox.stateChanged.connect(lambda _: self.dest_spin.setEnabled(self.remote_checkbox.isChecked())) + _grp_remote_layout.addWidget(self.remote_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.dest_spin = QSpinBox() + self.dest_spin.setRange(1, 9) + self.dest_spin.setValue(1) + self.dest_spin.setFixedWidth(52) + _grp_remote_layout.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter) + + inner.addWidget(self.grp_remote) # Fader self.fader = QSlider(Qt.Orientation.Vertical) @@ -155,15 +188,6 @@ 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) @@ -432,6 +456,10 @@ class FaderWidget(QWidget): self.hw_channel = channel self.stop_learn() + def _on_touch_sense_changed(self, index): + self.touch_sense_mode = "jl_cooper" if index == 1 else "off" + self.is_touching = False + def get_state(self): return { "uid": self.uid, @@ -444,9 +472,11 @@ class FaderWidget(QWidget): "last_sent": self.last_sent, "hw_cc": self.hw_cc, "hw_channel": self.hw_channel, + "touch_sense_mode": self.touch_sense_mode, "zone": self.zone, "center_index": self.center_index, "zone_index": self.zone_index, + "remote": self.remote_checkbox.isChecked(), "dest_id": self.dest_spin.value(), } @@ -474,9 +504,22 @@ 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) + # jl_cooper_off/jl_cooper_on were a short-lived three-state naming; + # both collapse to the single jl_cooper mode. + saved_touch_mode = state.get("touch_sense_mode", "off") + self.touch_sense_mode = "jl_cooper" if saved_touch_mode in ("jl_cooper", "jl_cooper_off", "jl_cooper_on") else "off" + self.touch_sense_combo.blockSignals(True) + self.touch_sense_combo.setCurrentIndex(1 if self.touch_sense_mode == "jl_cooper" else 0) + self.touch_sense_combo.blockSignals(False) + self.is_touching = False + remote = state.get("remote", True) + self.remote_checkbox.blockSignals(True) + self.remote_checkbox.setChecked(remote) + self.remote_checkbox.blockSignals(False) self.dest_spin.blockSignals(True) self.dest_spin.setValue(state.get("dest_id", 1)) self.dest_spin.blockSignals(False) + self.dest_spin.setEnabled(remote) 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/focusfaderwidget.py b/app/focusfaderwidget.py new file mode 100644 index 0000000..bc0dcc8 --- /dev/null +++ b/app/focusfaderwidget.py @@ -0,0 +1,601 @@ +import uuid + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QSlider, + QLabel, QLineEdit, QFrame, QSizePolicy, + QPushButton, QCheckBox, QSpinBox, QComboBox +) +from PyQt6.QtCore import Qt, QEvent, QTimer + +from palette import ( + PALETTE_NAMES, + PALETTE_HEX, + PALETTE_VIVID, + lighten_hex, + darken_hex, +) +from styles import FADER_STYLE, TITLE_STYLE +from zonebutton import ZoneButton +from colorswatchpopup import ColorSwatchPopup +from osc_sender import send_osc_message +from midi_sender import send_cc + + +class FocusFaderWidget(QWidget): + """Direct DAW-focus fader — OSC-only output, shown on any iPad in Channel Focus mode, no hardware learn input.""" + def __init__(self, label): + + super().__init__() + + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Maximum) + self.setFixedWidth(115) + self.current_color = PALETTE_HEX.get("Gray", "#263238") + + self.uid = str(uuid.uuid4()) + + # Pickup mode state + self.pickup_mode = False + self.last_sent = 64 + self.prev_position = 64 + self.hunting = False + + self.is_learning = False + self.is_learning_name = False + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + self.container = QFrame() + self.container.setFrameShape(QFrame.Shape.StyledPanel) + self.apply_color(self.current_color) + + inner = QVBoxLayout(self.container) + inner.setContentsMargins(4, 6, 4, 6) + inner.setSpacing(4) + inner.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + _grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }" + + # Group: OSC input (feedback from the DAW) + self.grp_input = QFrame() + self.grp_input.setStyleSheet(_grp_style) + _grp_input_layout = QVBoxLayout(self.grp_input) + _grp_input_layout.setContentsMargins(4, 4, 4, 4) + _grp_input_layout.setSpacing(3) + + self.learn_btn = QPushButton("Learn") + self.learn_btn.setFixedHeight(20) + self.learn_btn.setStyleSheet("border: none;") + _grp_input_layout.addWidget(self.learn_btn) + + self.osc_addr_in_edit = QLineEdit("/track/volume") + self.osc_addr_in_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.osc_addr_in_edit.setPlaceholderText("/address") + self.osc_addr_in_edit.setStyleSheet("border: none;") + _grp_input_layout.addWidget(self.osc_addr_in_edit) + + self.cc_input_lbl = QLabel("") + self.cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;") + self.cc_input_lbl.setFixedHeight(16) + _grp_input_layout.addWidget(self.cc_input_lbl) + + inner.addWidget(self.grp_input) + + self.arrow_lbl = QLabel("▼") + self.arrow_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.arrow_lbl.setStyleSheet("color: rgba(255,255,255,0.3); font-size: 9px; background: transparent;") + self.arrow_lbl.setFixedHeight(12) + inner.addWidget(self.arrow_lbl) + + # Group: OSC output controls + readout (sent to the DAW on fader change) + self.grp_cc = QFrame() + self.grp_cc.setStyleSheet(_grp_style) + _grp2_layout = QVBoxLayout(self.grp_cc) + _grp2_layout.setContentsMargins(4, 4, 4, 4) + _grp2_layout.setSpacing(3) + + # OSC address field + self.osc_addr_out_edit = QLineEdit("/track/volume") + self.osc_addr_out_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.osc_addr_out_edit.setPlaceholderText("/address") + self.osc_addr_out_edit.setStyleSheet("border: none;") + _grp2_layout.addWidget(self.osc_addr_out_edit) + + # Output readout + self.cc_output_lbl = QLabel(self._osc_output_label(64)) + self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") + self.cc_output_lbl.setFixedHeight(16) + _grp2_layout.addWidget(self.cc_output_lbl) + + inner.addWidget(self.grp_cc) + + # Group: Hardware — optional physical MIDI CC control alongside the + # app/OSC control (e.g. a JL Cooper motorized fader), positioned like + # grp_remote on the regular widgets rather than up with the OSC learn + # section. "Hardware" binds a hw CC that can move this fader; independently, + # "Feedback" sends a translated CC back out on every value change (from + # hardware, OSC feedback, or the app) so a motorized fader's position stays + # in sync — separate switches since you may want feedback without hardware + # input, or vice versa. + self.hw_cc = None + self.hw_channel = None + self.is_learning_hw = False + + # Touch sense: some motorized fader hardware (e.g. JL Cooper) sends + # touch on/off using the SAME CC as the fader's value, just on + # hw_channel - 1 (e.g. value on CH16, touch on CH15). "Off" (default) + # ignores this distinction entirely. While touching, incoming OSC/DAW + # feedback is ignored (see receive_osc_value) so the motor doesn't + # fight your hand. + self.touch_sense_mode = "off" + self.is_touching = False + + self.grp_hardware = QFrame() + self.grp_hardware.setStyleSheet(_grp_style) + _grp_hw_layout = QVBoxLayout(self.grp_hardware) + _grp_hw_layout.setContentsMargins(4, 4, 4, 4) + _grp_hw_layout.setSpacing(3) + + self.hardware_checkbox = QCheckBox("Hardware") + self.hardware_checkbox.setChecked(False) + self.hardware_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility()) + _grp_hw_layout.addWidget(self.hardware_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.hw_learn_btn = QPushButton("Learn") + self.hw_learn_btn.setFixedHeight(20) + self.hw_learn_btn.setStyleSheet("border: none;") + _grp_hw_layout.addWidget(self.hw_learn_btn) + + self.hw_cc_input_lbl = QLabel("") + self.hw_cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;") + self.hw_cc_input_lbl.setFixedHeight(16) + _grp_hw_layout.addWidget(self.hw_cc_input_lbl) + + self.touch_sense_combo = QComboBox() + self.touch_sense_combo.addItems(["No Profile", "MIDI JL Cooper CC Mode"]) + self.touch_sense_combo.setStyleSheet("font-size: 10px;") + self.touch_sense_combo.currentIndexChanged.connect(self._on_touch_sense_changed) + _grp_hw_layout.addWidget(self.touch_sense_combo) + + self.feedback_checkbox = QCheckBox("Feedback") + self.feedback_checkbox.setChecked(False) + self.feedback_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility()) + _grp_hw_layout.addWidget(self.feedback_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.hw_cc_spin = QSpinBox() + self.hw_cc_spin.setMinimum(0) + self.hw_cc_spin.setMaximum(127) + self.hw_cc_spin.setValue(7) + self.hw_cc_spin.setFixedWidth(55) + _hw_cc_lbl = QLabel("CC") + _hw_cc_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;") + self.hw_cc_spin_row = QWidget() + _hbox_hw_cc = QHBoxLayout(self.hw_cc_spin_row) + _hbox_hw_cc.setContentsMargins(0, 0, 0, 0) + _hbox_hw_cc.setSpacing(4) + _hbox_hw_cc.addWidget(_hw_cc_lbl) + _hbox_hw_cc.addWidget(self.hw_cc_spin) + _grp_hw_layout.addWidget(self.hw_cc_spin_row) + + self.hw_ch_spin = QSpinBox() + self.hw_ch_spin.setMinimum(1) + self.hw_ch_spin.setMaximum(16) + self.hw_ch_spin.setValue(1) + self.hw_ch_spin.setFixedWidth(55) + _hw_ch_lbl = QLabel("CH") + _hw_ch_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;") + self.hw_ch_spin_row = QWidget() + _hbox_hw_ch = QHBoxLayout(self.hw_ch_spin_row) + _hbox_hw_ch.setContentsMargins(0, 0, 0, 0) + _hbox_hw_ch.setSpacing(4) + _hbox_hw_ch.addWidget(_hw_ch_lbl) + _hbox_hw_ch.addWidget(self.hw_ch_spin) + _grp_hw_layout.addWidget(self.hw_ch_spin_row) + + self.hw_cc_output_lbl = QLabel(f"CC{self.hw_cc_spin.value()} CH{self.hw_ch_spin.value()} [--]") + self.hw_cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.hw_cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") + self.hw_cc_output_lbl.setFixedHeight(16) + _grp_hw_layout.addWidget(self.hw_cc_output_lbl) + + inner.addWidget(self.grp_hardware) + + # Fader + self.fader = QSlider(Qt.Orientation.Vertical) + self.fader.setMinimum(0) + self.fader.setMaximum(127) + self.fader.setValue(64) + self.fader.setMinimumHeight(200) + self.fader.setFixedWidth(40) + self.fader.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + self.fader.setStyleSheet(FADER_STYLE) + inner.addWidget(self.fader, alignment=Qt.AlignmentFlag.AlignHCenter) + + # Readout kept for pickup blink logic but not shown + self.readout = QLabel(f"{self.osc_addr_out_edit.text()} → {self.fader.value()}") + self.readout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.readout.setVisible(False) + + # Pickup checkbox + self.pickup_checkbox = QCheckBox("Pickup") + self.pickup_checkbox.setChecked(False) + self.pickup_checkbox.stateChanged.connect(self.on_pickup_changed) + inner.addWidget(self.pickup_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + # Track name learn (binds the scribble strip to live OSC track-name feedback) + self.name_learn_btn = QPushButton("Learn Name") + self.name_learn_btn.setFixedHeight(18) + self.name_learn_btn.setStyleSheet("border: none;") + inner.addWidget(self.name_learn_btn) + + self.osc_addr_name_edit = QLineEdit("/track/name") + self.osc_addr_name_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.osc_addr_name_edit.setPlaceholderText("/address") + self.osc_addr_name_edit.setStyleSheet("border: none;") + inner.addWidget(self.osc_addr_name_edit) + + # Scribble strip + self.title_edit = QLineEdit(label) + self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.title_edit.setStyleSheet(TITLE_STYLE) + inner.addWidget(self.title_edit) + + # Color swatch button + self.color_name = "Gray" + self.color_swatch_btn = QPushButton() + self.color_swatch_btn.setFixedHeight(10) + self.color_swatch_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID['Gray']}; border-radius: 2px; border: none;") + self.color_swatch_btn.clicked.connect(self.show_color_popup) + inner.addWidget(self.color_swatch_btn) + + # + / - buttons + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(2) + self.minus_btn = QPushButton("-") + self.minus_btn.setFixedSize(30, 20) + self.plus_btn = QPushButton("+") + self.plus_btn.setFixedSize(30, 20) + btn_row.addWidget(self.minus_btn) + btn_row.addStretch() + btn_row.addWidget(self.plus_btn) + inner.addLayout(btn_row) + + # Zone button + self.zone_btn = ZoneButton() + self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active) + self.zone_btn.right_callback = lambda active: self.on_zone_checked("right", active) + inner.addWidget(self.zone_btn) + + # Separator before ID label + id_sep = QWidget() + id_sep.setFixedHeight(1) + id_sep.setStyleSheet("background-color: #3a3a3a;") + inner.addWidget(id_sep) + + # Channel ID label + self.id_label = QLabel("") + self.id_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.id_label.setStyleSheet("color: #ffffff; font-size: 13px; font-weight: bold; background: transparent;") + inner.addWidget(self.id_label) + + outer.addWidget(self.container) + + self.fader.valueChanged.connect(self.on_fader_moved) + self.on_select_callback = None + self.on_context_menu_callback = None + self.zone = None # None, "left", or "right" + self.center_index = None # saved position in center row before zoning + self.zone_index = None # position within zone slot + self.on_zone_change_callback = None + + self._update_hw_visibility() + + # Install event filter on all children to forward clicks to select + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.MouseButtonPress: + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + elif event.type() == QEvent.Type.ContextMenu: + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + return True + return False + + def mousePressEvent(self, event): + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + super().mousePressEvent(event) + + def contextMenuEvent(self, event): + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + + def start_learn(self): + self.is_learning = True + self.learn_btn.setText("Listening...") + self.learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_learn(self): + self.is_learning = False + self.learn_btn.setText("Learn") + self.learn_btn.setStyleSheet("") + + def bind_osc_addr(self, address): + self.osc_addr_in_edit.setText(address) + self.stop_learn() + + def _update_hw_visibility(self): + hw_on = self.hardware_checkbox.isChecked() + self.hw_learn_btn.setVisible(hw_on) + self.hw_cc_input_lbl.setVisible(hw_on) + fb_on = self.feedback_checkbox.isChecked() + self.hw_cc_spin_row.setVisible(fb_on) + self.hw_ch_spin_row.setVisible(fb_on) + self.hw_cc_output_lbl.setVisible(fb_on) + + def start_hw_learn(self): + self.is_learning_hw = True + self.hw_learn_btn.setText("Listening...") + self.hw_learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_hw_learn(self): + self.is_learning_hw = False + self.hw_learn_btn.setStyleSheet("") + if self.hw_cc is not None: + self.hw_learn_btn.setText(f"HW: CC{self.hw_cc}") + self.hw_cc_input_lbl.setText(self._hw_cc_input_label()) + self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + else: + self.hw_learn_btn.setText("Learn") + self.hw_cc_input_lbl.setText("") + self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + + def bind_hw_cc(self, cc_number, channel=None): + self.hw_cc = cc_number + self.hw_channel = channel + self.stop_hw_learn() + + def _hw_cc_input_label(self, vel="--"): + if self.hw_cc is None: + return "" + ch = f" CH{self.hw_channel}" if self.hw_channel is not None else "" + return f"CC{self.hw_cc}{ch} [{vel}]" + + def _on_touch_sense_changed(self, index): + self.touch_sense_mode = "jl_cooper" if index == 1 else "off" + self.is_touching = False + + def receive_osc_value(self, value): + """Update the fader to reflect feedback from the DAW, without echoing it back out.""" + if self.is_touching: + # Hardware currently owns the value — don't let DAW/OSC feedback + # fight the motor while a hand is physically on the fader. + return + fader_value = max(0, min(127, round(value * 127))) + self.cc_input_lbl.setText(f"[{value:.2f}]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") + self.fader.blockSignals(True) + self.fader.setValue(fader_value) + self.fader.blockSignals(False) + + def start_learn_name(self): + self.is_learning_name = True + self.name_learn_btn.setText("Listening...") + self.name_learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_learn_name(self): + self.is_learning_name = False + self.name_learn_btn.setText("Learn Name") + self.name_learn_btn.setStyleSheet("") + + def bind_osc_name_addr(self, address): + self.osc_addr_name_edit.setText(address) + self.stop_learn_name() + + def receive_osc_name(self, name): + """Update the scribble strip to reflect the live track name from the DAW.""" + self.title_edit.setText(name) + + def on_pickup_changed(self, state): + self.pickup_mode = bool(state) + if self.pickup_mode: + self.hunting = True + self.prev_position = self.fader.value() + self.pickup_checkbox.setStyleSheet("color: orange; font-weight: bold;") + self._start_blink() + else: + self.hunting = False + self.pickup_checkbox.setStyleSheet("color: black;") + self._stop_blink() + self._set_readout_style("normal") + + def _set_readout_style(self, mode): + if mode == "normal": + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + self.pickup_checkbox.setStyleSheet("color: black;") if not self.pickup_mode else None + elif mode == "orange": + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: orange; font-size: 10px; font-weight: bold; padding: 1px;") + elif mode == "clear": + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: transparent; font-size: 10px; padding: 1px;") + + def _start_blink(self): + if not hasattr(self, '_blink_timer'): + self._blink_timer = QTimer() + self._blink_timer.timeout.connect(self._do_blink) + self._blink_state = False + self._blink_timer.start(400) + + def _stop_blink(self): + if hasattr(self, '_blink_timer'): + self._blink_timer.stop() + self._set_readout_style("normal") + + def _do_blink(self): + self._blink_state = not self._blink_state + if self._blink_state: + self._set_readout_style("orange") + else: + self._set_readout_style("clear") + + def on_fader_moved(self, value): + addr = self.osc_addr_out_edit.text().strip() or "/track/volume" + self.cc_output_lbl.setText(self._osc_output_label(value)) + if self.pickup_mode and self.hunting: + crossed = ( + (self.prev_position < self.last_sent <= value) or + (self.prev_position > self.last_sent >= value) + ) + self.prev_position = value + if crossed: + self.hunting = False + self._stop_blink() + self._set_readout_style("orange") + self.last_sent = value + send_osc_message(addr, value / 127.0) + self._send_hw_feedback(value) + else: + self.readout.setText(f"{addr} → {value}") + return + else: + self.last_sent = value + self.prev_position = value + send_osc_message(addr, value / 127.0) + self._send_hw_feedback(value) + self.readout.setText(f"{addr} → {value}") + + def _send_hw_feedback(self, value): + """Sends a translated MIDI CC out (e.g. so a motorized fader's position + tracks this channel's value), independent of whether hardware CC input + is also enabled — you may want feedback without hardware input, or + vice versa.""" + if not self.feedback_checkbox.isChecked(): + return + out_cc = self.hw_cc_spin.value() + out_ch = self.hw_ch_spin.value() + send_cc(out_cc, value, channel=out_ch) + self.hw_cc_output_lbl.setText(f"CC{out_cc} CH{out_ch} [{value}]") + + def apply_color(self, hex_color, selected=False): + self.current_color = hex_color + border = "#00ff88" if selected else "rgba(0,0,0,0.3)" + border_width = "3px" if selected else "2px" + self.container.setObjectName("stripContainer") + self.container.setStyleSheet(f""" + QFrame#stripContainer {{ + background-color: {hex_color}; + border-radius: 6px; + border: {border_width} solid {border}; + }} + """) + + def set_selected(self, selected): + self.apply_color(self.current_color, selected=selected) + + def update_id_label(self, zone, index): + if zone == "left": + self.id_label.setText(f"L{index + 1}") + elif zone == "right": + self.id_label.setText(f"R{index + 1}") + else: + self.id_label.setText(f"{index + 1}") + + def on_zone_checked(self, zone, active): + if active: + self.zone = zone + else: + self.zone = None + if self.on_zone_change_callback: + self.on_zone_change_callback(self) + + def show_color_popup(self): + popup = ColorSwatchPopup(self.on_color_change, self) + btn_pos = self.color_swatch_btn.mapToGlobal(self.color_swatch_btn.rect().bottomLeft()) + popup.move(btn_pos) + popup.show() + + def on_color_change(self, name, hex_color=None): + if hex_color is None: + hex_color = PALETTE_HEX.get(name, self.current_color) + self.color_name = name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + + def _osc_output_label(self, value="--"): + addr = self.osc_addr_out_edit.text().strip() or "/track/volume" + if isinstance(value, (int, float)): + return f"{addr} [{value / 127.0:.2f}]" + return f"{addr} [{value}]" + + def get_state(self): + return { + "uid": self.uid, + "label": self.title_edit.text(), + "value": self.fader.value(), + "color": self.color_name, + "pickup": self.pickup_checkbox.isChecked(), + "last_sent": self.last_sent, + "osc_addr_in": self.osc_addr_in_edit.text(), + "osc_addr_out": self.osc_addr_out_edit.text(), + "osc_addr_name": self.osc_addr_name_edit.text(), + "zone": self.zone, + "center_index": self.center_index, + "zone_index": self.zone_index, + "hardware_enabled": self.hardware_checkbox.isChecked(), + "feedback_enabled": self.feedback_checkbox.isChecked(), + "hw_cc": self.hw_cc, + "hw_channel": self.hw_channel, + "hw_out_cc": self.hw_cc_spin.value(), + "hw_out_ch": self.hw_ch_spin.value(), + "touch_sense_mode": self.touch_sense_mode, + } + + def set_state(self, state): + if "uid" in state: + self.uid = state["uid"] + self.title_edit.setText(state.get("label", "")) + self.last_sent = state.get("last_sent", 64) + self.fader.setValue(state.get("value", 64)) + color_name = state.get("color", "Gray") + hex_color = PALETTE_HEX.get(color_name, "transparent") + self.color_name = color_name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + pickup = state.get("pickup", False) + self.pickup_checkbox.setChecked(pickup) + if pickup: + self.hunting = True + self._start_blink() + self.zone = state.get("zone", None) + self.center_index = state.get("center_index", None) + self.zone_index = state.get("zone_index", None) + self.zone_btn.set_state(self.zone == "left", self.zone == "right") + self.osc_addr_in_edit.setText(state.get("osc_addr_in", "/track/volume")) + self.osc_addr_out_edit.setText(state.get("osc_addr_out", "/track/volume")) + self.osc_addr_name_edit.setText(state.get("osc_addr_name", "/track/name")) + self.hw_cc = state.get("hw_cc", None) + self.hw_channel = state.get("hw_channel", None) + self.hw_cc_spin.setValue(state.get("hw_out_cc", 7)) + self.hw_ch_spin.setValue(state.get("hw_out_ch", 1)) + self.stop_hw_learn() + self.hardware_checkbox.setChecked(state.get("hardware_enabled", False)) + self.feedback_checkbox.setChecked(state.get("feedback_enabled", False)) + # jl_cooper_off/jl_cooper_on were a short-lived three-state naming; + # both collapse to the single jl_cooper mode. + saved_touch_mode = state.get("touch_sense_mode", "off") + self.touch_sense_mode = "jl_cooper" if saved_touch_mode in ("jl_cooper", "jl_cooper_off", "jl_cooper_on") else "off" + self.touch_sense_combo.blockSignals(True) + self.touch_sense_combo.setCurrentIndex(1 if self.touch_sense_mode == "jl_cooper" else 0) + self.touch_sense_combo.blockSignals(False) + self.is_touching = False + self._update_hw_visibility() diff --git a/app/focusfeedbackwidget.py b/app/focusfeedbackwidget.py new file mode 100644 index 0000000..94db1b0 --- /dev/null +++ b/app/focusfeedbackwidget.py @@ -0,0 +1,226 @@ +import uuid + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QLineEdit, QFrame, QSizePolicy, QCheckBox +) +from PyQt6.QtCore import Qt, QEvent + +from palette import PALETTE_HEX, PALETTE_VIVID +from styles import TITLE_STYLE +from colorswatchpopup import ColorSwatchPopup + + +class FocusFeedbackWidget(QWidget): + """Read-only OSC feedback display for Channel Focus — Learn an address, show whatever value arrives. No output, no interaction.""" + def __init__(self, label): + super().__init__() + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + self.setFixedWidth(115) + self.current_color = PALETTE_HEX.get("Gray", "#263238") + self.uid = str(uuid.uuid4()) + self.is_learning = False + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + self.container = QFrame() + self.container.setFrameShape(QFrame.Shape.StyledPanel) + self.apply_color(self.current_color) + + inner = QVBoxLayout(self.container) + inner.setContentsMargins(4, 6, 4, 6) + inner.setSpacing(4) + inner.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + _grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }" + + # Group: OSC input (feedback from the DAW) + self.grp_input = QFrame() + self.grp_input.setStyleSheet(_grp_style) + _grp_input_layout = QVBoxLayout(self.grp_input) + _grp_input_layout.setContentsMargins(4, 4, 4, 4) + _grp_input_layout.setSpacing(3) + + self.learn_btn = QPushButton("Learn") + self.learn_btn.setFixedHeight(20) + self.learn_btn.setStyleSheet("border: none;") + _grp_input_layout.addWidget(self.learn_btn) + + self.osc_addr_in_edit = QLineEdit("/3/trackname") + self.osc_addr_in_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.osc_addr_in_edit.setPlaceholderText("/address") + self.osc_addr_in_edit.setStyleSheet("border: none;") + _grp_input_layout.addWidget(self.osc_addr_in_edit) + + # Custom: shows a fixed, manually-typed string instead of live OSC + # feedback — disables Learn and the OSC address input while checked. + self.custom_checkbox = QCheckBox("Custom") + self.custom_checkbox.setChecked(False) + self.custom_checkbox.stateChanged.connect(self._on_custom_changed) + _grp_input_layout.addWidget(self.custom_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.custom_text_edit = QLineEdit("") + self.custom_text_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.custom_text_edit.setPlaceholderText("Custom text") + self.custom_text_edit.setStyleSheet("border: none;") + self.custom_text_edit.textChanged.connect(self._on_custom_text_changed) + self.custom_text_edit.setVisible(False) + _grp_input_layout.addWidget(self.custom_text_edit) + + inner.addWidget(self.grp_input) + + # Readout — the live feedback value + self.readout_lbl = QLabel("—") + self.readout_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.readout_lbl.setWordWrap(True) + self.readout_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 14px; font-weight: bold; padding: 6px 2px; border-radius: 4px;") + self.readout_lbl.setMinimumHeight(48) + inner.addWidget(self.readout_lbl) + + # Scribble strip (caption — what this feedback represents, e.g. "Track Name") + self.title_edit = QLineEdit(label) + self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.title_edit.setStyleSheet(TITLE_STYLE) + inner.addWidget(self.title_edit) + + # Color swatch button + self.color_name = "Gray" + self.color_swatch_btn = QPushButton() + self.color_swatch_btn.setFixedHeight(10) + self.color_swatch_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID['Gray']}; border-radius: 2px; border: none;") + self.color_swatch_btn.clicked.connect(self.show_color_popup) + inner.addWidget(self.color_swatch_btn) + + # + / - buttons + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(2) + self.minus_btn = QPushButton("-") + self.minus_btn.setFixedSize(30, 20) + self.plus_btn = QPushButton("+") + self.plus_btn.setFixedSize(30, 20) + btn_row.addWidget(self.minus_btn) + btn_row.addStretch() + btn_row.addWidget(self.plus_btn) + inner.addLayout(btn_row) + + outer.addWidget(self.container) + self.on_select_callback = None + self.on_context_menu_callback = None + + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.MouseButtonPress: + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + elif event.type() == QEvent.Type.ContextMenu: + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + return True + return False + + def mousePressEvent(self, event): + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + super().mousePressEvent(event) + + def contextMenuEvent(self, event): + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + + def start_learn(self): + self.is_learning = True + self.learn_btn.setText("Listening...") + self.learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_learn(self): + self.is_learning = False + self.learn_btn.setText("Learn") + self.learn_btn.setStyleSheet("") + + def bind_osc_addr(self, address): + self.osc_addr_in_edit.setText(address) + self.stop_learn() + + def _update_custom_visibility(self): + is_custom = self.custom_checkbox.isChecked() + self.learn_btn.setVisible(not is_custom) + self.osc_addr_in_edit.setVisible(not is_custom) + self.custom_text_edit.setVisible(is_custom) + + def _on_custom_changed(self, _state): + self._update_custom_visibility() + if self.custom_checkbox.isChecked(): + self.readout_lbl.setText(self.custom_text_edit.text()) + + def _on_custom_text_changed(self, text): + if self.custom_checkbox.isChecked(): + self.readout_lbl.setText(text) + + def receive_osc_value(self, value): + """Display whatever feedback arrives on the bound address, verbatim.""" + if self.custom_checkbox.isChecked(): + return + self.readout_lbl.setText(value) + + def apply_color(self, hex_color, selected=False): + self.current_color = hex_color + border = "#00ff88" if selected else "rgba(0,0,0,0.3)" + border_width = "3px" if selected else "2px" + self.container.setObjectName("stripContainer") + self.container.setStyleSheet(f""" + QFrame#stripContainer {{ + background-color: {hex_color}; + border-radius: 6px; + border: {border_width} solid {border}; + }} + """) + + def set_selected(self, selected): + self.apply_color(self.current_color, selected=selected) + + def show_color_popup(self): + popup = ColorSwatchPopup(self.on_color_change, self) + btn_pos = self.color_swatch_btn.mapToGlobal(self.color_swatch_btn.rect().bottomLeft()) + popup.move(btn_pos) + popup.show() + + def on_color_change(self, name, hex_color=None): + if hex_color is None: + hex_color = PALETTE_HEX.get(name, self.current_color) + self.color_name = name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + + def get_state(self): + return { + "uid": self.uid, + "label": self.title_edit.text(), + "text": self.readout_lbl.text(), + "color": self.color_name, + "osc_addr_in": self.osc_addr_in_edit.text(), + "custom_enabled": self.custom_checkbox.isChecked(), + "custom_text": self.custom_text_edit.text(), + } + + def set_state(self, state): + if "uid" in state: + self.uid = state["uid"] + self.title_edit.setText(state.get("label", "")) + self.readout_lbl.setText(state.get("text", "—")) + color_name = state.get("color", "Gray") + hex_color = PALETTE_HEX.get(color_name, "transparent") + self.color_name = color_name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + self.osc_addr_in_edit.setText(state.get("osc_addr_in", "/3/trackname")) + self.custom_text_edit.setText(state.get("custom_text", "")) + self.custom_checkbox.setChecked(state.get("custom_enabled", False)) + self._update_custom_visibility() diff --git a/app/focustogglewidget.py b/app/focustogglewidget.py new file mode 100644 index 0000000..8a16d00 --- /dev/null +++ b/app/focustogglewidget.py @@ -0,0 +1,491 @@ +import uuid + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QLineEdit, QFrame, QSizePolicy, QCheckBox, QSpinBox +) +from PyQt6.QtCore import Qt, QEvent, QTimer + +from palette import PALETTE_HEX, PALETTE_VIVID +from styles import TITLE_STYLE +from zonebutton import ZoneButton +from colorswatchpopup import ColorSwatchPopup +from osc_sender import send_osc_message +from midi_sender import send_cc + + +class FocusToggleWidget(QWidget): + """Direct DAW-focus toggle — OSC-only output, shown on any iPad in Channel Focus mode, no hardware learn input.""" + def __init__(self, label): + super().__init__() + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + self.setFixedWidth(115) + self.current_color = PALETTE_HEX.get("Gray", "#263238") + self.toggle_state = False + self.trigger_mode = "toggle" + self.uid = str(uuid.uuid4()) + self.zone = None + self.center_index = None + self.zone_index = None + self.on_zone_change_callback = None + + self.is_learning = False + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + self.container = QFrame() + self.container.setFrameShape(QFrame.Shape.StyledPanel) + self.apply_color(self.current_color) + + inner = QVBoxLayout(self.container) + inner.setContentsMargins(4, 6, 4, 6) + inner.setSpacing(4) + inner.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + _grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }" + + # Group: OSC input (feedback from the DAW) + self.grp_input = QFrame() + self.grp_input.setStyleSheet(_grp_style) + _grp_input_layout = QVBoxLayout(self.grp_input) + _grp_input_layout.setContentsMargins(4, 4, 4, 4) + _grp_input_layout.setSpacing(3) + + self.learn_btn = QPushButton("Learn") + self.learn_btn.setFixedHeight(20) + self.learn_btn.setStyleSheet("border: none;") + _grp_input_layout.addWidget(self.learn_btn) + + self.osc_addr_in_edit = QLineEdit("/track/mute") + self.osc_addr_in_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.osc_addr_in_edit.setPlaceholderText("/address") + self.osc_addr_in_edit.setStyleSheet("border: none;") + _grp_input_layout.addWidget(self.osc_addr_in_edit) + + self.cc_input_lbl = QLabel("") + self.cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;") + self.cc_input_lbl.setFixedHeight(16) + _grp_input_layout.addWidget(self.cc_input_lbl) + + inner.addWidget(self.grp_input) + + self.arrow_lbl = QLabel("▼") + self.arrow_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.arrow_lbl.setStyleSheet("color: rgba(255,255,255,0.3); font-size: 9px; background: transparent;") + self.arrow_lbl.setFixedHeight(12) + inner.addWidget(self.arrow_lbl) + + # Group: OSC output controls + readout (sent to the DAW on click) + self.grp_cc = QFrame() + self.grp_cc.setStyleSheet(_grp_style) + _grp2_layout = QVBoxLayout(self.grp_cc) + _grp2_layout.setContentsMargins(4, 4, 4, 4) + _grp2_layout.setSpacing(3) + + # Momentary / Toggle mode selector + trigger_row = QHBoxLayout() + trigger_row.setContentsMargins(0, 0, 0, 0) + trigger_row.setSpacing(2) + self.momentary_btn = QPushButton("Mom.") + self.momentary_btn.setFixedHeight(18) + self.momentary_btn.setCheckable(True) + self.momentary_btn.setChecked(False) + self.momentary_btn.setStyleSheet("border: none;") + self.toggle_mode_btn = QPushButton("Tog.") + self.toggle_mode_btn.setFixedHeight(18) + self.toggle_mode_btn.setCheckable(True) + self.toggle_mode_btn.setChecked(True) + self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;") + self.momentary_btn.clicked.connect(lambda: self.set_trigger_mode("momentary")) + self.toggle_mode_btn.clicked.connect(lambda: self.set_trigger_mode("toggle")) + trigger_row.addWidget(self.momentary_btn) + trigger_row.addWidget(self.toggle_mode_btn) + _grp2_layout.addLayout(trigger_row) + + self.osc_addr_out_edit = QLineEdit("/track/mute") + self.osc_addr_out_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.osc_addr_out_edit.setPlaceholderText("/address") + self.osc_addr_out_edit.setStyleSheet("border: none;") + _grp2_layout.addWidget(self.osc_addr_out_edit) + + self.cc_output_lbl = QLabel(self._osc_output_label("--")) + self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") + self.cc_output_lbl.setFixedHeight(16) + _grp2_layout.addWidget(self.cc_output_lbl) + + inner.addWidget(self.grp_cc) + + # Group: Hardware — optional physical MIDI CC control alongside the + # app/OSC control (e.g. a JL Cooper button), positioned like grp_remote + # on the regular widgets rather than up with the OSC learn section. + # "Hardware" binds a hw CC/note that can trigger this toggle; independently, + # "Feedback" sends a translated CC back out on every state change (from + # hardware, OSC feedback, or the app) so hardware LEDs/motors stay in + # sync — separate switches since you may want one without the other. + self.hw_cc = None + self.hw_channel = None + self.is_learning_hw = False + + self.grp_hardware = QFrame() + self.grp_hardware.setStyleSheet(_grp_style) + _grp_hw_layout = QVBoxLayout(self.grp_hardware) + _grp_hw_layout.setContentsMargins(4, 4, 4, 4) + _grp_hw_layout.setSpacing(3) + + self.hardware_checkbox = QCheckBox("Hardware") + self.hardware_checkbox.setChecked(False) + self.hardware_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility()) + _grp_hw_layout.addWidget(self.hardware_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.hw_learn_btn = QPushButton("Learn") + self.hw_learn_btn.setFixedHeight(20) + self.hw_learn_btn.setStyleSheet("border: none;") + _grp_hw_layout.addWidget(self.hw_learn_btn) + + self.hw_cc_input_lbl = QLabel("") + self.hw_cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;") + self.hw_cc_input_lbl.setFixedHeight(16) + _grp_hw_layout.addWidget(self.hw_cc_input_lbl) + + self.feedback_checkbox = QCheckBox("Feedback") + self.feedback_checkbox.setChecked(False) + self.feedback_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility()) + _grp_hw_layout.addWidget(self.feedback_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.hw_cc_spin = QSpinBox() + self.hw_cc_spin.setMinimum(0) + self.hw_cc_spin.setMaximum(127) + self.hw_cc_spin.setValue(7) + self.hw_cc_spin.setFixedWidth(55) + _hw_cc_lbl = QLabel("CC") + _hw_cc_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;") + self.hw_cc_spin_row = QWidget() + _hbox_hw_cc = QHBoxLayout(self.hw_cc_spin_row) + _hbox_hw_cc.setContentsMargins(0, 0, 0, 0) + _hbox_hw_cc.setSpacing(4) + _hbox_hw_cc.addWidget(_hw_cc_lbl) + _hbox_hw_cc.addWidget(self.hw_cc_spin) + _grp_hw_layout.addWidget(self.hw_cc_spin_row) + + self.hw_ch_spin = QSpinBox() + self.hw_ch_spin.setMinimum(1) + self.hw_ch_spin.setMaximum(16) + self.hw_ch_spin.setValue(1) + self.hw_ch_spin.setFixedWidth(55) + _hw_ch_lbl = QLabel("CH") + _hw_ch_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;") + self.hw_ch_spin_row = QWidget() + _hbox_hw_ch = QHBoxLayout(self.hw_ch_spin_row) + _hbox_hw_ch.setContentsMargins(0, 0, 0, 0) + _hbox_hw_ch.setSpacing(4) + _hbox_hw_ch.addWidget(_hw_ch_lbl) + _hbox_hw_ch.addWidget(self.hw_ch_spin) + _grp_hw_layout.addWidget(self.hw_ch_spin_row) + + self.hw_cc_output_lbl = QLabel(f"CC{self.hw_cc_spin.value()} CH{self.hw_ch_spin.value()} [--]") + self.hw_cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.hw_cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") + self.hw_cc_output_lbl.setFixedHeight(16) + _grp_hw_layout.addWidget(self.hw_cc_output_lbl) + + inner.addWidget(self.grp_hardware) + + # Toggle button with LED dot overlay + btn_container = QWidget() + btn_container.setFixedSize(60, 60) + + self.btn = QPushButton("", btn_container) + self.btn.setFixedSize(60, 60) + self.btn.clicked.connect(self.on_click) + + self.led_dot = QLabel(btn_container) + self.led_dot.setFixedSize(6, 6) + self.led_dot.move(5, 49) + self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;") + + inner.addWidget(btn_container, alignment=Qt.AlignmentFlag.AlignHCenter) + + # Scribble strip + self.title_edit = QLineEdit(label) + self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.title_edit.setStyleSheet(TITLE_STYLE) + inner.addWidget(self.title_edit) + + # Color swatch button + self.color_name = "Gray" + self.color_swatch_btn = QPushButton() + self.color_swatch_btn.setFixedHeight(10) + self.color_swatch_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID['Gray']}; border-radius: 2px; border: none;") + self.color_swatch_btn.clicked.connect(self.show_color_popup) + inner.addWidget(self.color_swatch_btn) + + # + / - buttons + toggle_btn_row = QHBoxLayout() + toggle_btn_row.setContentsMargins(0, 0, 0, 0) + toggle_btn_row.setSpacing(2) + self.minus_btn = QPushButton("-") + self.minus_btn.setFixedSize(30, 20) + self.plus_btn = QPushButton("+") + self.plus_btn.setFixedSize(30, 20) + toggle_btn_row.addWidget(self.minus_btn) + toggle_btn_row.addStretch() + toggle_btn_row.addWidget(self.plus_btn) + inner.addLayout(toggle_btn_row) + + # Zone button + self.zone_btn = ZoneButton() + self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active) + self.zone_btn.right_callback = lambda active: self.on_zone_checked("right", active) + inner.addWidget(self.zone_btn) + + # Separator before ID label + id_sep = QWidget() + id_sep.setFixedHeight(1) + id_sep.setStyleSheet("background-color: #3a3a3a;") + inner.addWidget(id_sep) + + # Channel ID label + self.id_label = QLabel("") + self.id_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.id_label.setStyleSheet("color: #ffffff; font-size: 13px; font-weight: bold; background: transparent;") + inner.addWidget(self.id_label) + + outer.addWidget(self.container) + self.on_select_callback = None + self.on_context_menu_callback = None + + self._update_hw_visibility() + + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.MouseButtonPress: + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + elif event.type() == QEvent.Type.ContextMenu: + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + return True + return False + + def mousePressEvent(self, event): + if event.button() != Qt.MouseButton.RightButton: + if self.on_select_callback: + self.on_select_callback(self) + super().mousePressEvent(event) + + def contextMenuEvent(self, event): + if self.on_context_menu_callback: + self.on_context_menu_callback(self, event.globalPos()) + + def start_learn(self): + self.is_learning = True + self.learn_btn.setText("Listening...") + self.learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_learn(self): + self.is_learning = False + self.learn_btn.setText("Learn") + self.learn_btn.setStyleSheet("") + + def bind_osc_addr(self, address): + self.osc_addr_in_edit.setText(address) + self.stop_learn() + + def _update_hw_visibility(self): + hw_on = self.hardware_checkbox.isChecked() + self.hw_learn_btn.setVisible(hw_on) + self.hw_cc_input_lbl.setVisible(hw_on) + fb_on = self.feedback_checkbox.isChecked() + self.hw_cc_spin_row.setVisible(fb_on) + self.hw_ch_spin_row.setVisible(fb_on) + self.hw_cc_output_lbl.setVisible(fb_on) + + def start_hw_learn(self): + self.is_learning_hw = True + self.hw_learn_btn.setText("Listening...") + self.hw_learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_hw_learn(self): + self.is_learning_hw = False + self.hw_learn_btn.setStyleSheet("") + if self.hw_cc is not None: + self.hw_learn_btn.setText(f"HW: CC{self.hw_cc}") + self.hw_cc_input_lbl.setText(self._hw_cc_input_label()) + self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + else: + self.hw_learn_btn.setText("Learn") + self.hw_cc_input_lbl.setText("") + self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + + def bind_hw_cc(self, cc_number, channel=None): + self.hw_cc = cc_number + self.hw_channel = channel + self.stop_hw_learn() + + def _hw_cc_input_label(self, vel="--"): + if self.hw_cc is None: + return "" + ch = f" CH{self.hw_channel}" if self.hw_channel is not None else "" + return f"CC{self.hw_cc}{ch} [{vel}]" + + def _send_hw_feedback(self, value): + """Sends a translated MIDI CC out (e.g. so a hardware button's LED + tracks this toggle's state), independent of whether hardware CC input + is also enabled.""" + if not self.feedback_checkbox.isChecked(): + return + out_cc = self.hw_cc_spin.value() + out_ch = self.hw_ch_spin.value() + send_cc(out_cc, value, channel=out_ch) + self.hw_cc_output_lbl.setText(f"CC{out_cc} CH{out_ch} [{value}]") + + def receive_osc_value(self, value): + """Update the toggle to reflect feedback from the DAW, without echoing it back out.""" + self.toggle_state = value >= 0.5 + self.cc_input_lbl.setText(f"[{value:.2f}]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;") + self._update_btn_style() + + def apply_color(self, hex_color, selected=False): + self.current_color = hex_color + border = "#00ff88" if selected else "rgba(0,0,0,0.3)" + border_width = "3px" if selected else "2px" + self.container.setObjectName("stripContainer") + self.container.setStyleSheet(f""" + QFrame#stripContainer {{ + background-color: {hex_color}; + border-radius: 6px; + border: {border_width} solid {border}; + }} + """) + + def set_selected(self, selected): + self.apply_color(self.current_color, selected=selected) + + def update_id_label(self, zone, index): + if zone == "left": + self.id_label.setText(f"L{index + 1}") + elif zone == "right": + self.id_label.setText(f"R{index + 1}") + else: + self.id_label.setText(f"{index + 1}") + + def on_zone_checked(self, zone, active): + if active: + self.zone = zone + else: + self.zone = None + if self.on_zone_change_callback: + self.on_zone_change_callback(self) + + def show_color_popup(self): + popup = ColorSwatchPopup(self.on_color_change, self) + btn_pos = self.color_swatch_btn.mapToGlobal(self.color_swatch_btn.rect().bottomLeft()) + popup.move(btn_pos) + popup.show() + + def on_color_change(self, name, hex_color=None): + if hex_color is None: + hex_color = PALETTE_HEX.get(name, self.current_color) + self.color_name = name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + self._update_btn_style() + + def set_trigger_mode(self, mode): + self.trigger_mode = mode + if mode == "momentary": + self.momentary_btn.setChecked(True) + self.toggle_mode_btn.setChecked(False) + self.momentary_btn.setStyleSheet("font-weight: bold; color: #00e676;") + self.toggle_mode_btn.setStyleSheet("") + else: + self.toggle_mode_btn.setChecked(True) + self.momentary_btn.setChecked(False) + self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;") + self.momentary_btn.setStyleSheet("") + + def on_click(self): + addr = self.osc_addr_out_edit.text().strip() or "/track/mute" + if self.trigger_mode == "momentary": + send_osc_message(addr, 1.0) + self.cc_output_lbl.setText(self._osc_output_label(1.0)) + self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") + QTimer.singleShot(150, lambda: self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;")) + self._send_hw_feedback(127) + else: + self.toggle_state = not self.toggle_state + value = 1.0 if self.toggle_state else 0.0 + send_osc_message(addr, value) + self.cc_output_lbl.setText(self._osc_output_label(value)) + self._update_btn_style() + self._send_hw_feedback(127 if self.toggle_state else 0) + + def _update_btn_style(self): + if self.toggle_state: + self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") + else: + self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;") + + def _osc_output_label(self, value="--"): + addr = self.osc_addr_out_edit.text().strip() or "/track/mute" + if isinstance(value, (int, float)): + return f"{addr} [{value:.1f}]" + return f"{addr} [{value}]" + + def get_state(self): + return { + "uid": self.uid, + "label": self.title_edit.text(), + "state": self.toggle_state, + "color": self.color_name, + "zone": self.zone, + "center_index": self.center_index, + "zone_index": self.zone_index, + "trigger_mode": self.trigger_mode, + "osc_addr_in": self.osc_addr_in_edit.text(), + "osc_addr_out": self.osc_addr_out_edit.text(), + "hardware_enabled": self.hardware_checkbox.isChecked(), + "feedback_enabled": self.feedback_checkbox.isChecked(), + "hw_cc": self.hw_cc, + "hw_channel": self.hw_channel, + "hw_out_cc": self.hw_cc_spin.value(), + "hw_out_ch": self.hw_ch_spin.value(), + } + + def set_state(self, state): + if "uid" in state: + self.uid = state["uid"] + self.title_edit.setText(state.get("label", "")) + color_name = state.get("color", "Gray") + hex_color = PALETTE_HEX.get(color_name, "transparent") + self.color_name = color_name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + self.toggle_state = state.get("state", False) + self._update_btn_style() + self.cc_output_lbl.setText(self._osc_output_label(1.0 if self.toggle_state else 0.0)) + self.zone = state.get("zone", None) + self.center_index = state.get("center_index", None) + self.zone_index = state.get("zone_index", None) + self.zone_btn.set_state(self.zone == "left", self.zone == "right") + self.set_trigger_mode(state.get("trigger_mode", "toggle")) + self.osc_addr_in_edit.setText(state.get("osc_addr_in", "/track/mute")) + self.osc_addr_out_edit.setText(state.get("osc_addr_out", "/track/mute")) + self.hw_cc = state.get("hw_cc", None) + self.hw_channel = state.get("hw_channel", None) + self.hw_cc_spin.setValue(state.get("hw_out_cc", 7)) + self.hw_ch_spin.setValue(state.get("hw_out_ch", 1)) + self.stop_hw_learn() + self.hardware_checkbox.setChecked(state.get("hardware_enabled", False)) + self.feedback_checkbox.setChecked(state.get("feedback_enabled", False)) + self._update_hw_visibility() diff --git a/app/main.py b/app/main.py index 256111b..92e5a6b 100644 --- a/app/main.py +++ b/app/main.py @@ -8,16 +8,20 @@ from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSlider, QComboBox, QLabel, QPushButton, QSizePolicy, QSpinBox, QLineEdit, QInputDialog, QFrame, - QCheckBox, QTextEdit + QCheckBox, QTextEdit, QDialog, QListWidget, QListWidgetItem ) from PyQt6.QtCore import Qt, QObject, pyqtSignal, QTimer, QEvent from PyQt6.QtGui import QAction, QColor from pythonosc import dispatcher as osc_dispatcher from pythonosc import osc_server import threading +from zeroconf import Zeroconf, ServiceInfo from togglewidget import ToggleWidget from faderwidget import FaderWidget +from focusfaderwidget import FocusFaderWidget +from focustogglewidget import FocusToggleWidget +from focusfeedbackwidget import FocusFeedbackWidget from transportwidget import TransportWidget from presetwidget import PresetWidget @@ -83,6 +87,10 @@ _osc_thread = None ws_server = None +BONJOUR_SERVICE_TYPE = "_vcremote._tcp.local." +_bonjour_zc = None +_bonjour_info = None + @@ -102,6 +110,7 @@ toggles = [] transports = [] toggle_strip_width = 100 midi_in_port = None +midi_controller_in_port = None fader_show_hide_config = False transport_show_hide_config = False @@ -250,6 +259,30 @@ picker_midi_in = QComboBox() picker_midi_in.setFixedWidth(200) # picker_midi_in.setStyleSheet("background-color: rgba(0, 255, 0, 0.15); border: 1px solid green;") +# Second, independent MIDI input dedicated to controllers only (e.g. JL Cooper +# fader bank) — same CC learn/routing pipeline as picker_midi_in, but never +# passes Note On/Off through to MIDI Out. + +# vstack_midi_controller_in +vstack_midi_controller_in = QVBoxLayout() +vstack_midi_controller_in.setSpacing(2) + +# hstack_midi_controller_in_activity_and_label +hstack_midi_controller_in_activity_and_label = QHBoxLayout() + +# activity_indicator_midi_controller_in +activity_indicator_midi_controller_in = QLabel("●") + +# label_connection_midi_controller_in +label_connection_midi_controller_in = QLabel("Not Connected") + +# label_midi_controller_in +label_midi_controller_in = QLabel("MIDI Controller In") + +# picker_midi_controller_in +picker_midi_controller_in = QComboBox() +picker_midi_controller_in.setFixedWidth(200) + # REFACTOR MAP: Old names → New names # self.out_blink → activity_indicator_midi_out @@ -393,6 +426,11 @@ button_layout_panel = QPushButton("Layout") button_layout_panel.setFixedWidth(65) button_layout_panel.setCheckable(True) +# Widget: button_focus_panel +button_focus_panel = QPushButton("Focus") +button_focus_panel.setFixedWidth(65) +button_focus_panel.setCheckable(True) + # Layout: hstack_osc_status hstack_osc_status = QHBoxLayout() @@ -729,6 +767,84 @@ del _btn vstack_layout_panel.addStretch() +# MARK: FOCUS PANEL +container_focus_panel = QWidget(None, Qt.WindowType.Window) +container_focus_panel.setWindowTitle("Focus") +container_focus_panel.setStyleSheet("background-color: #1a1a1a;") +container_focus_panel.setFixedWidth(220) + +vstack_focus_panel = QVBoxLayout(container_focus_panel) +vstack_focus_panel.setContentsMargins(10, 10, 10, 10) +vstack_focus_panel.setSpacing(6) + +label_focus_panel_title = QLabel("Channel Focus") +label_focus_panel_title.setStyleSheet("color: #ffffff; font-weight: bold; font-size: 12px;") +vstack_focus_panel.addWidget(label_focus_panel_title) + +_focus_section_sep = QFrame() +_focus_section_sep.setFrameShape(QFrame.Shape.HLine) +_focus_section_sep.setStyleSheet("color: #3a3a3a;") +vstack_focus_panel.addWidget(_focus_section_sep) + +# Row: focus_faders (multiple OSC-only faders — track volume, sends, master, etc.) +hstack_focus_faders = QHBoxLayout() +hstack_focus_faders.setSpacing(6) +vstack_focus_panel.addLayout(hstack_focus_faders) + +focus_faders = [] + +def _wire_focus_fader(fader): + fader.learn_btn.clicked.connect(lambda: start_osc_learn(fader)) + fader.name_learn_btn.clicked.connect(lambda: start_osc_learn(fader, target="name")) + fader.hw_learn_btn.clicked.connect(lambda: check_and_start_focus_hw_learn(fader)) + fader.title_edit.editingFinished.connect(lambda: _broadcast_preset_update()) + fader.plus_btn.clicked.connect(lambda: focus_fader_add(insert_after=fader)) + fader.minus_btn.clicked.connect(lambda: focus_fader_remove(fader)) + +_first_focus_fader = FocusFaderWidget("Focus") +focus_faders.append(_first_focus_fader) +hstack_focus_faders.addWidget(_first_focus_fader, alignment=Qt.AlignmentFlag.AlignHCenter) +_wire_focus_fader(_first_focus_fader) + +# Row: focus_toggles (multiple OSC-only toggles, shown on any iPad in Channel Focus mode) +hstack_focus_toggles = QHBoxLayout() +hstack_focus_toggles.setSpacing(6) +vstack_focus_panel.addLayout(hstack_focus_toggles) + +focus_toggles = [] + +def _wire_focus_toggle(toggle): + toggle.learn_btn.clicked.connect(lambda: start_osc_learn(toggle)) + toggle.hw_learn_btn.clicked.connect(lambda: check_and_start_focus_hw_learn(toggle)) + toggle.title_edit.editingFinished.connect(lambda: _broadcast_preset_update()) + toggle.plus_btn.clicked.connect(lambda: focus_toggle_add(insert_after=toggle)) + toggle.minus_btn.clicked.connect(lambda: focus_toggle_remove(toggle)) + +_first_focus_toggle = FocusToggleWidget("Toggle 1") +focus_toggles.append(_first_focus_toggle) +hstack_focus_toggles.addWidget(_first_focus_toggle, alignment=Qt.AlignmentFlag.AlignHCenter) +_wire_focus_toggle(_first_focus_toggle) + +# Row: focus_feedbacks (multiple read-only OSC feedback displays, e.g. track name, bar, time) +hstack_focus_feedbacks = QHBoxLayout() +hstack_focus_feedbacks.setSpacing(6) +vstack_focus_panel.addLayout(hstack_focus_feedbacks) + +focus_feedbacks = [] + +def _wire_focus_feedback(widget): + widget.learn_btn.clicked.connect(lambda: start_osc_learn(widget)) + widget.title_edit.editingFinished.connect(lambda: _broadcast_preset_update()) + widget.plus_btn.clicked.connect(lambda: focus_feedback_add(insert_after=widget)) + widget.minus_btn.clicked.connect(lambda: focus_feedback_remove(widget)) + +_first_focus_feedback = FocusFeedbackWidget("Track Name") +focus_feedbacks.append(_first_focus_feedback) +hstack_focus_feedbacks.addWidget(_first_focus_feedback, alignment=Qt.AlignmentFlag.AlignHCenter) +_wire_focus_feedback(_first_focus_feedback) + +vstack_focus_panel.addStretch() + # MARK: PRESET BROWSER ROW MODULE label_preset_browser_title = QLabel("Instrument Browser") hstack_preset_browser = QHBoxLayout() @@ -791,6 +907,7 @@ 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.remote_checkbox.clicked.connect(lambda: _on_remote_checkbox_changed()) _t.dest_spin.valueChanged.connect(lambda val: _on_remote_checkbox_changed()) _t.title_edit.editingFinished.connect(lambda: _broadcast_preset_update()) del _t, _label, _cc @@ -997,6 +1114,41 @@ class MainWindow(QMainWindow): #section seperator main_layout.addWidget(make_separator()) + #MARK: MIDI CONTROLLER IN COLUMN + # Globals for mutation safety + global vstack_midi_controller_in + global hstack_midi_controller_in_activity_and_label + global activity_indicator_midi_controller_in + global label_connection_midi_controller_in + global label_midi_controller_in + global picker_midi_controller_in + global available_ports_midi_controller_in + + # build the hstack with activity indicator and connection state label + hstack_midi_controller_in_activity_and_label.addWidget(activity_indicator_midi_controller_in) + hstack_midi_controller_in_activity_and_label.addWidget(label_connection_midi_controller_in) + hstack_midi_controller_in_activity_and_label.addStretch() + + # add all 3 to the vstack + vstack_midi_controller_in.addLayout(hstack_midi_controller_in_activity_and_label) #activity indicator and connection label + vstack_midi_controller_in.addWidget(label_midi_controller_in) # midi controller input label + vstack_midi_controller_in.addWidget(picker_midi_controller_in) # dropdown menu + + # picker_midi_controller_in population & signal + available_ports_midi_controller_in = get_available_input_ports() + picker_midi_controller_in.addItem("Not Connected") + for display_name, idx, available in available_ports_midi_controller_in: + picker_midi_controller_in.addItem(display_name) + if not available: + item_index = picker_midi_controller_in.count() - 1 + picker_midi_controller_in.model().item(item_index).setEnabled(False) + picker_midi_controller_in.model().item(item_index).setForeground(QColor('#888888')) + + picker_midi_controller_in.currentTextChanged.connect(check_controller_in_port_avail_and_assign) + + # add the combined stack to the row + hstack_io_row.addLayout(vstack_midi_controller_in) + #MARK: MIDI IN COLUMN 1 #build the hstack with activity indicator and connection state label hstack_midi_in_activity_and_label.addWidget(activity_indicator_midi_in) @@ -1115,6 +1267,7 @@ class MainWindow(QMainWindow): hstack_osc_buttons.addWidget(button_osc_toggle) hstack_osc_buttons.addWidget(button_osc_messages_log) hstack_osc_buttons.addWidget(button_layout_panel) + hstack_osc_buttons.addWidget(button_focus_panel) hstack_osc_buttons.addSpacing(16) hstack_osc_buttons.addWidget(button_tablet_server) hstack_osc_buttons.addWidget(button_tablet_log) @@ -1131,9 +1284,11 @@ class MainWindow(QMainWindow): # connect signals textbox_osc_daw_ip.textChanged.connect(update_osc_server_ip) spinner_osc_send_port.valueChanged.connect(update_osc_server_port) + spinner_osc_listen_port.valueChanged.connect(lambda _: save_io_config()) button_osc_toggle.clicked.connect(start_stop_osc_server) button_osc_messages_log.clicked.connect(log_ui_show_hide) button_layout_panel.clicked.connect(layout_panel_show_hide) + button_focus_panel.clicked.connect(focus_panel_show_hide) # add the combined stack to the row hstack_io_row.addLayout(vstack_osc) @@ -1286,6 +1441,9 @@ class MainWindow(QMainWindow): container_layout_panel.setVisible(True) button_layout_panel.setChecked(True) + #MARK: FOCUS PANEL + container_focus_panel.setVisible(False) + container_transport_row = QWidget() _vbox_transport_row = QVBoxLayout(container_transport_row) _vbox_transport_row.setContentsMargins(0, 0, 0, 0) @@ -1516,6 +1674,9 @@ class MainWindow(QMainWindow): check_preset_previous_and_assign() load_transport_preset() load_io_config() + load_focus_faders_state() + load_focus_toggles_state() + load_focus_feedbacks_state() transport_show_hide_config_set(loaded_presets.get("transport_show_hide", False)) check_and_index_strip_labels() check_midi_port_availability() @@ -1526,6 +1687,8 @@ class MainWindow(QMainWindow): midi_receiver.midi_out_signal.connect(log_midi_outgoing) midi_receiver.transport_out_signal.connect(lambda: make_activity_blink(activity_indicator_transport_out)) midi_receiver.hw_cc_signal.connect(midi_message_route_hw_to_output_cc) + midi_receiver.controller_in_signal.connect(lambda msg: make_activity_blink(activity_indicator_midi_controller_in)) + midi_receiver.controller_in_signal.connect(log_midi_incoming) # OSC signals osc_receiver.track_name_signal.connect(update_ui_on_osc_track_received) @@ -1535,6 +1698,11 @@ class MainWindow(QMainWindow): osc_receiver.play_signal.connect(update_ui_on_osc_play_transport_received) osc_receiver.record_signal.connect(update_ui_on_osc_record_transport_received) osc_receiver.raw_message_signal.connect(log_osc_incoming) + osc_receiver.raw_message_signal.connect(_on_osc_learn_message) + osc_receiver.raw_message_signal.connect(_on_osc_focus_fader_feedback) + osc_receiver.raw_message_signal.connect(_on_osc_focus_fader_name_feedback) + osc_receiver.raw_message_signal.connect(_on_osc_focus_toggles_feedback) + osc_receiver.raw_message_signal.connect(_on_osc_focus_feedbacks_feedback) osc_receiver.osc_out_signal.connect(log_osc_outgoing) osc_receiver.osc_out_signal.connect(lambda *_: make_activity_blink(activity_indicator_osc)) osc_receiver.uuid_preset.connect(switch_preset_by_uuid) @@ -1544,7 +1712,8 @@ class MainWindow(QMainWindow): uuid_presets_batch_timer.setSingleShot(True) uuid_presets_batch_timer.timeout.connect(resolve_uuid_batch) - QTimer.singleShot(0, start_stop_osc_server) + QTimer.singleShot(0, _auto_start_osc_server) + QTimer.singleShot(0, _auto_start_tablet_server) def resizeEvent(self, event): super().resizeEvent(event) @@ -1552,6 +1721,8 @@ class MainWindow(QMainWindow): log_ui_window_calc() if container_layout_panel.isVisible(): layout_panel_window_calc() + if container_focus_panel.isVisible(): + focus_panel_window_calc() if container_tablet_log.isVisible(): tablet_log_window_calc() @@ -1561,6 +1732,8 @@ class MainWindow(QMainWindow): log_ui_window_calc() if container_layout_panel.isVisible(): layout_panel_window_calc() + if container_focus_panel.isVisible(): + focus_panel_window_calc() if container_tablet_log.isVisible(): tablet_log_window_calc() @@ -1691,7 +1864,7 @@ def check_midi_port_availability(): for i in range(picker_midi_out.model().rowCount()): item = picker_midi_out.model().item(i) base = item.text().replace(" [in use]", "") - if base == t_port and base != out_port: + if base != "Not Connected" and base == t_port and base != out_port: item.setText(f"{base} [in use]") item.setEnabled(False) item.setForeground(QColor('#888888')) @@ -1703,7 +1876,7 @@ def check_midi_port_availability(): for i in range(picker_transport_out.model().rowCount()): item = picker_transport_out.model().item(i) base = item.text().replace(" [in use]", "") - if base == out_port and base != t_port: + if base != "Not Connected" and base == out_port and base != t_port: item.setText(f"{base} [in use]") item.setEnabled(False) item.setForeground(QColor('#888888')) @@ -1828,6 +2001,11 @@ def update_osc_server_port(port): set_osc_target(port=port) save_io_config() +def _auto_start_osc_server(): + should_start = loaded_presets.get("io_config", {}).get("osc_running", True) + if should_start: + start_stop_osc_server() + def is_osc_port_in_use(port): with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: try: @@ -1845,6 +2023,7 @@ def start_stop_osc_server(): button_osc_toggle.setText("Start OSC") activity_indicator_osc.setStyleSheet("color: #444; font-size: 14px;") container_osc_feedback.setVisible(False) + save_osc_running_state(False) else: port = spinner_osc_listen_port.value() if is_osc_port_in_use(port): @@ -1858,6 +2037,7 @@ def start_stop_osc_server(): button_osc_toggle.setText("Stop OSC") activity_indicator_osc.setStyleSheet("color: #2ecc71; font-size: 14px;") container_osc_feedback.setVisible(True) + save_osc_running_state(True) else: label_connection_osc.setText("Failed") label_connection_osc.setStyleSheet("color: #c0392b;") @@ -1922,6 +2102,21 @@ def check_and_start_midi_learn(strip): learning_strip = strip strip.start_learn() +def check_and_start_focus_hw_learn(widget): + """Same shared learning_strip mechanism as check_and_start_midi_learn, but + for Channel Focus faders/toggles — their start_learn/stop_learn names are + already used for OSC address learn, so this calls the differently-named + start_hw_learn/stop_hw_learn instead, to bind the optional Hardware CC.""" + global learning_strip + + if learning_strip and learning_strip != widget: + if hasattr(learning_strip, "stop_hw_learn"): + learning_strip.stop_hw_learn() + else: + learning_strip.stop_learn() + learning_strip = widget + widget.start_hw_learn() + preset_browser_back.learn_btn.clicked.connect(lambda: check_and_start_midi_learn(preset_browser_back)) preset_browser_fwd.learn_btn.clicked.connect(lambda: check_and_start_midi_learn(preset_browser_fwd)) @@ -2195,6 +2390,7 @@ 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.remote_checkbox.clicked.connect(lambda: _on_remote_checkbox_changed()) toggle.dest_spin.valueChanged.connect(lambda val: _on_remote_checkbox_changed()) toggle.title_edit.editingFinished.connect(_broadcast_preset_update) update_window_width() @@ -2230,6 +2426,7 @@ 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.remote_checkbox.clicked.connect(lambda: _on_remote_checkbox_changed()) fader.dest_spin.valueChanged.connect(lambda val: _on_remote_checkbox_changed()) fader.title_edit.editingFinished.connect(_broadcast_preset_update) update_window_width() @@ -2248,6 +2445,69 @@ def fader_remove(fader): check_and_index_strip_labels() _update_fader_placeholder() +def focus_fader_add(insert_after=None): + n = len(focus_faders) + 1 + fader = FocusFaderWidget(f"Focus {n}") + if insert_after is not None and insert_after in focus_faders: + idx = focus_faders.index(insert_after) + focus_faders.insert(idx + 1, fader) + hstack_focus_faders.insertWidget(idx + 1, fader) + else: + focus_faders.append(fader) + hstack_focus_faders.addWidget(fader) + _wire_focus_fader(fader) + update_focus_panel_width() + +def focus_fader_remove(fader): + if len(focus_faders) <= 1: + return + hstack_focus_faders.removeWidget(fader) + fader.deleteLater() + focus_faders.remove(fader) + update_focus_panel_width() + +def focus_toggle_add(insert_after=None): + n = len(focus_toggles) + 1 + toggle = FocusToggleWidget(f"Toggle {n}") + if insert_after is not None and insert_after in focus_toggles: + idx = focus_toggles.index(insert_after) + focus_toggles.insert(idx + 1, toggle) + hstack_focus_toggles.insertWidget(idx + 1, toggle) + else: + focus_toggles.append(toggle) + hstack_focus_toggles.addWidget(toggle) + _wire_focus_toggle(toggle) + update_focus_panel_width() + +def focus_toggle_remove(toggle): + if len(focus_toggles) <= 0: + return + hstack_focus_toggles.removeWidget(toggle) + toggle.deleteLater() + focus_toggles.remove(toggle) + update_focus_panel_width() + +def focus_feedback_add(insert_after=None): + n = len(focus_feedbacks) + 1 + widget = FocusFeedbackWidget(f"Feedback {n}") + if insert_after is not None and insert_after in focus_feedbacks: + idx = focus_feedbacks.index(insert_after) + focus_feedbacks.insert(idx + 1, widget) + hstack_focus_feedbacks.insertWidget(idx + 1, widget) + else: + focus_feedbacks.append(widget) + hstack_focus_feedbacks.addWidget(widget) + _wire_focus_feedback(widget) + update_focus_panel_width() + +def focus_feedback_remove(widget): + if len(focus_feedbacks) <= 0: + return + hstack_focus_feedbacks.removeWidget(widget) + widget.deleteLater() + focus_feedbacks.remove(widget) + update_focus_panel_width() + def transport_toggle_add(label, default_cc, insert_after=None): t = TransportWidget(label, default_cc) t.on_select_callback = lambda strip: check_and_update_selected_strip(strip) @@ -2256,11 +2516,12 @@ 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.remote_checkbox.clicked.connect(lambda: _on_remote_checkbox_changed()) 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']: + for attr in ['grp_learn', 'arrow_lbl', 'grp_cc', 'grp_remote', + 'zone_btn', 'minus_btn', 'plus_btn', 'title_edit', 'id_sep', 'id_label', 'color_swatch_btn']: getattr(t, attr).setVisible(params_visible) if insert_after is not None and insert_after in transports: idx = transports.index(insert_after) @@ -2304,19 +2565,87 @@ def load_transport_preset(): def save_transport_preset(): loaded_presets["transport_preset"] = [t.get_state() for t in transports] +def load_focus_faders_state(): + states = loaded_presets.get("focus_faders") + if not states: + legacy = loaded_presets.get("focus_fader") # pre-multi-fader single-widget format + states = [legacy] if legacy else None + if not states: + return + for f in list(focus_faders): + hstack_focus_faders.removeWidget(f) + f.deleteLater() + focus_faders.clear() + for state in states: + focus_fader_add() + focus_faders[-1].set_state(state) + update_focus_panel_width() + +def save_focus_faders_state(): + loaded_presets["focus_faders"] = [f.get_state() for f in focus_faders] + loaded_presets.pop("focus_fader", None) + +def load_focus_toggles_state(): + states = loaded_presets.get("focus_toggles", []) + if not states: + return + for t in list(focus_toggles): + hstack_focus_toggles.removeWidget(t) + t.deleteLater() + focus_toggles.clear() + for state in states: + focus_toggle_add() + focus_toggles[-1].set_state(state) + update_focus_panel_width() + +def save_focus_toggles_state(): + loaded_presets["focus_toggles"] = [t.get_state() for t in focus_toggles] + +def load_focus_feedbacks_state(): + states = loaded_presets.get("focus_feedbacks", []) + if not states: + return + for w in list(focus_feedbacks): + hstack_focus_feedbacks.removeWidget(w) + w.deleteLater() + focus_feedbacks.clear() + for state in states: + focus_feedback_add() + focus_feedbacks[-1].set_state(state) + update_focus_panel_width() + +def save_focus_feedbacks_state(): + loaded_presets["focus_feedbacks"] = [w.get_state() for w in focus_feedbacks] + def save_io_config(): loaded_presets["io_config"] = { "midi_out": picker_midi_out.currentText(), "midi_in": picker_midi_in.currentText(), + "midi_controller_in": picker_midi_controller_in.currentText(), "transport_out": picker_transport_out.currentText(), "osc_ip": textbox_osc_daw_ip.text(), "osc_listen_port": spinner_osc_listen_port.value(), "osc_send_port": spinner_osc_send_port.value(), + # Preserve whatever running-state was last explicitly recorded by + # start_stop_osc_server() rather than recomputing it here — this function + # also runs while fields are being restored at load time, before the + # server has had a chance to auto-start. + "osc_running": loaded_presets.get("io_config", {}).get("osc_running", True), + "tablet_running": loaded_presets.get("io_config", {}).get("tablet_running", True), } save_presets_file(loaded_presets) +def save_osc_running_state(running): + loaded_presets.setdefault("io_config", {})["osc_running"] = running + save_io_config() + +def save_tablet_running_state(running): + loaded_presets.setdefault("io_config", {})["tablet_running"] = running + save_io_config() + def load_io_config(): global available_ports_midi_in + global available_ports_midi_controller_in cfg = loaded_presets.get("io_config", {}) if not cfg: return @@ -2327,6 +2656,9 @@ def load_io_config(): midi_in = cfg.get("midi_in", "") input_names = [entry[0] for entry in available_ports_midi_in] picker_midi_in.setCurrentText(midi_in if midi_in in input_names else "Not Connected") + midi_controller_in = cfg.get("midi_controller_in", "") + controller_input_names = [entry[0] for entry in available_ports_midi_controller_in] + picker_midi_controller_in.setCurrentText(midi_controller_in if midi_controller_in in controller_input_names else "Not Connected") ip = cfg.get("osc_ip", "") if ip: textbox_osc_daw_ip.setText(ip) @@ -2339,6 +2671,9 @@ def load_io_config(): def preset_build_snapshot(name): save_transport_preset() + save_focus_faders_state() + save_focus_toggles_state() + save_focus_feedbacks_state() return { "faders": [f.get_state() for f in faders], "toggles": [t.get_state() for t in toggles], @@ -2371,9 +2706,12 @@ 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(), "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], + "faders": [{"uid": f.uid, "label": f.title_edit.text(), "value": f.fader.value(), "dest_id": f.dest_spin.value() if f.remote_checkbox.isChecked() else 0, "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() if t.remote_checkbox.isChecked() else 0, "color": PALETTE_HEX.get(t.color_name, "#00e676"), "color_name": t.color_name} for t in toggles], + "transports": [{"uid": t.uid, "label": t.title_edit.text(), "dest_id": t.dest_spin.value() if t.remote_checkbox.isChecked() else 0, "color": PALETTE_HEX.get(t.color_name, "#00e676")} for t in transports], + "focus_faders": [{"uid": f.uid, "label": f.title_edit.text(), "value": f.fader.value(), "color": PALETTE_HEX.get(f.color_name, "#00e676")} for f in focus_faders], + "focus_toggles": [{"uid": t.uid, "label": t.title_edit.text(), "state": t.toggle_state, "color": PALETTE_HEX.get(t.color_name, "#00e676"), "color_name": t.color_name, "trigger_mode": t.trigger_mode} for t in focus_toggles], + "focus_feedbacks": [{"uid": w.uid, "label": w.title_edit.text(), "text": w.readout_lbl.text(), "color": PALETTE_HEX.get(w.color_name, "#00e676")} for w in focus_feedbacks], } return snapshot, saved_layout @@ -2388,8 +2726,13 @@ def _on_tablet_connected(): def _on_widget_visibility_from_tablet(uid, dest_id): for w in faders + toggles + transports: if w.uid == uid: + w.remote_checkbox.blockSignals(True) + w.remote_checkbox.setChecked(dest_id > 0) + w.remote_checkbox.blockSignals(False) w.dest_spin.blockSignals(True) - w.dest_spin.setValue(dest_id) + if dest_id > 0: + w.dest_spin.setValue(dest_id) + w.dest_spin.setEnabled(dest_id > 0) w.dest_spin.blockSignals(False) name = menuPresets.currentText() if name and name in loaded_presets.get("presets", {}): @@ -2408,6 +2751,14 @@ def _broadcast_preset_update(): snapshot, layout = _build_tablet_snapshot() ws_server.broadcast_preset(snapshot, layout) log_ws_outgoing(snapshot) + # Channel Focus mode ignores "preset" events on the iPad, so labels + # (e.g. edited titles) need their own explicit push to stay in sync. + for f in focus_faders: + ws_server.broadcast_widget_label(f.uid, f.title_edit.text()) + for t in focus_toggles: + ws_server.broadcast_widget_label(t.uid, t.title_edit.text()) + for w in focus_feedbacks: + ws_server.broadcast_widget_label(w.uid, w.title_edit.text()) def _on_remote_checkbox_changed(): @@ -2675,6 +3026,7 @@ def check_midi_port_avail_and_assign(display_name): midi_in_port = None label_connection_midi_in.setText("Not Connected") label_connection_midi_in.setStyleSheet("color: #888;") + save_io_config() return port_entry = next((entry for entry in available_ports_midi_in if entry[0] == display_name), None) if port_entry is None: @@ -2699,14 +3051,63 @@ def check_midi_port_avail_and_assign(display_name): label_connection_midi_in.setStyleSheet("color: #c0392b;") -def route_midi_message_input(event, data=None): +def check_controller_in_port_avail_and_assign(display_name): + + global available_ports_midi_controller_in + global midi_controller_in_port + + if not display_name or display_name == "Not Connected": + if midi_controller_in_port is not None: + close_midi_input_port(midi_controller_in_port) + midi_controller_in_port = None + label_connection_midi_controller_in.setText("Not Connected") + label_connection_midi_controller_in.setStyleSheet("color: #888;") + save_io_config() + return + port_entry = next((entry for entry in available_ports_midi_controller_in if entry[0] == display_name), None) + if port_entry is None: + return + display_name, original_index, available = port_entry + if not available: + label_connection_midi_controller_in.setText("In use by another program") + label_connection_midi_controller_in.setStyleSheet("color: #e67e22;") + return + if midi_controller_in_port is not None: + close_midi_input_port(midi_controller_in_port) + midi_controller_in_port = None + try: + midi_controller_in_port = open_midi_input_port(original_index, route_midi_controller_message_input) + label_connection_midi_controller_in.setText("Connected") + label_connection_midi_controller_in.setStyleSheet("color: #27ae60;") + print(f"Opened controller input port: {display_name}") + save_io_config() + except Exception as e: + print(f"Failed to open port {display_name}: {e}") + label_connection_midi_controller_in.setText("Failed to open") + label_connection_midi_controller_in.setStyleSheet("color: #c0392b;") + + +def _route_hw_cc_bind(status, channel, cc_num, cc_val): + """Shared CC learn/bind/route pipeline for both MIDI In and MIDI Controller + In — a fader/toggle/transport/preset-browser bind is keyed on CC number + + channel, not on which physical input port it arrived on. Returns True if + the message was consumed (learned, or routed to a bound widget).""" global learning_strip - message, _ = event - status = message[0] & 0xF0 - channel = (message[0] & 0x0F) + 1 - cc_num = message[1] if len(message) >= 2 else None - cc_val = message[2] if len(message) >= 3 else None + # Touch sense (JL Cooper CC): touch on/off arrives on the SAME CC as the + # fader's value data, just on hw_channel - 1 (e.g. value on CH16, touch on + # CH15). Checked first, ahead of the generic CC-only matching below, since + # that matching would otherwise treat a touch message as a real value + # update for any fader with a matching hw_cc. "off" leaves this untouched + # entirely (channel-blind, for non-touch-sensing hardware). + if status == 0xB0: + for fader in faders + focus_faders: + if (getattr(fader, "touch_sense_mode", "off") == "jl_cooper" + and fader.hw_cc == cc_num + and fader.hw_channel is not None + and channel == fader.hw_channel - 1): + fader.is_touching = cc_val >= 64 + return True bound_fader = None bound_transport = None @@ -2731,29 +3132,73 @@ def route_midi_message_input(event, data=None): if w.hw_cc == cc_num: bound_browser = w break + # Channel Focus widgets are opt-in via their own "Hardware" checkbox — + # checked last so a regular preset binding on the same CC always wins. + if not bound_fader and not bound_transport and not bound_browser: + for f in focus_faders: + if f.hardware_checkbox.isChecked() and f.hw_cc == cc_num: + bound_fader = f + break + if not bound_fader: + for t in focus_toggles: + if t.hardware_checkbox.isChecked() and t.hw_cc == cc_num: + bound_fader = t + break if status == 0xB0 and learning_strip: learning_strip.bind_hw_cc(cc_num, channel=channel) learning_strip = None - return + return True if bound_browser: bound_browser.on_hw_trigger(cc_val) + return True elif bound_transport: print(f"[transport hw] CC{cc_num} val={cc_val} → uid={bound_transport.uid}") midi_receiver.hw_cc_signal.emit(bound_transport.uid, cc_val) + return True elif bound_fader: - print(f"[hw] CC{cc_num} val={cc_val} → uid={bound_fader.uid} out_cc={bound_fader.cc_spin.value()}") + print(f"[hw] CC{cc_num} val={cc_val} → uid={bound_fader.uid}") midi_receiver.hw_cc_signal.emit(bound_fader.uid, cc_val) - elif status in (0x80, 0x90): - if midi_out.is_port_open(): - midi_out.send_message(list(message)) - midi_receiver.midi_out_signal.emit(list(message)) - else: - print(f"[unbound] CC{cc_num} val={cc_val}") + return True + return False + + +def route_midi_message_input(event, data=None): + message, _ = event + status = message[0] & 0xF0 + channel = (message[0] & 0x0F) + 1 + cc_num = message[1] if len(message) >= 2 else None + cc_val = message[2] if len(message) >= 3 else None + + handled = _route_hw_cc_bind(status, channel, cc_num, cc_val) + if not handled: + if status in (0x80, 0x90): + if midi_out.is_port_open(): + midi_out.send_message(list(message)) + midi_receiver.midi_out_signal.emit(list(message)) + else: + print(f"[unbound] CC{cc_num} val={cc_val}") midi_receiver.midi_in_signal.emit(list(message)) + +def route_midi_controller_message_input(event, data=None): + """Controller-only input (e.g. JL Cooper fader bank) — same CC learn/bind + pipeline as route_midi_message_input, but Notes are never passed through + to MIDI Out; this port is for controllers only.""" + message, _ = event + status = message[0] & 0xF0 + channel = (message[0] & 0x0F) + 1 + cc_num = message[1] if len(message) >= 2 else None + cc_val = message[2] if len(message) >= 3 else None + + handled = _route_hw_cc_bind(status, channel, cc_num, cc_val) + if not handled and status not in (0x80, 0x90): + print(f"[unbound ctrl] CC{cc_num} val={cc_val}") + + midi_receiver.controller_in_signal.emit(list(message)) + def midi_message_route_hw_to_output_cc(fader_uid, value): for fader in faders: if fader.uid == fader_uid: @@ -2819,6 +3264,82 @@ def midi_message_route_hw_to_output_cc(fader_uid, value): t.cc_output_lbl.setText(f"CC{out_cc} [{out_val}]") t._update_btn_style() return + # Channel Focus fader — hardware drives the same OSC output as the app + # would (see _on_focus_fader_control_f); optional CC feedback (e.g. for a + # motorized fader) goes out via _send_hw_feedback if that's also enabled. + for f in focus_faders: + if f.uid == fader_uid: + addr = f.osc_addr_out_edit.text().strip() or "/track/volume" + f.hw_cc_input_lbl.setText(f._hw_cc_input_label(value)) + f.fader.blockSignals(True) + f.fader.setValue(value) + f.fader.blockSignals(False) + f.last_sent = value + f.prev_position = value + f.cc_output_lbl.setText(f._osc_output_label(value)) + send_osc_message(addr, value / 127.0) + f._send_hw_feedback(value) + if ws_server: + ws_server.broadcast_widget_update_f(fader_uid, value / 127.0) + return + # Channel Focus toggle — same idea, respecting its own Momentary/Toggle mode. + for t in focus_toggles: + if t.uid == fader_uid: + addr = t.osc_addr_out_edit.text().strip() or "/track/mute" + t.hw_cc_input_lbl.setText(t._hw_cc_input_label(value)) + if t.trigger_mode == "momentary": + send_osc_message(addr, 1.0) + t.cc_output_lbl.setText(t._osc_output_label(1.0)) + t.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") + QTimer.singleShot(150, lambda: t.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;")) + t._send_hw_feedback(127) + if ws_server: + ws_server.broadcast_widget_update(fader_uid, 127) + else: + should_be_on = value > 63 + if t.toggle_state != should_be_on: + t.toggle_state = should_be_on + out_val = 1.0 if should_be_on else 0.0 + send_osc_message(addr, out_val) + t.cc_output_lbl.setText(t._osc_output_label(out_val)) + t._update_btn_style() + t._send_hw_feedback(127 if should_be_on else 0) + if ws_server: + ws_server.broadcast_widget_update(fader_uid, 127 if should_be_on else 0) + return + for f in focus_faders: + if f.uid == fader_uid: + addr = f.osc_addr_out_edit.text().strip() or "/track/volume" + f.fader.blockSignals(True) + f.fader.setValue(value) + f.fader.blockSignals(False) + f.last_sent = value + f.prev_position = value + f.cc_output_lbl.setText(f._osc_output_label(value)) + f.readout.setText(f"{addr} → {value}") + send_osc_message(addr, value / 127.0) + if ws_server: + ws_server.broadcast_widget_update(fader_uid, value) + return + for toggle in focus_toggles: + if toggle.uid == fader_uid: + addr = toggle.osc_addr_out_edit.text().strip() or "/track/mute" + if toggle.trigger_mode == "momentary": + send_osc_message(addr, 1.0) + toggle.cc_output_lbl.setText(toggle._osc_output_label(1.0)) + toggle.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") + QTimer.singleShot(150, lambda: toggle.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;")) + else: + should_be_on = value > 63 + if toggle.toggle_state != should_be_on: + toggle.toggle_state = should_be_on + out_val = 1.0 if should_be_on else 0.0 + send_osc_message(addr, out_val) + toggle.cc_output_lbl.setText(toggle._osc_output_label(out_val)) + toggle._update_btn_style() + if ws_server: + ws_server.broadcast_widget_update(fader_uid, 127 if should_be_on else 0) + return def log_ui_show_hide(checked): container_osc_in_log.setVisible(checked) @@ -2861,6 +3382,31 @@ def layout_panel_window_calc(): container_layout_panel.move(geo.left() - 224, geo.top()) +def focus_panel_show_hide(checked): + container_focus_panel.setVisible(checked) + if checked: + focus_panel_window_calc() + + +def focus_panel_window_calc(): + geo = app_window.frameGeometry() + container_focus_panel.setFixedHeight(geo.height()) + container_focus_panel.move(geo.left() - 224, geo.top()) + + +def update_focus_panel_width(): + k = len(focus_faders) + faders_width = (k * 115) + max(0, k - 1) * 6 if k > 0 else 0 + n = len(focus_toggles) + toggles_width = (n * 115) + max(0, n - 1) * 6 if n > 0 else 0 + m = len(focus_feedbacks) + feedbacks_width = (m * 115) + max(0, m - 1) * 6 if m > 0 else 0 + width = max(220, faders_width + 20, toggles_width + 20, feedbacks_width + 20) + container_focus_panel.setFixedWidth(width) + if container_focus_panel.isVisible(): + focus_panel_window_calc() + + def tablet_log_show_hide(checked): container_tablet_log.setVisible(checked) if checked: @@ -2913,6 +3459,7 @@ def _start_tablet_server(): ws_server.log_signal.connect(log_tablet) ws_server.raw_in_signal.connect(log_ws_incoming) ws_server.control_received.connect(lambda uid, val: midi_receiver.hw_cc_signal.emit(uid, val)) + ws_server.control_f_received.connect(_on_focus_fader_control_f) 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) @@ -2926,6 +3473,15 @@ def _start_tablet_server(): button_tablet_server.setText("Stop Tablet") label_tablet_status.setText(f"ws://{ip}:8765") log_tablet(f"WS ws://{ip}:8765") + save_tablet_running_state(True) + if ip != "localhost": + _start_bonjour_advertisement(ip, 8765) + + +def _auto_start_tablet_server(): + should_start = loaded_presets.get("io_config", {}).get("tablet_running", True) + if should_start: + _start_tablet_server() def _stop_tablet_server(): @@ -2934,8 +3490,230 @@ def _stop_tablet_server(): ws_server.stop() ws_server = None button_tablet_server.setText("Start Tablet") + save_tablet_running_state(False) label_tablet_status.setText("") log_tablet("server stopped") + _stop_bonjour_advertisement() + + +def _start_bonjour_advertisement(ip, port): + global _bonjour_zc, _bonjour_info + _stop_bonjour_advertisement() + print(f"[Bonjour] registering {BONJOUR_SERVICE_TYPE} on {ip}:{port}") + try: + hostname = socket.gethostname().split(".")[0] + service_name = f"{hostname}.{BONJOUR_SERVICE_TYPE}" + _bonjour_info = ServiceInfo( + BONJOUR_SERVICE_TYPE, + service_name, + addresses=[socket.inet_aton(ip)], + port=port, + properties={}, + ) + _bonjour_zc = Zeroconf() + _bonjour_zc.register_service(_bonjour_info) + print(f"[Bonjour] registered as {service_name}") + log_tablet(f"Bonjour advertising as {service_name}") + except Exception as e: + print(f"[Bonjour] registration FAILED: {e}") + log_tablet(f"Bonjour registration failed: {e}") + _bonjour_zc = None + _bonjour_info = None + + +def _stop_bonjour_advertisement(): + global _bonjour_zc, _bonjour_info + if _bonjour_zc: + print(f"[Bonjour] unregistering {_bonjour_info.name if _bonjour_info else '?'}") + try: + if _bonjour_info: + _bonjour_zc.unregister_service(_bonjour_info) + _bonjour_zc.close() + print("[Bonjour] unregistered") + except Exception as e: + print(f"[Bonjour] error while unregistering: {e}") + _bonjour_zc = None + _bonjour_info = None + + +class OSCLearnDialog(QDialog): + """Live list of distinct OSC addresses seen since opening, for picking which one to bind.""" + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("OSC Learn") + self.resize(360, 300) + self._seen = {} + self.selected_address = None + + layout = QVBoxLayout(self) + hint = QLabel("Trigger the control in the DAW, then pick the message below:") + hint.setWordWrap(True) + layout.addWidget(hint) + + self.list_widget = QListWidget() + self.list_widget.itemDoubleClicked.connect(self._on_confirm) + self.list_widget.itemSelectionChanged.connect( + lambda: self.use_btn.setEnabled(bool(self.list_widget.selectedItems())) + ) + layout.addWidget(self.list_widget) + + btn_row = QHBoxLayout() + self.use_btn = QPushButton("Use Selected") + self.use_btn.setEnabled(False) + self.use_btn.clicked.connect(self._on_confirm) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(self.use_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + def add_message(self, addr, val): + text = f"{addr} [{val}]" + if addr in self._seen: + for i in range(self.list_widget.count()): + item = self.list_widget.item(i) + if item.data(Qt.ItemDataRole.UserRole) == addr: + item.setText(text) + break + else: + item = QListWidgetItem(text) + item.setData(Qt.ItemDataRole.UserRole, addr) + self.list_widget.addItem(item) + self._seen[addr] = val + + def _on_confirm(self): + item = self.list_widget.currentItem() + if not item: + return + self.selected_address = item.data(Qt.ItemDataRole.UserRole) + self.accept() + + +learning_osc_widget = None +learning_osc_target = "value" +_osc_learn_dialog = None + + +def _stop_osc_learn_widget(widget, target): + if target == "name": + widget.stop_learn_name() + else: + widget.stop_learn() + + +def start_osc_learn(widget, target="value"): + global learning_osc_widget, learning_osc_target, _osc_learn_dialog + + if learning_osc_widget and learning_osc_widget != widget: + _stop_osc_learn_widget(learning_osc_widget, learning_osc_target) + if _osc_learn_dialog: + _osc_learn_dialog.close() + + learning_osc_widget = widget + learning_osc_target = target + if target == "name": + widget.start_learn_name() + else: + widget.start_learn() + + _osc_learn_dialog = OSCLearnDialog(app_window) + _osc_learn_dialog.finished.connect(lambda result: _on_osc_learn_dialog_closed(widget, target, result)) + _osc_learn_dialog.show() + + +def _on_osc_learn_dialog_closed(widget, target, result): + global learning_osc_widget, learning_osc_target, _osc_learn_dialog + + if result == QDialog.DialogCode.Accepted and _osc_learn_dialog is not None and _osc_learn_dialog.selected_address: + if target == "name": + widget.bind_osc_name_addr(_osc_learn_dialog.selected_address) + else: + widget.bind_osc_addr(_osc_learn_dialog.selected_address) + else: + _stop_osc_learn_widget(widget, target) + learning_osc_widget = None + _osc_learn_dialog = None + + +def _on_osc_learn_message(addr, val): + if learning_osc_widget and _osc_learn_dialog: + _osc_learn_dialog.add_message(addr, val) + + +def _on_osc_focus_fader_feedback(addr, val): + for f in focus_faders: + target_addr = f.osc_addr_in_edit.text().strip() + if not target_addr or addr != target_addr: + continue + try: + value = float(val.split()[0]) + except (ValueError, IndexError): + continue + f.receive_osc_value(value) + f._send_hw_feedback(f.fader.value()) + if ws_server: + # Full-resolution echo — the raw float, not the desktop QSlider's + # quantized 0-127 position, so the iPad fader doesn't lose precision. + ws_server.broadcast_widget_update_f(f.uid, value) + + +def _on_focus_fader_control_f(uid, value): + """Full-resolution (0.0-1.0) touch from one of the iPad's Channel Focus faders.""" + for f in focus_faders: + if f.uid != uid: + continue + value = max(0.0, min(1.0, value)) + addr = f.osc_addr_out_edit.text().strip() or "/track/volume" + slider_value = round(value * 127) + f.fader.blockSignals(True) + f.fader.setValue(slider_value) + f.fader.blockSignals(False) + f.last_sent = slider_value + f.prev_position = slider_value + f.cc_output_lbl.setText(f._osc_output_label(slider_value)) + f.readout.setText(f"{addr} → {value:.4f}") + send_osc_message(addr, value) + f._send_hw_feedback(slider_value) + if ws_server: + ws_server.broadcast_widget_update_f(uid, value) + return + + +def _on_osc_focus_fader_name_feedback(addr, val): + for f in focus_faders: + target_addr = f.osc_addr_name_edit.text().strip() + if not target_addr or addr != target_addr: + continue + f.receive_osc_name(val) + if ws_server: + ws_server.broadcast_widget_label(f.uid, val) + + +def _on_osc_focus_toggles_feedback(addr, val): + for toggle in focus_toggles: + target_addr = toggle.osc_addr_in_edit.text().strip() + if not target_addr or addr != target_addr: + continue + try: + value = float(val.split()[0]) + except (ValueError, IndexError): + continue + toggle.receive_osc_value(value) + toggle._send_hw_feedback(127 if toggle.toggle_state else 0) + if ws_server: + ws_server.broadcast_widget_update(toggle.uid, 127 if toggle.toggle_state else 0) + + +def _on_osc_focus_feedbacks_feedback(addr, val): + for widget in focus_feedbacks: + if widget.custom_checkbox.isChecked(): + continue + target_addr = widget.osc_addr_in_edit.text().strip() + if not target_addr or addr != target_addr: + continue + widget.receive_osc_value(val) + if ws_server: + ws_server.broadcast_widget_feedback(widget.uid, val) def start_osc_server(port=9000): @@ -3029,6 +3807,7 @@ def fader_show_hide_config_set(enabled): fader.cc_spin_row.setVisible(not enabled) fader.ch_spin_row.setVisible(not enabled) fader.pickup_checkbox.setVisible(not enabled) + fader.grp_remote.setVisible(not enabled) fader.zone_btn.setVisible(not enabled) fader.plus_btn.setVisible(not enabled) fader.minus_btn.setVisible(not enabled) @@ -3052,6 +3831,7 @@ def transport_show_hide_config_set(enabled): t.grp_learn.setVisible(not enabled) t.arrow_lbl.setVisible(not enabled) t.grp_cc.setVisible(not enabled) + t.grp_remote.setVisible(not enabled) t.zone_btn.setVisible(not enabled) t.minus_btn.setVisible(not enabled) t.plus_btn.setVisible(not enabled) @@ -3073,6 +3853,7 @@ def toggle_show_hide_config_set(enabled): t.grp_learn.setVisible(not enabled) t.arrow_lbl.setVisible(not enabled) t.grp_cc.setVisible(not enabled) + t.grp_remote.setVisible(not enabled) t.zone_btn.setVisible(not enabled) t.minus_btn.setVisible(not enabled) t.plus_btn.setVisible(not enabled) diff --git a/app/midi_receiver.py b/app/midi_receiver.py index 71e41ad..bc9fa8e 100644 --- a/app/midi_receiver.py +++ b/app/midi_receiver.py @@ -5,6 +5,7 @@ class MidiReceiver(QObject): midi_out_signal = pyqtSignal(list) transport_out_signal = pyqtSignal() hw_cc_signal = pyqtSignal(str, int) + controller_in_signal = pyqtSignal(list) midi_receiver = MidiReceiver() diff --git a/app/togglewidget.py b/app/togglewidget.py index 833f672..4a5febf 100644 --- a/app/togglewidget.py +++ b/app/togglewidget.py @@ -137,6 +137,26 @@ class ToggleWidget(QWidget): inner.addWidget(self.grp_cc) + # Group 3: Remote routing + self.grp_remote = QFrame() + self.grp_remote.setStyleSheet(_grp_style) + _grp_remote_layout = QVBoxLayout(self.grp_remote) + _grp_remote_layout.setContentsMargins(4, 4, 4, 4) + _grp_remote_layout.setSpacing(3) + + self.remote_checkbox = QCheckBox("App") + self.remote_checkbox.setChecked(True) + self.remote_checkbox.stateChanged.connect(lambda _: self.dest_spin.setEnabled(self.remote_checkbox.isChecked())) + _grp_remote_layout.addWidget(self.remote_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.dest_spin = QSpinBox() + self.dest_spin.setRange(1, 9) + self.dest_spin.setValue(1) + self.dest_spin.setFixedWidth(52) + _grp_remote_layout.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter) + + inner.addWidget(self.grp_remote) + # Toggle button with LED dot overlay btn_container = QWidget() btn_container.setFixedSize(60, 60) @@ -180,15 +200,6 @@ 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) @@ -379,6 +390,7 @@ class ToggleWidget(QWidget): "hw_cc": self.hw_cc, "hw_channel": self.hw_channel, "trigger_mode": self.trigger_mode, + "remote": self.remote_checkbox.isChecked(), "dest_id": self.dest_spin.value(), } @@ -404,9 +416,14 @@ 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")) + remote = state.get("remote", True) + self.remote_checkbox.blockSignals(True) + self.remote_checkbox.setChecked(remote) + self.remote_checkbox.blockSignals(False) self.dest_spin.blockSignals(True) self.dest_spin.setValue(state.get("dest_id", 1)) self.dest_spin.blockSignals(False) + self.dest_spin.setEnabled(remote) 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 6249d87..f39123a 100644 --- a/app/transportwidget.py +++ b/app/transportwidget.py @@ -152,6 +152,26 @@ class TransportWidget(QWidget): self.output_mode = "midi" + # Group 3: Remote routing + self.grp_remote = QFrame() + self.grp_remote.setStyleSheet(_grp_style) + _grp_remote_layout = QVBoxLayout(self.grp_remote) + _grp_remote_layout.setContentsMargins(4, 4, 4, 4) + _grp_remote_layout.setSpacing(3) + + self.remote_checkbox = QCheckBox("App") + self.remote_checkbox.setChecked(True) + self.remote_checkbox.stateChanged.connect(lambda _: self.dest_spin.setEnabled(self.remote_checkbox.isChecked())) + _grp_remote_layout.addWidget(self.remote_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.dest_spin = QSpinBox() + self.dest_spin.setRange(1, 9) + self.dest_spin.setValue(1) + self.dest_spin.setFixedWidth(52) + _grp_remote_layout.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter) + + inner.addWidget(self.grp_remote) + # Transport button with LED dot btn_container = QWidget() btn_container.setFixedSize(60, 60) @@ -195,15 +215,6 @@ 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) @@ -387,6 +398,7 @@ class TransportWidget(QWidget): "output_mode": self.output_mode, "osc_addr": self.osc_addr_edit.text(), "trigger_mode": self.trigger_mode, + "remote": self.remote_checkbox.isChecked(), "dest_id": self.dest_spin.value(), } @@ -414,6 +426,11 @@ 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")) + remote = state.get("remote", True) + self.remote_checkbox.blockSignals(True) + self.remote_checkbox.setChecked(remote) + self.remote_checkbox.blockSignals(False) 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 + self.dest_spin.blockSignals(False) + self.dest_spin.setEnabled(remote) \ No newline at end of file diff --git a/daw-config-reaper/reaper-osc-config-paul-custom.ReaperOSC b/daw-config-reaper/reaper-osc-config-paul-custom.ReaperOSC new file mode 100644 index 0000000..b8d06a6 --- /dev/null +++ b/daw-config-reaper/reaper-osc-config-paul-custom.ReaperOSC @@ -0,0 +1,254 @@ +# OSC pattern config file for TouchOSC LogicPad layout. + +# See extensive comments in Default.ReaperOSC. + +DEVICE_TRACK_COUNT 8 +DEVICE_SEND_COUNT 5 +DEVICE_RECEIVE_COUNT +DEVICE_FX_COUNT 12 +DEVICE_FX_PARAM_COUNT 16 +DEVICE_FX_INST_PARAM_COUNT 24 +DEVICE_MARKER_COUNT +DEVICE_REGION_COUNT + +REAPER_TRACK_FOLLOWS DEVICE +#important for osc to follow reaper +DEVICE_TRACK_FOLLOWS LAST_TOUCHED +DEVICE_TRACK_BANK_FOLLOWS DEVICE +DEVICE_FX_FOLLOWS DEVICE + + +# default +#REAPER_TRACK_FOLLOWS REAPER +#DEVICE_TRACK_FOLLOWS DEVICE +#DEVICE_TRACK_BANK_FOLLOWS DEVICE +#DEVICE_FX_FOLLOWS DEVICE + +DEVICE_ROTARY_CENTER 0.0 + +# ---------------------------------------------------------------- + +SCROLL_X- +SCROLL_X+ +SCROLL_Y- +SCROLL_Y+ +ZOOM_X- +ZOOM_X+ +ZOOM_Y- +ZOOM_Y+ + +TIME s/1/time s/2/time s/3/time s/4/time s/5/time +BEAT s/1/bar s/2/bar s/3/bar s/4/bar s/5/bar +SAMPLES +FRAMES + +METRONOME t/1/click t/2/click t/3/click t/4/click t/5/click +REPLACE t/1/replace t/2/replace t/3/replace t/4/replace t/5/replace +REPEAT t/1/cycle t/2/cycle t/3/cycle t/4/cycle t/5/cycle + +RECORD t/1/record t/2/record t/3/record t/4/record t/5/record +STOP t/1/stop t/2/stop t/3/stop t/4/stop t/5/stop +PLAY t/1/play t/2/play t/3/play t/4/play t/5/play +PAUSE + +AUTO_REC_ARM t/1/sel-rec t/2/sel-rec +SOLO_RESET t/1/soloreset t/2/soloreset +ANY_SOLO t/1/ledanysolo t/2/ledanysolo + +REWIND b/rewind +FORWARD b/forward + +REWIND_FORWARD_BYMARKER t/1/bymarker t/2/bymarker +REWIND_FORWARD_SETLOOP t/1/bycycle t/2/bycycle +GOTO_MARKER +GOTO_REGION + +SCRUB + +PLAY_RATE +TEMPO + +MARKER_NAME +MARKER_NUMBER +REGION_NAME +REGION_NUMBER +LAST_MARKER_NAME +LAST_MARKER_NUMBER +LAST_REGION_NAME +LAST_REGION_NUMBER + +MASTER_VOLUME n/1/mastervolume s/1/masterlevel +MASTER_PAN +MASTER_VU +MASTER_VU_L +MASTER_VU_R + +MASTER_SEND_NAME s/1/auxname@ s/2/auxname@ +MASTER_SEND_VOLUME n/1/auxvolume@ n/2/auxvolume@ s/1/auxlevel@ s/2/auxlevel@ +MASTER_SEND_PAN + +TRACK_NAME s/1/trackname@ s/2/trackname@ s/3/trackname s/4/trackname s/5/trackname +TRACK_NUMBER s/1/track#@ s/2/track#@ s/3/track# s/4/track# s/5/track# + +TRACK_MUTE b/1/mute/1/@ b/2/mute/1/@ t/3/mute +TRACK_SOLO b/1/solo/1/@ b/2/solo/1/@ t/3/solo +TRACK_REC_ARM t/1/recenable/1/@ t/2/recenable/1/@ t/3/recenable +TRACK_MONITOR t/3/input +TRACK_SELECT b/1/select/1/@ b/2/select/1/@ + +TRACK_VU +TRACK_VU_L +TRACK_VU_R +TRACK_VOLUME n/1/volume@ n/3/volume s/1/level@ s/3/trkvolval +TRACK_PAN n/2/pan@ n/3/pan s/2/panval@ s/3/trkpanval +TRACK_PAN2 +TRACK_PAN_MODE + +TRACK_SEND_NAME s/2/sendname@@ s/3/sendname@ +TRACK_SEND_VOLUME n/2/send@@ n/3/sendlevel@ s/2/sendval@@ s/3/sendval@ +TRACK_SEND_PAN + +TRACK_RECV_NAME +TRACK_RECV_VOLUME +TRACK_RECV_VOLUME +TRACK_RECV_PAN +TRACK_RECV_PAN + +TRACK_AUTO +TRACK_AUTO_TRIM t/3/atmoff +TRACK_AUTO_READ t/3/atmread +TRACK_AUTO_LATCH t/3/atmlatch +TRACK_AUTO_TOUCH t/3/atmtouch +TRACK_AUTO_WRITE t/3/atmwrite + +TRACK_VOLUME_TOUCH +TRACK_PAN_TOUCH + +FX_NAME s/3/pluginname s/3/insertname@ +FX_NUMBER +FX_BYPASS b/3/insertbypass/@/1 +FX_OPEN_UI + +FX_PRESET +FX_PREV_PRESET +FX_NEXT_PRESET + +FX_PARAM_NAME s/3/parname@ +FX_WETDRY +FX_PARAM_VALUE n/3/par@ s/3/value@ + +FX_EQ_BYPASS +FX_EQ_OPEN_UI + +FX_EQ_PRESET +FX_EQ_PREV_PRESET +FX_EQ_NEXT_PRESET + +FX_EQ_MASTER_GAIN n/4/eqmstgain +FX_EQ_WETDRY + +FX_EQ_HIPASS_NAME s/4/label165 +FX_EQ_HIPASS_FREQ n/4/hpffrq s/4/hpffrqval +FX_EQ_HIPASS_Q n/4/hpfq s/4/hpfqval + +FX_EQ_LOSHELF_NAME s/4/label159 +FX_EQ_LOSHELF_FREQ n/4/loslvfrq s/4/loslvfrqval +FX_EQ_LOSHELF_GAIN n/4/gain/1 s/4/loslvgainval +FX_EQ_LOSHELF_Q n/4/loslvq s/4/loslvqval + +FX_EQ_BAND_NAME s/4/label160 s/4/label161 s/4/label162 +FX_EQ_BAND_FREQ n/4/lomidfrq s/4/lomidfrqval +FX_EQ_BAND_GAIN n/4/gain/3 s/5/lomidgainval +FX_EQ_BAND_Q n/4/lomidq s/5/lomidqval + +FX_EQ_NOTCH_NAME s/4/label163 +FX_EQ_NOTCH_FREQ n/4/hifrq s/4/hifrqval +FX_EQ_NOTCH_GAIN n/4/gain/5 s/4/higainval +FX_EQ_NOTCH_Q n/4/hiq s/4/hiqval + +FX_EQ_HISHELF_NAME s/4/label164 +FX_EQ_HISHELF_FREQ n/4/hislvfrq s/4/hislvfrqval +FX_EQ_HISHELF_GAIN n/4/gain/6 s/4/hislvgainval +FX_EQ_HISHELF_Q n/4/hislvq s/4/hislvqval + +FX_EQ_LOPASS_NAME s/4/label166 +FX_EQ_LOPASS_FREQ n/4/lpffrq s/4/lpffrqval +FX_EQ_LOPASS_Q n/4/lpfq s/4/lpfqval + +FX_INST_NAME s/5/pluginname +FX_INST_BYPASS +FX_INST_OPEN_UI + +FX_INST_PRESET +FX_INST_PREV_PRESET +FX_INST_NEXT_PRESET + +FX_INST_PARAM_NAME s/5/parname@ +FX_INST_PARAM_VALUE n/5/par@ s/5/parval@ + +LAST_TOUCHED_FX_TRACK_NAME +LAST_TOUCHED_FX_TRACK_NUMBER +LAST_TOUCHED_FX_NAME +LAST_TOUCHED_FX_NUMBER +LAST_TOUCHED_FX_PARAM_NAME +LAST_TOUCHED_FX_PARAM_VALUE + +ACTION +MIDIACTION +MIDILISTACTION + +# ---------------------------------------------------------------- + +DEVICE_TRACK_COUNT +DEVICE_SEND_COUNT +DEVICE_RECEIVE_COUNT +DEVICE_FX_COUNT +DEVICE_FX_PARAM_COUNT +DEVICE_FX_INST_PARAM_COUNT +DEVICE_MARKER_COUNT +DEVICE_REGION_COUNT + +REAPER_TRACK_FOLLOWS +REAPER_TRACK_FOLLOWS_REAPER +REAPER_TRACK_FOLLOWS_DEVICE + +DEVICE_TRACK_FOLLOWS +DEVICE_TRACK_FOLLOWS_DEVICE +DEVICE_TRACK_FOLLOWS_LAST_TOUCHED + +DEVICE_TRACK_BANK_FOLLOWS +DEVICE_TRACK_BANK_FOLLOWS_DEVICE +DEVICE_TRACK_BANK_FOLLOWS_MIXER + +DEVICE_FX_FOLLOWS +DEVICE_FX_FOLLOWS_DEVICE +DEVICE_FX_FOLLOWS_LAST_TOUCHED +DEVICE_FX_FOLLOWS_FOCUSED + +DEVICE_TRACK_SELECT +DEVICE_PREV_TRACK t/3/track- t/4/track- t/5/track- +DEVICE_NEXT_TRACK t/3/track+ t/4/track+ t/5/track+ + +DEVICE_TRACK_BANK_SELECT +DEVICE_PREV_TRACK_BANK t/1/bank- t/2/bank- +DEVICE_NEXT_TRACK_BANK t/1/bank+ t/2/bank+ + +DEVICE_FX_SELECT b/3/mInsertEdit/@/1 +DEVICE_PREV_FX +DEVICE_NEXT_FX + +DEVICE_FX_PARAM_BANK_SELECT s/3/page# +DEVICE_PREV_FX_PARAM_BANK t/3/page- +DEVICE_NEXT_FX_PARAM_BANK t/3/page+ + +DEVICE_FX_INST_PARAM_BANK_SELECT s/5/page# +DEVICE_PREV_FX_INST_PARAM_BANK t/5/page- +DEVICE_NEXT_FX_INST_PARAM_BANK t/5/page+ + +DEVICE_MARKER_BANK_SELECT +DEVICE_PREV_MARKER_BANK +DEVICE_NEXT_MARKER_BANK + +DEVICE_REGION_BANK_SELECT +DEVICE_PREV_REGION_BANK +DEVICE_NEXT_REGION_BANK diff --git a/presets/presets.json b/presets/presets.json index 43ea33a..8d48154 100644 --- a/presets/presets.json +++ b/presets/presets.json @@ -1,55 +1,58 @@ { - "__last_used__": "rename whatever", + "__last_used__": "preset5", "presets": { "preset1": { "faders": [ { "uid": "c98d2009-d186-489a-8883-ccbd612cb4af", - "label": "Fader 1", + "label": "fuckin a", "cc": 1, "ch": 1, - "value": 69, + "value": 117, "color": "Red", "pickup": false, - "last_sent": 69, + "last_sent": 117, "hw_cc": null, "hw_channel": null, "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095", "label": "Fader 2", "cc": 1, "ch": 1, - "value": 96, + "value": 107, "color": "Gray", "pickup": false, - "last_sent": 96, + "last_sent": 107, "hw_cc": null, "hw_channel": null, "zone": "right", "center_index": 1, "zone_index": -1, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790", "label": "Fader 3", "cc": 1, "ch": 1, - "value": 56, + "value": 83, "color": "Orange", "pickup": false, - "last_sent": 56, + "last_sent": 83, "hw_cc": null, "hw_channel": null, "zone": "left", "center_index": 2, "zone_index": -1, - "remote": true + "remote": true, + "dest_id": 1 } ], "toggles": [ @@ -66,14 +69,15 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "73962e37-53b3-400f-a7e2-25ef7f1ea583", "label": "Toggle 2", "cc": 20, "ch": 1, - "state": true, + "state": false, "color": "Blue", "zone": "left", "center_index": 1, @@ -81,7 +85,8 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "b3cf419a-5183-4272-bd6f-44f858afbec8", @@ -96,7 +101,8 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "momentary", - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "c179bfea-deec-416a-b4aa-74e81b5cbe03", @@ -111,14 +117,15 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "a5a50618-fc4c-446e-868e-e4eafa6254e7", "label": "Toggle 6", "cc": 20, "ch": 1, - "state": true, + "state": false, "color": "Gray", "zone": null, "center_index": null, @@ -126,7 +133,8 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "f1775380-3390-4fbb-bd8b-8c30ff18f72a", @@ -141,7 +149,8 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "2064f2ca-6d18-468b-bf45-9bcb1fa1247d", @@ -156,7 +165,8 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 } ], "browser": { @@ -207,18 +217,44 @@ "center_index": null, "zone_index": null, "hw_cc": null, - "hw_channel": null + "hw_channel": null, + "trigger_mode": "toggle", + "remote": true, + "dest_id": 1 } ], "browser": { - "back_cc": 22, - "fwd_cc": 23 + "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": "", "preset_uuid": "30ff84d1-9240-41f6-b499-1d18ef7ab2eb", "show_hide": { "fader": false, "toggle": false, - "transport": false + "transport": false, + "browser": false + }, + "sections": { + "preset_browser": true, + "big_title": true, + "fader_row": true, + "toggle_row": true, + "transport_row": true } }, "preset3": { @@ -237,7 +273,8 @@ "zone": "left", "center_index": 0, "zone_index": -1, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095", @@ -253,23 +290,25 @@ "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790", "label": "Fader 3", "cc": 1, "ch": 1, - "value": 93, + "value": 48, "color": "Yellow", "pickup": false, - "last_sent": 93, + "last_sent": 48, "hw_cc": null, "hw_channel": null, "zone": "right", "center_index": 2, "zone_index": -1, - "remote": true + "remote": true, + "dest_id": 1 } ], "toggles": [ @@ -286,7 +325,8 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 } ], "browser": { @@ -311,7 +351,7 @@ "preset_uuid": "7483fb5b-f47f-49e4-bccb-e8d20195078c", "show_hide": { "fader": false, - "toggle": false, + "toggle": true, "transport": true, "browser": true }, @@ -367,118 +407,132 @@ "label": "Fader 1", "cc": 90, "ch": 15, - "value": 103, - "color": "Gray", + "value": 50, + "color": "Yellow", "pickup": false, - "last_sent": 103, + "last_sent": 50, "hw_cc": null, "hw_channel": null, + "touch_sense_mode": "off", "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "5aaecf41-3455-4fa2-bef1-ca02f5933a69", - "label": "Fader 2", + "label": "VERB", "cc": 1, "ch": 1, - "value": 89, - "color": "Gray", + "value": 57, + "color": "Orange", "pickup": false, - "last_sent": 89, + "last_sent": 57, "hw_cc": null, "hw_channel": null, + "touch_sense_mode": "off", "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "e8e8cca8-3bad-49a5-8c91-d3a78d25a1eb", - "label": "Fader 3", + "label": "VELOCITY", "cc": 1, "ch": 1, "value": 64, - "color": "Gray", + "color": "Yellow", "pickup": false, "last_sent": 64, "hw_cc": null, "hw_channel": null, + "touch_sense_mode": "off", "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "ae9cd47a-96af-45a4-83e6-816916a65e0e", - "label": "Fader 4", + "label": "oh shit", "cc": 1, "ch": 1, - "value": 64, + "value": 76, "color": "Gray", "pickup": false, - "last_sent": 64, + "last_sent": 76, "hw_cc": null, "hw_channel": null, + "touch_sense_mode": "off", "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "fee53fde-2f59-4800-8873-3d75ca619366", "label": "Fader 5", "cc": 1, "ch": 1, - "value": 64, + "value": 0, "color": "Gray", "pickup": false, - "last_sent": 64, + "last_sent": 0, "hw_cc": null, "hw_channel": null, + "touch_sense_mode": "off", "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "1783a98d-16c9-4a06-ae4d-0f6ab36d4421", "label": "WOOOOW", "cc": 1, "ch": 1, - "value": 64, + "value": 36, "color": "Gray", "pickup": false, - "last_sent": 64, + "last_sent": 36, "hw_cc": null, "hw_channel": null, + "touch_sense_mode": "off", "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 }, { "uid": "2a55bdef-83aa-458f-a37d-e12a61e3e6a0", "label": "Fader 7", "cc": 1, "ch": 1, - "value": 64, + "value": 18, "color": "Gray", "pickup": false, - "last_sent": 64, + "last_sent": 18, "hw_cc": null, "hw_channel": null, + "touch_sense_mode": "off", "zone": null, "center_index": null, "zone_index": null, - "remote": true + "remote": true, + "dest_id": 1 } ], "toggles": [ { "uid": "46f17f40-1bb2-4527-b6c5-fd26e535beed", - "label": "Toggle 1", + "label": "whatever tap", "cc": 20, "ch": 1, "state": false, @@ -489,7 +543,8 @@ "hw_cc": null, "hw_channel": null, "trigger_mode": "toggle", - "remote": true + "remote": true, + "dest_id": 1 } ], "browser": { @@ -515,7 +570,7 @@ "show_hide": { "fader": false, "toggle": true, - "transport": true, + "transport": false, "browser": true }, "sections": { @@ -789,6 +844,7 @@ "output_mode": "osc", "osc_addr": "t/1/play", "trigger_mode": "momentary", + "remote": true, "dest_id": 1 }, { @@ -804,6 +860,7 @@ "output_mode": "osc", "osc_addr": "t/1/stop", "trigger_mode": "momentary", + "remote": true, "dest_id": 1 }, { @@ -819,6 +876,7 @@ "output_mode": "osc", "osc_addr": "t/1/record", "trigger_mode": "momentary", + "remote": true, "dest_id": 1 }, { @@ -834,16 +892,406 @@ "output_mode": "osc", "osc_addr": "t/1/recenable/1/@", "trigger_mode": "momentary", + "remote": true, + "dest_id": 1 + }, + { + "uid": "4208c3e3-c0b0-4c51-a426-be90568c904a", + "label": "<", + "cc": 20, + "state": false, + "color": "Gray", + "zone": null, + "center_index": null, + "zone_index": null, + "hw_cc": null, + "output_mode": "osc", + "osc_addr": "/3/track-", + "trigger_mode": "momentary", + "remote": true, + "dest_id": 1 + }, + { + "uid": "7a029b92-9cc0-4534-8657-6abe24c592e0", + "label": ">", + "cc": 20, + "state": false, + "color": "Gray", + "zone": null, + "center_index": null, + "zone_index": null, + "hw_cc": null, + "output_mode": "osc", + "osc_addr": "/3/track+", + "trigger_mode": "momentary", + "remote": true, "dest_id": 1 } ], - "transport_show_hide": true, + "transport_show_hide": false, "io_config": { - "midi_out": "IAC Driver Bus 5", + "midi_out": "USB MIDI Port 1", "midi_in": "Not Connected", + "midi_controller_in": "USB MIDI Port 1", "transport_out": "Not Connected", "osc_ip": "127.0.0.1", - "osc_listen_port": 9000, - "osc_send_port": 8000 - } + "osc_listen_port": 9001, + "osc_send_port": 8000, + "osc_running": true, + "tablet_running": true + }, + "focus_toggles": [ + { + "uid": "6aa50c87-25fa-45a9-8b21-22ca6823bfef", + "label": "MUTE", + "state": false, + "color": "Red", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/mute", + "osc_addr_out": "/3/mute", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "01d26539-51b9-4413-b180-5b012f212e66", + "label": "SOLO", + "state": false, + "color": "Yellow", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/solo", + "osc_addr_out": "/3/solo", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "888310d7-efd8-4ca7-b5e6-9d62a2ba7d5c", + "label": "WRITE", + "state": false, + "color": "Red", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/atmwrite", + "osc_addr_out": "/3/atmwrite", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "21f875c2-d49e-484f-b0cd-5a9a5f89b0f9", + "label": "OFF", + "state": true, + "color": "White", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/atmoff", + "osc_addr_out": "/3/atmoff", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "180759b7-e68b-4b8a-aa11-ee19d2a87fa3", + "label": "TOUCH", + "state": false, + "color": "Yellow", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/atmtouch", + "osc_addr_out": "/3/atmtouch", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "5d9b7b3a-2429-4dd1-bcf2-043a6db9135d", + "label": "LISTEN", + "state": false, + "color": "Green", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/input", + "osc_addr_out": "/3/input", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "0916bf53-ac82-4fb2-9842-835aea39e7d5", + "label": "ARM", + "state": true, + "color": "Red", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/recenable", + "osc_addr_out": "/3/recenable", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "5433708f-5566-40a8-9333-f3061a07c217", + "label": "READ", + "state": false, + "color": "Green", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/3/atmread", + "osc_addr_out": "/3/atmread", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "d8741393-ec45-4bc2-a9a0-6dd7dbbe04dd", + "label": "<", + "state": false, + "color": "Gray", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "", + "osc_addr_out": "/3/track-", + "hardware_enabled": true, + "feedback_enabled": false, + "hw_cc": 39, + "hw_channel": 16, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "5f951196-c69a-43f2-b5c3-0bf263128c69", + "label": ">", + "state": false, + "color": "Gray", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "", + "osc_addr_out": "/3/track+", + "hardware_enabled": true, + "feedback_enabled": false, + "hw_cc": 40, + "hw_channel": 16, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "47a463c5-83c9-4e93-ace7-a488e0a20647", + "label": "Play", + "state": true, + "color": "Green", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/track/mute", + "osc_addr_out": "t/1/play", + "hardware_enabled": true, + "feedback_enabled": false, + "hw_cc": 48, + "hw_channel": 16, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "64a0aa02-4d41-4da2-b35d-c32a04d95157", + "label": "Stop", + "state": false, + "color": "White", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/track/mute", + "osc_addr_out": "t/1/stop", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + }, + { + "uid": "c0cb0422-b095-4d2d-bafa-b3c48da537d3", + "label": "Record", + "state": false, + "color": "Red", + "zone": null, + "center_index": null, + "zone_index": null, + "trigger_mode": "momentary", + "osc_addr_in": "/track/mute", + "osc_addr_out": "t/1/record", + "hardware_enabled": false, + "feedback_enabled": false, + "hw_cc": null, + "hw_channel": null, + "hw_out_cc": 7, + "hw_out_ch": 1 + } + ], + "focus_feedbacks": [ + { + "uid": "f61354e9-96f7-4826-975b-faea732c487c", + "label": "track name", + "text": "piano", + "color": "Gray", + "osc_addr_in": "/3/trackname", + "custom_enabled": false, + "custom_text": "" + }, + { + "uid": "8e77be47-8d5a-4eda-906e-db978cd2207e", + "label": "track number", + "text": "6", + "color": "Gray", + "osc_addr_in": "/3/track#", + "custom_enabled": false, + "custom_text": "" + }, + { + "uid": "c97ec8c9-1b97-48f4-9744-62f8a2faec28", + "label": "fader val", + "text": "-7.13dB", + "color": "Gray", + "osc_addr_in": "/3/trkvolval", + "custom_enabled": false, + "custom_text": "" + }, + { + "uid": "28956bcf-b147-4e5b-a8c9-ec07d7be70e3", + "label": "time", + "text": "15:29.000", + "color": "Gray", + "osc_addr_in": "/1/time", + "custom_enabled": false, + "custom_text": "" + }, + { + "uid": "0916a793-6adf-43b9-a2ea-8fca7ddafc57", + "label": "bar | beat", + "text": "465.3.00", + "color": "Gray", + "osc_addr_in": "/3/bar", + "custom_enabled": false, + "custom_text": "" + }, + { + "uid": "7085f9f7-31dd-4214-9e4d-84b9618850ae", + "label": "Send 1 Val", + "text": "-10.3dB", + "color": "Gray", + "osc_addr_in": "/3/sendval1", + "custom_enabled": false, + "custom_text": "" + }, + { + "uid": "506eaa2e-8ff4-4e86-a445-f00a46c037df", + "label": "2MX Send", + "text": "2MX SEND", + "color": "Gray", + "osc_addr_in": "/3/trackname", + "custom_enabled": true, + "custom_text": "2MX SEND" + }, + { + "uid": "5259b459-4ba4-478d-b35e-4785bb6d75b0", + "label": "Feedback 8", + "text": "Track", + "color": "Gray", + "osc_addr_in": "/3/trackname", + "custom_enabled": true, + "custom_text": "Track" + } + ], + "focus_faders": [ + { + "uid": "18558185-7cd4-4c2a-995e-4524e69d99e1", + "label": "piano", + "value": 73, + "color": "Green", + "pickup": false, + "last_sent": 62, + "osc_addr_in": "/3/volume", + "osc_addr_out": "/3/volume", + "osc_addr_name": "/3/trackname", + "zone": null, + "center_index": null, + "zone_index": null, + "hardware_enabled": true, + "feedback_enabled": true, + "hw_cc": 8, + "hw_channel": 16, + "hw_out_cc": 8, + "hw_out_ch": 16, + "touch_sense_mode": "jl_cooper" + }, + { + "uid": "279a2c81-6548-422e-bf6d-75119c81b9c3", + "label": "2MX", + "value": 65, + "color": "Gray", + "pickup": false, + "last_sent": 42, + "osc_addr_in": "/3/sendlevel1", + "osc_addr_out": "/3/sendlevel1", + "osc_addr_name": "/track/name", + "zone": null, + "center_index": null, + "zone_index": null, + "hardware_enabled": true, + "feedback_enabled": true, + "hw_cc": 7, + "hw_channel": 16, + "hw_out_cc": 7, + "hw_out_ch": 16, + "touch_sense_mode": "jl_cooper" + } + ] } \ No newline at end of file diff --git a/remote-client-ios/remote-client-ios/Core/FaderTestVC.swift b/remote-client-ios/remote-client-ios/Core/FaderTestVC.swift new file mode 100644 index 0000000..e239455 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/FaderTestVC.swift @@ -0,0 +1,231 @@ +import UIKit + +class FaderTestVC: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(white: 0.08, alpha: 1) + + let colors: [UIColor] = [ + UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1), + UIColor(red: 0, green: 0.6, blue: 1, alpha: 1), + UIColor(red: 1, green: 0.55, blue: 0, alpha: 1), + ] + + let padW: CGFloat = 98 + let padH: CGFloat = 540 + let spacing: CGFloat = 20 + let totalW = CGFloat(colors.count) * padW + CGFloat(colors.count - 1) * spacing + let startX = (UIScreen.main.bounds.width - totalW) / 2 + let startY = (UIScreen.main.bounds.height - padH) / 2 + + for (i, color) in colors.enumerated() { + let x = startX + CGFloat(i) * (padW + spacing) + let pad = XYPadView(label: "PAD \(i)", color: color, + frame: CGRect(x: x, y: startY, width: padW, height: padH)) + view.addSubview(pad) + } + } +} + +class XYPadView: UIView { + + private let label: String + private let color: UIColor + private let padHeight: CGFloat = 90 + + private var isRelative = true + private var currentValue: Float = 0 + private var touchStartY: CGFloat = 0 + private var valueAtTouchStart: Float = 0 + private var didInitialLayout = false + private var lastHapticValue: Int = -1 + + private let haptic = UIImpactFeedbackGenerator(style: .medium) + + private let topZone = UIView() + private let botZone = UIView() + private let fillView = UIView() + private let gradLayer = CAGradientLayer() + private let line = UIView() + private let dot = UIView() + + private let nameLabel: UILabel = { + let lbl = UILabel() + lbl.textAlignment = .center + lbl.font = .systemFont(ofSize: 13, weight: .medium) + return lbl + }() + + private lazy var modeButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("REL", for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .semibold) + btn.setTitleColor(.white, for: .normal) + btn.backgroundColor = color.withAlphaComponent(0.35) + btn.layer.cornerRadius = 4 + btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside) + return btn + }() + + init(label: String, color: UIColor, frame: CGRect) { + self.label = label + self.color = color + super.init(frame: frame) + backgroundColor = UIColor(white: 0.1, alpha: 1) + layer.borderColor = color.withAlphaComponent(0.3).cgColor + layer.borderWidth = 1 + layer.cornerRadius = 6 + clipsToBounds = true + + topZone.backgroundColor = UIColor(white: 0.07, alpha: 1) + botZone.backgroundColor = UIColor(white: 0.07, alpha: 1) + addSubview(topZone) + addSubview(botZone) + + gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor] + gradLayer.startPoint = CGPoint(x: 0.5, y: 1) + gradLayer.endPoint = CGPoint(x: 0.5, y: 0) + fillView.layer.addSublayer(gradLayer) + addSubview(fillView) + + line.backgroundColor = color + line.layer.shadowColor = color.cgColor + line.layer.shadowRadius = 4 + line.layer.shadowOpacity = 0.8 + line.layer.shadowOffset = .zero + addSubview(line) + + dot.backgroundColor = color + dot.layer.cornerRadius = 5 + dot.layer.shadowColor = color.cgColor + dot.layer.shadowRadius = 6 + dot.layer.shadowOpacity = 1 + dot.layer.shadowOffset = .zero + addSubview(dot) + + nameLabel.textColor = UIColor(white: 0.4, alpha: 1) + nameLabel.text = label + addSubview(nameLabel) + + addSubview(modeButton) + haptic.prepare() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + let w = bounds.width + let h = bounds.height + + topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight) + botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight) + line.frame.size = CGSize(width: w, height: 2) + dot.frame.size = CGSize(width: 10, height: 10) + dot.layer.cornerRadius = 5 + nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight) + modeButton.frame = CGRect(x: w / 2 - 22, y: padHeight / 2 - 12, width: 44, height: 24) + + topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() } + botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() } + addDash(to: topZone, atBottom: true) + addDash(to: botZone, atBottom: false) + + if !didInitialLayout { + didInitialLayout = true + showAtCurrentValue() + } + } + + private func addDash(to zone: UIView, atBottom: Bool) { + let dash = CAShapeLayer() + let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0 + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: zone.bounds.width, y: y)) + dash.path = path.cgPath + dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor + dash.lineWidth = 1 + dash.lineDashPattern = [6, 4] + zone.layer.addSublayer(dash) + } + + // MARK: - Mode Toggle + + @objc private func toggleMode() { + isRelative.toggle() + modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal) + modeButton.backgroundColor = isRelative + ? color.withAlphaComponent(0.35) + : color.withAlphaComponent(0.15) + } + + // MARK: - Touch + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let pt = touches.first?.location(in: self) else { return } + touchStartY = pt.y + valueAtTouchStart = currentValue + moveDot(to: pt) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let pt = touches.first?.location(in: self) else { return } + moveDot(to: pt) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) {} + override func touchesCancelled(_ touches: Set, with event: UIEvent?) {} + + private func moveDot(to pt: CGPoint) { + let activeTop = padHeight + let activeBottom = bounds.height - padHeight + let activeHeight = activeBottom - activeTop + + let value: Float + if isRelative { + let deltaY = pt.y - touchStartY + let deltaPct = Float(-deltaY / activeHeight) + value = max(0, min(127, valueAtTouchStart + deltaPct * 127)) + } else { + let clampedY = max(activeTop, min(activeBottom, pt.y)) + value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127))) + } + + currentValue = value + updateDisplay() + + let intValue = Int(value) + if (intValue == 0 || intValue == 127) && intValue != lastHapticValue { + haptic.impactOccurred() + lastHapticValue = intValue + } else if intValue != 0 && intValue != 127 { + lastHapticValue = -1 + } + + print("[\(label)] val:\(intValue)") + } + + private func showAtCurrentValue() { updateDisplay() } + + private func updateDisplay() { + let activeTop = padHeight + let activeBottom = bounds.height - padHeight + let activeHeight = activeBottom - activeTop + let w = bounds.width + let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight + + let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY) + CATransaction.begin() + CATransaction.setDisableActions(true) + fillView.frame = fillFrame + gradLayer.frame = fillView.bounds + CATransaction.commit() + + line.frame.origin = CGPoint(x: 0, y: displayY - 1) + dot.center = CGPoint(x: w / 2, y: displayY) + } +} 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 32f96f9..d191969 100644 --- a/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift +++ b/remote-client-ios/remote-client-ios/Core/Models/PresetLoadToRemoteDevice.swift @@ -7,6 +7,9 @@ struct ModelPresetLoadToRemoteDevice: Codable { let faders: [ModelFaderPayload] let toggles: [ModelTogglePayload] let transports: [ModelTransportPayload] + let focusFaders: [ModelFocusFaderPayload] + let focusToggles: [ModelFocusTogglePayload] + let focusFeedbacks: [ModelFocusFeedbackPayload] var displayTitle: String { bigTitle.isEmpty ? "No Title" : bigTitle @@ -27,10 +30,42 @@ struct ModelTogglePayload: Codable { let state: Bool let destId: Int let color: String + // The desktop's actual palette name (e.g. "Gray", "White", "Red") — used + // instead of inferring from the resolved color's saturation, which can't + // reliably tell a true neutral gray apart from white (both ~0 saturation). + let colorName: String } struct ModelTransportPayload: Codable { let uid: String let label: String let destId: Int + let color: String +} + +// Channel Focus items are global (not per-preset) and shown via the dedicated +// Channel Focus mode toggle rather than destId-based routing. +struct ModelFocusFaderPayload: Codable { + let uid: String + let label: String + let value: Int + let color: String +} + +struct ModelFocusTogglePayload: Codable { + let uid: String + let label: String + let state: Bool + let color: String + let colorName: String + let triggerMode: String + + var isMomentary: Bool { triggerMode == "momentary" } +} + +struct ModelFocusFeedbackPayload: Codable { + let uid: String + let label: String + let text: String + let color: String } diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Bonjour.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Bonjour.swift new file mode 100644 index 0000000..73e913b --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Functions-Bonjour.swift @@ -0,0 +1,86 @@ +import Foundation + +// Discovers the desktop app's WebSocket server on the LAN via Bonjour/mDNS +// (matches the "_vcremote._tcp" service the desktop advertises while its +// tablet server is running) and auto-fills ipField/portField — the user +// still taps Connect, this just saves typing the IP by hand. +extension ArrangeObjects: NetServiceBrowserDelegate, NetServiceDelegate { + + func startBonjourDiscovery() { + print("[Bonjour] starting search for \(bonjourServiceType).local.") + bonjourBrowser.delegate = self + bonjourBrowser.searchForServices(ofType: "\(bonjourServiceType).", inDomain: "local.") + loggingView.log("Bonjour: searching for \(bonjourServiceType)", category: .local) + } + + func stopBonjourDiscovery() { + print("[Bonjour] stopping search") + bonjourBrowser.stop() + discoveredServices.removeAll() + } + + // MARK: - NetServiceBrowserDelegate + func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) { + print("[Bonjour] found service: \(service.name) domain=\(service.domain) moreComing=\(moreComing)") + discoveredServices.append(service) + service.delegate = self + service.resolve(withTimeout: 5) + } + + func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) { + print("[Bonjour] service removed: \(service.name)") + discoveredServices.removeAll { $0 === service } + } + + func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) { + print("[Bonjour] search stopped") + } + + func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) { + print("[Bonjour] search failed to start: \(errorDict)") + } + + // MARK: - NetServiceDelegate + func netServiceDidResolveAddress(_ sender: NetService) { + print("[Bonjour] resolving \(sender.name) — addresses: \(sender.addresses?.count ?? 0), rawPort=\(sender.port)") + guard let ip = Self.ipv4Address(from: sender) else { + print("[Bonjour] could not extract an IPv4 address from \(sender.name)") + return + } + print("[Bonjour] resolved \(sender.name) -> \(ip):\(sender.port)") + DispatchQueue.main.async { + self.ipField.text = ip + self.portField.text = "\(sender.port)" + self.loggingView.log("Bonjour found \(sender.name) at \(ip):\(sender.port)", category: .local) + self.showStatus("[ Bonjour found \(sender.name) \(self.statusTimestamp()) ]") + } + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + print("[Bonjour] failed to resolve \(sender.name): \(errorDict)") + loggingView.log("Bonjour: failed to resolve \(sender.name) — \(errorDict)", category: .local) + } + + // MARK: - Address parsing + private static func ipv4Address(from service: NetService) -> String? { + guard let addresses = service.addresses else { return nil } + for data in addresses { + if let ip = ipv4String(from: data) { + return ip + } + } + return nil + } + + private static func ipv4String(from data: Data) -> String? { + data.withUnsafeBytes { rawPtr -> String? in + guard let sockaddrPtr = rawPtr.baseAddress?.assumingMemoryBound(to: sockaddr.self) else { return nil } + guard sockaddrPtr.pointee.sa_family == sa_family_t(AF_INET) else { return nil } + let sinPtr = rawPtr.baseAddress!.assumingMemoryBound(to: sockaddr_in.self) + var addr = sinPtr.pointee.sin_addr + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil } + return String(cString: buffer) + } + } +} 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 index 1f83b48..93e9a42 100644 --- 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 @@ -21,10 +21,34 @@ extension ArrangeObjects { guard checkerIPAddressInput(ip: ip) else { return } guard checkerPortInput(port: port) else { return } guard let url = buildWebSocketURL(ip: ip, port: port) else { return } + UserDefaults.standard.set(ip, forKey: "last_ip") + UserDefaults.standard.set(port, forKey: "last_port") self.connectWebSocket(url: url) } } + @objc func buttonTappedGridButton() { + let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1) + gridEnabled.toggle() + gridOverlay.isHidden = !gridEnabled + gridButton.backgroundColor = gridEnabled ? green : UIColor(white: 0.15, alpha: 1) + gridButton.setTitleColor(gridEnabled ? .black : .lightGray, for: .normal) + } + + @objc func tabletIdFieldChanged() { + guard let text = tabletIdField.text, let idVal = Int(text), idVal > 0 else { return } + myTabletId = idVal + if let preset = currentPreset { + buildUIFromPreset(preset) + } + } + + @objc func buttonTappedClearLayouts() { + UserDefaults.standard.removeObject(forKey: "layout_\(self.presetUuid)") + loggingView.log("cleared layout for \(self.presetUuid)", category: .local) + if let preset = currentPreset { buildUIFromPreset(preset) } + } + @objc func buttonTappedShowLogging() { let visible = (self.logWidget.isHidden == false) self.logWidget.isHidden = visible @@ -39,17 +63,69 @@ extension ArrangeObjects { 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 buttonTappedChannelFocus() { + guard currentPreset != nil else { + loggingView.log("channel focus: no preset received yet", category: .local) + return + } + isChannelFocusMode.toggle() + let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1) + channelFocusButton.backgroundColor = isChannelFocusMode ? green : UIColor(white: 0.15, alpha: 1) + channelFocusButton.setTitleColor(isChannelFocusMode ? .black : .lightGray, for: .normal) + if isChannelFocusMode { + buildChannelFocusUI() + } else if let preset = currentPreset { + buildUIFromPreset(preset) + } + loggingView.log("channel focus \(isChannelFocusMode ? "enabled" : "disabled")", category: .local) } - @objc func buttonTappedLockButton() { - self.isLocked = true - self.updateUIArrangeLockButtons() - self.saveLayout() - self.widgets.values.forEach { $0.setArrangeMode(false) } + @objc func buttonTappedArrangeButton() { + isLocked.toggle() + updateUIArrangeLockButtons() + if isLocked { + saveLayout() + arrangeSessionLayoutKey = nil + arrangeSessionLayoutSnapshot = nil + widgets.values.forEach { $0.setArrangeMode(false) } + } else { + if let key = currentLayoutKey { + arrangeSessionLayoutKey = key + arrangeSessionLayoutSnapshot = UserDefaults.standard.dictionary(forKey: key) ?? [:] + } + widgets.values.forEach { $0.setArrangeMode(true) } + } + } + + // Reverts to how things were the moment Arrange mode was entered — + // undoes drags/resizes/alignment changes/hides made this session, even + // though most of those already auto-saved along the way. + @objc func buttonTappedCancelChanges() { + guard let key = arrangeSessionLayoutKey else { return } + UserDefaults.standard.set(arrangeSessionLayoutSnapshot ?? [:], forKey: key) + + // Hiding a regular (non-Channel-Focus) widget tells the desktop to + // uncheck its Remote assignment and saves that to the preset file — + // resend every regular widget's original destId so the desktop's + // state matches again. Channel Focus uids aren't tracked server-side + // for this message, so this is a no-op for those either way. + if let preset = currentPreset { + for f in preset.faders { wsClientSendJSON(["event": "widget_visibility", "uid": f.uid, "dest_id": f.destId]) } + for t in preset.toggles { wsClientSendJSON(["event": "widget_visibility", "uid": t.uid, "dest_id": t.destId]) } + for t in preset.transports { wsClientSendJSON(["event": "widget_visibility", "uid": t.uid, "dest_id": t.destId]) } + } + + arrangeSessionLayoutKey = nil + arrangeSessionLayoutSnapshot = nil + isLocked = true + updateUIArrangeLockButtons() + + if isChannelFocusMode { + buildChannelFocusUI() + } else if let preset = currentPreset { + buildUIFromPreset(preset) + } + showStatus("[ Cancelled \(self.statusTimestamp()) ]") } } 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 index 005fe6c..a627e34 100644 --- 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 @@ -1,19 +1,109 @@ import UIKit +extension ArrangeObjects: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } +} + extension ArrangeObjects { // MARK: - Gestures func addDragGesture(to view: UIView) { let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGesturePan(_:))) + pan.cancelsTouchesInView = false 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) + + if gesture.state == .began { + dragStartOrigin = view.frame.origin + lastSnappedOrigin = view.frame.origin + if gridEnabled { snapHaptic.prepare() } + } + + let total = gesture.translation(in: self.containerView) + let rawOrigin = CGPoint(x: dragStartOrigin.x + total.x, y: dragStartOrigin.y + total.y) + + if gridEnabled { + let snapped = CGPoint(x: snapValue(rawOrigin.x), y: snapValue(rawOrigin.y)) + view.frame.origin = snapped + if snapped != lastSnappedOrigin { + snapHaptic.impactOccurred() + lastSnappedOrigin = snapped + } + } else { + view.frame.origin = rawOrigin + } + } + + func snapFrame(_ frame: CGRect) -> CGRect { + guard gridEnabled else { return frame } + + let nearestLeft = snapValue(frame.minX) + let nearestRight = snapValue(frame.maxX) - frame.width + let newX = abs(nearestLeft - frame.minX) <= abs(nearestRight - frame.minX) ? nearestLeft : nearestRight + + let nearestTop = snapValue(frame.minY) + let nearestBottom = snapValue(frame.maxY) - frame.height + let newY = abs(nearestTop - frame.minY) <= abs(nearestBottom - frame.minY) ? nearestTop : nearestBottom + + return CGRect(x: newX, y: newY, width: frame.width, height: frame.height) + } + + func snapValue(_ value: CGFloat) -> CGFloat { + (value / gridSize).rounded() * gridSize + } + + func addPinchGesture(to view: UIView) { + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handleGesturePinch(_:))) + pinch.delegate = self + view.addGestureRecognizer(pinch) + } + + @objc func handleGesturePinch(_ gesture: UIPinchGestureRecognizer) { + guard self.isLocked == false, let widget = gesture.view else { return } + switch gesture.state { + case .began: + pinchStartSize = widget.bounds.size + case .changed: + let scale = max(gesture.scale, 75 / min(pinchStartSize.width, pinchStartSize.height)) + let center = widget.center + widget.bounds.size = CGSize(width: pinchStartSize.width * scale, height: pinchStartSize.height * scale) + widget.center = center + case .ended, .cancelled: + widget.frame.origin = CGPoint(x: snapValue(widget.frame.origin.x), y: snapValue(widget.frame.origin.y)) + saveLayout() + default: + break + } + } + + // FocusFeedbackWidget pinch: adjusts font size directly rather than an + // independent bounds size, so the frame is always a tight fit around the + // text (no baked-in minimum box) and aspect ratio can't drift from it. + func addFeedbackPinchGesture(to widget: FocusFeedbackWidget) { + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handleFeedbackPinch(_:))) + pinch.delegate = self + widget.addGestureRecognizer(pinch) + } + + @objc func handleFeedbackPinch(_ gesture: UIPinchGestureRecognizer) { + guard self.isLocked == false, let widget = gesture.view as? FocusFeedbackWidget else { return } + switch gesture.state { + case .began: + pinchStartFontSize = widget.fontSize + case .changed: + widget.setFontSize(pinchStartFontSize * gesture.scale) + case .ended, .cancelled: + widget.frame.origin = CGPoint(x: snapValue(widget.frame.origin.x), y: snapValue(widget.frame.origin.y)) + saveLayout() + default: + break + } } func addContextMenuGesture(to view: UIView) { @@ -31,6 +121,56 @@ extension ArrangeObjects { let alert = UIAlertController(title: widget.displayName, message: nil, preferredStyle: .actionSheet) alert.overrideUserInterfaceStyle = .dark + // Channel Focus fader only: longer throw = finer touch resolution, + // since the active drag zone grows with the widget's height while the + // top/bottom padding stays fixed (see FocusFaderWidget.padHeight). + if widget is FocusFaderWidget { + alert.addAction(UIAlertAction(title: "50% Longer", style: .default) { [weak self] _ in + self?.scaleFocusFaderLength(widget, multiplier: 1.5) + }) + alert.addAction(UIAlertAction(title: "70% Longer", style: .default) { [weak self] _ in + self?.scaleFocusFaderLength(widget, multiplier: 1.7) + }) + alert.addAction(UIAlertAction(title: "Reset Length", style: .default) { [weak self] _ in + self?.scaleFocusFaderLength(widget, multiplier: 1.0) + }) + } + + if let alignable = widget as? TextAlignableWidget { + let current = alignable.textAlignmentName + func alignTitle(_ label: String, _ value: String) -> String { + value == current ? "\(label) ✓" : label + } + alert.addAction(UIAlertAction(title: alignTitle("Align Left", "left"), style: .default) { [weak self] _ in + alignable.textAlignmentName = "left" + self?.saveLayout() + }) + alert.addAction(UIAlertAction(title: alignTitle("Align Center", "center"), style: .default) { [weak self] _ in + alignable.textAlignmentName = "center" + self?.saveLayout() + }) + alert.addAction(UIAlertAction(title: alignTitle("Align Right", "right"), style: .default) { [weak self] _ in + alignable.textAlignmentName = "right" + self?.saveLayout() + }) + } + + // FocusFeedbackWidget's box size is a direct function of its font + // size (see resizeToFitText) — pinch changes fontSize, so resetting + // fontSize back to defaultFontSize is what "original size" means here. + if let feedbackWidget = widget as? FocusFeedbackWidget { + alert.addAction(UIAlertAction(title: "Reset Size", style: .default) { [weak self] _ in + feedbackWidget.setFontSize(FocusFeedbackWidget.defaultFontSize) + self?.saveLayout() + }) + } + + if let colorable = widget as? TextColorableWidget { + alert.addAction(UIAlertAction(title: "Text Color", style: .default) { [weak self] _ in + self?.presentTextColorPicker(for: colorable, widget: widget) + }) + } + alert.addAction(UIAlertAction(title: "Hide", style: .destructive) { [weak self] _ in self?.hideWidget(widget) }) @@ -44,6 +184,28 @@ extension ArrangeObjects { self.present(alert, animated: true) } + func presentTextColorPicker(for colorable: TextColorableWidget, widget: BaseWidget) { + let alert = UIAlertController(title: "Text Color", message: nil, preferredStyle: .actionSheet) + alert.overrideUserInterfaceStyle = .dark + + let current = colorable.textColorName + for entry in NamedColorPalette.entries { + let title = entry.name == current ? "\(entry.name) ✓" : entry.name + alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in + colorable.textColorName = entry.name + self?.saveLayout() + }) + } + 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) @@ -51,4 +213,17 @@ extension ArrangeObjects { self.wsClientSendJSON(["event": "widget_visibility", "uid": widget.uid, "dest_id": 0]) } + // Scales only the height, anchored to the fader's bottom edge (its "zero" + // reference point) — width and position stay put. multiplier is relative + // to the widget's default size, not its current size, so picking the same + // option twice is idempotent rather than compounding. + func scaleFocusFaderLength(_ widget: BaseWidget, multiplier: CGFloat) { + let bottomY = widget.frame.maxY + let newHeight = FocusFaderWidget.size.height * multiplier + widget.frame.size.width = FocusFaderWidget.size.width + widget.frame.size.height = newHeight + widget.frame.origin.y = bottomY - newHeight + self.saveLayout() + } + } 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 index caf5141..7698f64 100644 --- 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 @@ -19,13 +19,18 @@ extension ArrangeObjects { self.presetUuid = preset.presetUuid self.widgets = [:] self.feedbackWidgets = [] - self.containerView.subviews.forEach { if $0 !== self.logWidget { $0.removeFromSuperview() } } + self.containerView.subviews.forEach { if $0 !== self.logWidget && $0 !== self.gridOverlay { $0.removeFromSuperview() } } + self.containerView.sendSubviewToBack(self.gridOverlay) 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 titleFrame = savedFrame(uid: titleUid, in: savedLayout) ?? CGRect(x: 16, y: 90, width: 300, height: 44) let titleWidget = TitleWidget(uid: titleUid, text: preset.displayTitle, frame: titleFrame) + if let align = savedAlignment(uid: titleUid, in: savedLayout) { + titleWidget.textAlignmentName = align + } self.addDragGesture(to: titleWidget) + self.addContextMenuGesture(to: titleWidget) self.containerView.addSubview(titleWidget) self.widgets[titleUid] = titleWidget @@ -41,6 +46,7 @@ extension ArrangeObjects { frame: frame) w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) } self.addDragGesture(to: w) + // self.addPinchGesture(to: w) self.addContextMenuGesture(to: w) self.containerView.addSubview(w) self.widgets[f.uid] = w @@ -56,6 +62,7 @@ extension ArrangeObjects { frame: frame) w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) } self.addDragGesture(to: w) + self.addPinchGesture(to: w) self.addContextMenuGesture(to: w) self.containerView.addSubview(w) self.widgets[t.uid] = w @@ -66,9 +73,11 @@ extension ArrangeObjects { let frame = savedFrame(uid: t.uid, in: savedLayout) ?? TransportWidget.defaultFrame(idx: squareIdx) let w = TransportWidget(uid: t.uid, label: t.label, + 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.addPinchGesture(to: w) self.addContextMenuGesture(to: w) self.containerView.addSubview(w) self.widgets[t.uid] = w @@ -80,24 +89,110 @@ extension ArrangeObjects { self.showStatus("[ \(preset.presetName) \(self.statusTimestamp()) ]") } + // Channel Focus: global, static controls shown instead of the current preset's + // widgets, independent of destId routing and unaffected by preset auto-switch. + func buildChannelFocusUI() { + guard let preset = currentPreset else { return } + let layoutKey = "layout_channel_focus" + let savedLayout = UserDefaults.standard.dictionary(forKey: layoutKey) ?? [:] + + self.widgets = [:] + self.feedbackWidgets = [] + self.containerView.subviews.forEach { if $0 !== self.logWidget && $0 !== self.gridOverlay { $0.removeFromSuperview() } } + self.containerView.sendSubviewToBack(self.gridOverlay) + self.presetLabel.text = "Channel Focus" + + var faderIdx = 0 + var squareIdx = 0 + + for f in preset.focusFaders { + let faderFrame = savedFrame(uid: f.uid, in: savedLayout) ?? FocusFaderWidget.defaultFrame(idx: faderIdx) + let faderWidget = FocusFaderWidget(uid: f.uid, + label: f.label, + value: f.value, + color: UIColor.fromHex(f.color), + frame: faderFrame) + faderWidget.onSendFloat = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control_f", "uid": uid, "value": val]) } + self.addDragGesture(to: faderWidget) + self.addContextMenuGesture(to: faderWidget) + self.containerView.addSubview(faderWidget) + self.widgets[f.uid] = faderWidget + faderIdx += 1 + } + + for t in preset.focusToggles { + let frame = savedFrame(uid: t.uid, in: savedLayout) ?? FocusToggleWidget.defaultFrame(idx: squareIdx) + let w = FocusToggleWidget(uid: t.uid, + label: t.label, + isOn: t.state, + color: UIColor.fromHex(t.color), + colorName: t.colorName, + isMomentary: t.isMomentary, + frame: frame) + w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) } + self.addDragGesture(to: w) + self.addPinchGesture(to: w) + self.addContextMenuGesture(to: w) + self.containerView.addSubview(w) + self.widgets[t.uid] = w + squareIdx += 1 + } + + var feedbackIdx = 0 + for fb in preset.focusFeedbacks { + let frame = savedFrame(uid: fb.uid, in: savedLayout) ?? FocusFeedbackWidget.defaultFrame(idx: feedbackIdx, text: fb.text) + let w = FocusFeedbackWidget(uid: fb.uid, + label: fb.label, + text: fb.text, + frame: frame) + if let align = savedAlignment(uid: fb.uid, in: savedLayout) { + w.textAlignmentName = align + } + if let colorName = savedTextColorName(uid: fb.uid, in: savedLayout) { + w.textColorName = colorName + } + self.addDragGesture(to: w) + self.addFeedbackPinchGesture(to: w) + self.addContextMenuGesture(to: w) + self.containerView.addSubview(w) + self.widgets[fb.uid] = w + feedbackIdx += 1 + } + + self.isLocked = savedLayout.count > 0 + self.updateUIArrangeLockButtons() + self.showStatus("[ Channel Focus \(self.statusTimestamp()) ]") + } + func saveLayout() { - guard self.presetUuid.isEmpty == false else { + guard let key = currentLayoutKey else { self.loggingView.log("saveLayout: aborted — presetUuid is empty", category: .local) return } + saveLayoutWidgets(key: key) + } + + private func saveLayoutWidgets(key: String) { var layoutDict: [String: Any] = [:] for (uid, w) in self.widgets { let f = w.frame - layoutDict[uid] = [ + var entry: [String: Any] = [ "x": Double(f.origin.x), "y": Double(f.origin.y), "w": Double(f.width), "h": Double(f.height) ] + if let alignable = w as? TextAlignableWidget { + entry["align"] = alignable.textAlignmentName + } + if let colorable = w as? TextColorableWidget { + entry["textColor"] = colorable.textColorName + } + layoutDict[uid] = entry } - UserDefaults.standard.set(layoutDict, forKey: "layout_\(self.presetUuid)") + UserDefaults.standard.set(layoutDict, forKey: key) self.showStatus("[ Saved \(self.statusTimestamp()) ]") - self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=layout_\(self.presetUuid)", category: .local) + self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=\(key)", category: .local) if let data = try? JSONSerialization.data(withJSONObject: layoutDict), let jsonStr = String(data: data, encoding: .utf8) { self.loggingView.log(jsonStr, category: .local) @@ -112,4 +207,14 @@ extension ArrangeObjects { return CGRect(x: x, y: y, width: w, height: h) } + func savedAlignment(uid: String, in savedLayout: [String: Any]) -> String? { + guard let saved = savedLayout[uid] as? [String: Any] else { return nil } + return saved["align"] as? String + } + + func savedTextColorName(uid: String, in savedLayout: [String: Any]) -> String? { + guard let saved = savedLayout[uid] as? [String: Any] else { return nil } + return saved["textColor"] as? String + } + } 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 index ebc9331..5729668 100644 --- 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 @@ -29,21 +29,30 @@ extension ArrangeObjects { self.connectButton.backgroundColor = red self.connectButton.setTitleColor(.white, for: .normal) self.connectButton.isEnabled = true + stopBonjourDiscovery() 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 + startBonjourDiscovery() } } 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) + arrangeButton.setTitle(isLocked ? "Arrange" : "Lock / Save", for: .normal) + arrangeButton.backgroundColor = isLocked ? UIColor(white: 0.15, alpha: 1) : green + arrangeButton.setTitleColor(isLocked ? .lightGray : .black, for: .normal) + gridButton.isHidden = isLocked + cancelChangesButton.isHidden = isLocked + if isLocked { + gridEnabled = false + gridOverlay.isHidden = true + gridButton.backgroundColor = UIColor(white: 0.15, alpha: 1) + gridButton.setTitleColor(.lightGray, for: .normal) + } } func updateUIFromDawState(_ json: [String: Any]) { @@ -64,6 +73,10 @@ extension ArrangeObjects { self.widgets[uid]?.update(value: value) } + func updateWidgetLabel(uid: String, label: String) { + self.widgets[uid]?.applyLabel(label) + } + func showStatus(_ message: String) { DispatchQueue.main.async { self.statusToken += 1 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 index a1c4435..51775ef 100644 --- 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 @@ -66,6 +66,11 @@ extension ArrangeObjects { self.loggingView.log(jsonStr, category: .local) } DispatchQueue.main.async { + self.currentPreset = preset + guard self.isChannelFocusMode == false else { + self.loggingView.log("preset event cached but not rendered — channel focus is active", category: .local) + return + } let isNewPreset = preset.presetUuid != self.presetUuid if self.isLocked == false && isNewPreset { let alert = UIAlertController( @@ -98,6 +103,18 @@ extension ArrangeObjects { if let uid = json["uid"] as? String, let value = json["value"] as? Int { DispatchQueue.main.async { self.updateWidget(uid: uid, value: value) } } + case "widget_label": + if let uid = json["uid"] as? String, let label = json["label"] as? String { + DispatchQueue.main.async { self.updateWidgetLabel(uid: uid, label: label) } + } + case "widget_update_f": + if let uid = json["uid"] as? String, let value = json["value"] as? Double { + DispatchQueue.main.async { self.widgets[uid]?.applyFloatValue(value) } + } + case "widget_feedback": + if let uid = json["uid"] as? String, let text = json["text"] as? String { + DispatchQueue.main.async { self.widgets[uid]?.applyFeedbackText(text) } + } case "daw_state": DispatchQueue.main.async { self.updateUIFromDawState(json) } default: diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Objects.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Objects.swift index 453ceb9..77fcf58 100644 --- a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Objects.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-Objects.swift @@ -4,9 +4,16 @@ class ArrangeObjects: UIViewController { // MARK: - WebSocket var wsClient: URLSessionWebSocketTask? - + var urlSession: URLSession? + // MARK: - Bonjour Discovery + // Auto-fills ipField/portField when the desktop app's service is found on + // the LAN — user still taps Connect, this just saves typing the IP. + let bonjourServiceType = "_vcremote._tcp" + lazy var bonjourBrowser: NetServiceBrowser = NetServiceBrowser() + var discoveredServices: [NetService] = [] + // MARK: - Connection State enum ConnectionState { case notStarted, connecting, connected, disconnected @@ -14,10 +21,25 @@ class ArrangeObjects: UIViewController { var connectionState: ConnectionState = .notStarted // MARK: - State + var currentPreset: ModelPresetLoadToRemoteDevice? = nil var presetName: String = "" var presetUuid: String = "" var isLocked: Bool = true var autoSwitchEnabled: Bool = true + var isChannelFocusMode: Bool = false + + // MARK: - Arrange Session (Cancel Changes) + // Captured the moment Arrange mode is entered, so "Cancel Changes" can + // restore the layout exactly as it was, undoing any drags/resizes/hides + // made during the session (most of which auto-save immediately). + var arrangeSessionLayoutKey: String? + var arrangeSessionLayoutSnapshot: [String: Any]? + + var currentLayoutKey: String? { + if isChannelFocusMode { return "layout_channel_focus" } + guard presetUuid.isEmpty == false else { return nil } + return "layout_\(presetUuid)" + } var myTabletId: Int { get { let stored = UserDefaults.standard.integer(forKey: "tablet_id") @@ -32,11 +54,25 @@ class ArrangeObjects: UIViewController { // MARK: - Layout var layout: [String: CGRect] = [:] + var pinchStartSize: CGSize = .zero + var pinchStartFontSize: CGFloat = FocusFeedbackWidget.defaultFontSize + var dragStartOrigin: CGPoint = .zero + var lastSnappedOrigin: CGPoint = .zero + lazy var snapHaptic = UIImpactFeedbackGenerator(style: .light) + var gridEnabled: Bool = false + let gridSize: CGFloat = 25 + + lazy var gridOverlay: GridView = { + let v = GridView(frame: .zero) + v.gridSize = self.gridSize + v.translatesAutoresizingMaskIntoConstraints = false + return v + }() let containerView: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = UIColor(white: 0.1, alpha: 1) + view.backgroundColor = .black return view }() @@ -53,6 +89,21 @@ class ArrangeObjects: UIViewController { return btn }() + lazy var gridButton: UIButton = { + let btn = UIButton(type: .system) + btn.translatesAutoresizingMaskIntoConstraints = false + btn.setTitle("Grid", for: .normal) + btn.setTitleColor(.lightGray, for: .normal) + btn.backgroundColor = UIColor(white: 0.15, alpha: 1) + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor + btn.layer.cornerRadius = 4 + btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) + btn.addTarget(self, action: #selector(self.buttonTappedGridButton), for: .touchUpInside) + btn.isHidden = true + return btn + }() + lazy var arrangeButton: UIButton = { let btn = UIButton(type: .system) btn.translatesAutoresizingMaskIntoConstraints = false @@ -67,6 +118,21 @@ class ArrangeObjects: UIViewController { return btn }() + lazy var cancelChangesButton: UIButton = { + let btn = UIButton(type: .system) + btn.translatesAutoresizingMaskIntoConstraints = false + btn.setTitle("Cancel Changes", for: .normal) + btn.setTitleColor(.lightGray, for: .normal) + btn.backgroundColor = UIColor(white: 0.15, alpha: 1) + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor + btn.layer.cornerRadius = 4 + btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) + btn.addTarget(self, action: #selector(self.buttonTappedCancelChanges), for: .touchUpInside) + btn.isHidden = true + return btn + }() + lazy var autoSwitchButton: UIButton = { let btn = UIButton(type: .system) btn.translatesAutoresizingMaskIntoConstraints = false @@ -79,18 +145,21 @@ class ArrangeObjects: UIViewController { return btn }() - lazy var lockButton: UIButton = { + lazy var channelFocusButton: UIButton = { let btn = UIButton(type: .system) btn.translatesAutoresizingMaskIntoConstraints = false - btn.setTitle("Lock / Save", for: .normal) - btn.setTitleColor(.black, for: .normal) - btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1) + btn.setTitle("Channel Focus", for: .normal) + btn.setTitleColor(.lightGray, for: .normal) + btn.backgroundColor = UIColor(white: 0.15, alpha: 1) + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor btn.layer.cornerRadius = 4 btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) - btn.addTarget(self, action: #selector(self.buttonTappedLockButton), for: .touchUpInside) + btn.addTarget(self, action: #selector(self.buttonTappedChannelFocus), for: .touchUpInside) return btn }() + // MARK: - Connection Fields let ipField: UITextField = { let tf = UITextField() @@ -157,6 +226,20 @@ class ArrangeObjects: UIViewController { return w }() + lazy var clearLayoutsButton: UIButton = { + let btn = UIButton(type: .system) + btn.translatesAutoresizingMaskIntoConstraints = false + btn.setTitle("Clear Layouts", for: .normal) + btn.setTitleColor(.lightGray, for: .normal) + btn.backgroundColor = UIColor(white: 0.15, alpha: 1) + btn.layer.borderWidth = 1 + btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor + btn.layer.cornerRadius = 4 + btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) + btn.addTarget(self, action: #selector(self.buttonTappedClearLayouts), for: .touchUpInside) + return btn + }() + lazy var showLoggingButton: UIButton = { let btn = UIButton(type: .system) btn.translatesAutoresizingMaskIntoConstraints = false diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-UILayout.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-UILayout.swift index 7a943a4..69155ea 100644 --- a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-UILayout.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-UILayout.swift @@ -5,7 +5,7 @@ extension ArrangeObjects { func setupUI() { DispatchQueue.main.async { - self.view.backgroundColor = UIColor(white: 0.1, alpha: 1) + self.view.backgroundColor = .black // containerView self.view.addSubview(self.containerView) @@ -14,27 +14,38 @@ extension ArrangeObjects { self.containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true self.containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true - // statusLabel - self.view.addSubview(self.statusLabel) - self.statusLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true - self.statusLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true - - // presetLabel - self.view.addSubview(self.presetLabel) - self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true - self.presetLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true + // grid overlay — pinned to containerView, behind all widgets + self.containerView.addSubview(self.gridOverlay) + self.gridOverlay.topAnchor.constraint(equalTo: self.containerView.topAnchor).isActive = true + self.gridOverlay.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor).isActive = true + self.gridOverlay.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor).isActive = true + self.gridOverlay.bottomAnchor.constraint(equalTo: self.containerView.bottomAnchor).isActive = true + self.gridOverlay.isHidden = true // toolbar buttons (top-right) + self.view.addSubview(self.clearLayoutsButton) + self.clearLayoutsButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true + self.clearLayoutsButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true + + // status and preset label — stacked below Clear Layouts + self.view.addSubview(self.statusLabel) + self.statusLabel.topAnchor.constraint(equalTo: self.clearLayoutsButton.bottomAnchor, constant: 6).isActive = true + self.statusLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true + + self.view.addSubview(self.presetLabel) + self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true + self.presetLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true + self.view.addSubview(self.showLoggingButton) self.showLoggingButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true - self.showLoggingButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true + self.showLoggingButton.trailingAnchor.constraint(equalTo: self.clearLayoutsButton.leadingAnchor, constant: -6).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.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) @@ -43,6 +54,9 @@ extension ArrangeObjects { self.tabletIdField.widthAnchor.constraint(equalToConstant: 40).isActive = true self.tabletIdField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true self.tabletIdField.text = "\(self.myTabletId)" + if let lastIp = UserDefaults.standard.string(forKey: "last_ip") { self.ipField.text = lastIp } + if let lastPort = UserDefaults.standard.string(forKey: "last_port") { self.portField.text = lastPort } + self.tabletIdField.addTarget(self, action: #selector(self.tabletIdFieldChanged), for: .editingChanged) self.view.addSubview(self.portField) self.portField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true @@ -56,17 +70,25 @@ extension ArrangeObjects { self.ipField.widthAnchor.constraint(equalToConstant: 120).isActive = true self.ipField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true - self.view.addSubview(self.lockButton) - self.lockButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true - self.lockButton.trailingAnchor.constraint(equalTo: self.ipField.leadingAnchor, constant: -6).isActive = true - self.view.addSubview(self.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.autoSwitchButton.trailingAnchor.constraint(equalTo: self.ipField.leadingAnchor, constant: -6).isActive = true + + self.view.addSubview(self.gridButton) + self.gridButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true + + self.view.addSubview(self.cancelChangesButton) + self.cancelChangesButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).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.autoSwitchButton.leadingAnchor, constant: -6).isActive = true + self.cancelChangesButton.trailingAnchor.constraint(equalTo: self.arrangeButton.leadingAnchor, constant: -6).isActive = true + self.gridButton.trailingAnchor.constraint(equalTo: self.cancelChangesButton.leadingAnchor, constant: -6).isActive = true + + self.view.addSubview(self.channelFocusButton) + self.channelFocusButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true + self.channelFocusButton.trailingAnchor.constraint(equalTo: self.gridButton.leadingAnchor, constant: -6).isActive = true self.containerView.addSubview(self.logWidget) self.addDragGesture(to: self.logWidget) diff --git a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-View.swift b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-View.swift index 2cb81b0..5cb500a 100644 --- a/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-View.swift +++ b/remote-client-ios/remote-client-ios/Core/VC-Arrange/Arrange-View.swift @@ -12,6 +12,7 @@ class ArrangeView: ArrangeObjects { override func viewDidLoad() { super.viewDidLoad() setupUI() + startBonjourDiscovery() } } 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 index 165c0b3..a60b925 100644 --- 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 @@ -28,8 +28,14 @@ class BaseWidget: UIView { } func applyValue(_ value: Int) {} + func applyFloatValue(_ value: Double) {} + func applyFeedbackText(_ text: String) {} func setArrangeMode(_ enabled: Bool) {} + func applyLabel(_ label: String) { + self.displayName = label + } + // MARK: - Activity Ring private func setupActivityRingGesture() { @@ -68,6 +74,40 @@ class BaseWidget: UIView { } +// Conformed to by label-only widgets (TitleWidget, FocusFeedbackWidget) whose +// text can be left/center/right aligned within their own box via the Arrange +// mode long-press menu. +protocol TextAlignableWidget: AnyObject { + var textAlignmentName: String { get set } +} + +// Conformed to by widgets whose text color can be picked from the app's +// named palette via the Arrange mode long-press menu. +protocol TextColorableWidget: AnyObject { + var textColorName: String { get set } +} + +// Mirrors the desktop's palette.py PALETTE list exactly, so a color picked +// here means the same thing it would on the desktop. +enum NamedColorPalette { + static let entries: [(name: String, hex: String)] = [ + ("Red", "#e53935"), + ("Orange", "#fb8c00"), + ("Yellow", "#fdd835"), + ("Green", "#43a047"), + ("Teal", "#00897b"), + ("Blue", "#1e88e5"), + ("Purple", "#8e24aa"), + ("Pink", "#e91e63"), + ("White", "#f5f5f5"), + ("Gray", "#4a5568"), + ] + + static func hex(for name: String) -> String { + entries.first(where: { $0.name == name })?.hex ?? "#f5f5f5" + } +} + extension BaseWidget: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true @@ -87,4 +127,14 @@ extension UIColor { alpha: CGFloat((int >> 24) & 0xFF) / 255 ) } + + /// Scales each RGB channel toward black by `percentage` (0-1). Used for + /// toggle off-states — a dim, tinted version of the widget's own assigned + /// color instead of a flat generic gray. + func darkened(by percentage: CGFloat) -> UIColor { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + guard self.getRed(&r, green: &g, blue: &b, alpha: &a) else { return self } + let factor = 1 - percentage + return UIColor(red: r * factor, green: g * factor, blue: b * factor, alpha: a) + } } diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FaderWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FaderWidget.swift index 048ad5f..237314b 100644 --- a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FaderWidget.swift +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FaderWidget.swift @@ -2,35 +2,62 @@ import UIKit class FaderWidget: BaseWidget { - static let size = CGSize(width: 140, height: 360) + static let size = CGSize(width: 98, height: 540) static func defaultFrame(idx: Int) -> CGRect { - let col = idx % 3 - let row = idx / 3 - return CGRect(x: 16 + CGFloat(col) * 170, y: 48 + CGFloat(row) * 380, width: size.width, height: size.height) + let col = idx % 4 + let row = idx / 4 + return CGRect(x: 16 + CGFloat(col) * 118, y: 90 + CGFloat(row) * 560, width: size.width, height: size.height) } // MARK: - Properties let color: UIColor - private var sliderWidthConstraint: NSLayoutConstraint? + private let padHeight: CGFloat = 90 + + private var arrangeMode = false + private var isRelative = true + private var currentValue: Float = 0 + private var touchStartY: CGFloat = 0 + private var valueAtTouchStart: Float = 0 + private var didInitialLayout = false + private var lastHapticValue: Int = -1 + private var lastSentValue: Int = -1 + + private let haptic = UIImpactFeedbackGenerator(style: .medium) // MARK: - Subviews - lazy var nameLabel: UILabel = { + private let topZone = UIView() + private let botZone = UIView() + private let fillView = UIView() + private let gradLayer = CAGradientLayer() + private let line = UIView() + private let dot = UIView() + private let arrangeCover = UIView() // clear overlay that blocks touches in arrange mode + + private let nameLabel: UILabel = { let lbl = UILabel() - lbl.translatesAutoresizingMaskIntoConstraints = false - lbl.textColor = UIColor(white: 0.53, alpha: 1) - lbl.font = .systemFont(ofSize: 14) lbl.textAlignment = .center + lbl.font = .systemFont(ofSize: 13, weight: .medium) return lbl }() - lazy var slider: UISlider = { - let s = UISlider() - s.translatesAutoresizingMaskIntoConstraints = false - s.minimumValue = 0 - s.maximumValue = 127 - s.transform = CGAffineTransform(rotationAngle: -.pi / 2) - return s + private lazy var modeButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("REL", for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .semibold) + btn.setTitleColor(.white, for: .normal) + btn.backgroundColor = color.withAlphaComponent(0.35) + btn.layer.cornerRadius = 4 + btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside) + return btn + }() + + private let valueLabel: UILabel = { + let lbl = UILabel() + lbl.textAlignment = .center + lbl.font = .monospacedSystemFont(ofSize: 13, weight: .medium) + lbl.textColor = .white + return lbl }() // MARK: - Init @@ -38,51 +65,190 @@ class FaderWidget: BaseWidget { self.color = color super.init(uid: uid, frame: frame) self.displayName = label - self.restingBorderColor = color.cgColor - self.layer.borderColor = color.cgColor + self.restingBorderColor = color.withAlphaComponent(0.3).cgColor + self.layer.borderColor = color.withAlphaComponent(0.3).cgColor + self.currentValue = Float(value) self.nameLabel.text = label - self.slider.value = Float(value) - self.slider.minimumTrackTintColor = color setupUI() } required init?(coder: NSCoder) { fatalError() } - // MARK: - Layout - func setupUI() { - self.addSubview(self.nameLabel) - self.nameLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 8).isActive = true - self.nameLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4).isActive = true - self.nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4).isActive = true + // MARK: - Setup + private func setupUI() { + backgroundColor = UIColor(white: 0.1, alpha: 1) + layer.cornerRadius = 6 + clipsToBounds = true - self.addSubview(self.slider) - self.slider.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true - self.slider.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: 10).isActive = true - let wc = self.slider.widthAnchor.constraint(equalToConstant: 216) - wc.isActive = true - self.sliderWidthConstraint = wc + topZone.backgroundColor = UIColor(white: 0.07, alpha: 1) + botZone.backgroundColor = UIColor(white: 0.07, alpha: 1) + addSubview(topZone) + addSubview(botZone) - self.slider.addTarget(self, action: #selector(self.sliderChanged), for: .valueChanged) + gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor] + gradLayer.startPoint = CGPoint(x: 0.5, y: 1) + gradLayer.endPoint = CGPoint(x: 0.5, y: 0) + fillView.layer.addSublayer(gradLayer) + addSubview(fillView) + + line.backgroundColor = color + line.layer.shadowColor = color.cgColor + line.layer.shadowRadius = 4 + line.layer.shadowOpacity = 0.8 + line.layer.shadowOffset = .zero + addSubview(line) + + dot.backgroundColor = color + dot.layer.cornerRadius = 5 + dot.layer.shadowColor = color.cgColor + dot.layer.shadowRadius = 6 + dot.layer.shadowOpacity = 1 + dot.layer.shadowOffset = .zero + addSubview(dot) + + nameLabel.textColor = UIColor(white: 0.4, alpha: 1) + addSubview(nameLabel) + addSubview(modeButton) + addSubview(valueLabel) + + arrangeCover.backgroundColor = .clear + arrangeCover.isHidden = true + addSubview(arrangeCover) + + haptic.prepare() } + // MARK: - Layout override func layoutSubviews() { super.layoutSubviews() - self.sliderWidthConstraint?.constant = bounds.height * 0.6 + let w = bounds.width + let h = bounds.height + + topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight) + botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight) + line.frame.size = CGSize(width: w, height: 2) + dot.frame.size = CGSize(width: 10, height: 10) + dot.layer.cornerRadius = 5 + nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight) + modeButton.frame = CGRect(x: w / 2 - 22, y: padHeight / 2 - 16, width: 44, height: 24) + valueLabel.frame = CGRect(x: 0, y: padHeight / 2 + 12, width: w, height: 18) + arrangeCover.frame = bounds + + topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() } + botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() } + addDash(to: topZone, atBottom: true) + addDash(to: botZone, atBottom: false) + + if !didInitialLayout { + didInitialLayout = true + updateDisplay() + } } - // MARK: - Update - override func applyValue(_ value: Int) { - self.slider.value = Float(value) + private func addDash(to zone: UIView, atBottom: Bool) { + let dash = CAShapeLayer() + let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0 + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: zone.bounds.width, y: y)) + dash.path = path.cgPath + dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor + dash.lineWidth = 1 + dash.lineDashPattern = [6, 4] + zone.layer.addSublayer(dash) } // MARK: - Arrange Mode override func setArrangeMode(_ enabled: Bool) { - self.slider.isUserInteractionEnabled = (enabled == false) + arrangeMode = enabled + arrangeCover.isHidden = !enabled } - // MARK: - Action - @objc private func sliderChanged() { - self.onSend?(self.uid, Int(self.slider.value)) + // MARK: - Mode Toggle + @objc private func toggleMode() { + isRelative.toggle() + modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal) + modeButton.backgroundColor = isRelative + ? color.withAlphaComponent(0.35) + : color.withAlphaComponent(0.15) } + // MARK: - Touch + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard !arrangeMode, let pt = touches.first?.location(in: self) else { return } + touchStartY = pt.y + valueAtTouchStart = currentValue + move(to: pt) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard !arrangeMode, let pt = touches.first?.location(in: self) else { return } + move(to: pt) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) {} + override func touchesCancelled(_ touches: Set, with event: UIEvent?) {} + + private func move(to pt: CGPoint) { + let activeTop = padHeight + let activeBottom = bounds.height - padHeight + let activeHeight = activeBottom - activeTop + + let value: Float + if isRelative { + let deltaY = pt.y - touchStartY + let deltaPct = Float(-deltaY / activeHeight) + value = max(0, min(127, valueAtTouchStart + deltaPct * 127)) + } else { + let clampedY = max(activeTop, min(activeBottom, pt.y)) + value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127))) + } + + currentValue = value + updateDisplay() + + let intValue = Int(value) + if (intValue == 0 || intValue == 127) && intValue != lastHapticValue { + haptic.impactOccurred() + lastHapticValue = intValue + } else if intValue != 0 && intValue != 127 { + lastHapticValue = -1 + } + + if intValue != lastSentValue { + lastSentValue = intValue + onSend?(uid, intValue) + } + } + + // MARK: - External value + override func applyValue(_ value: Int) { + currentValue = Float(value) + updateDisplay() + } + + override func applyLabel(_ label: String) { + super.applyLabel(label) + self.nameLabel.text = label + } + + // MARK: - Display + private func updateDisplay() { + let activeTop = padHeight + let activeBottom = bounds.height - padHeight + let activeHeight = activeBottom - activeTop + let w = bounds.width + let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight + + let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY) + CATransaction.begin() + CATransaction.setDisableActions(true) + fillView.frame = fillFrame + gradLayer.frame = fillView.bounds + CATransaction.commit() + + line.frame.origin = CGPoint(x: 0, y: displayY - 1) + dot.center = CGPoint(x: w / 2, y: displayY) + valueLabel.text = "\(Int(currentValue))" + } } diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusFaderWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusFaderWidget.swift new file mode 100644 index 0000000..70baa6c --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusFaderWidget.swift @@ -0,0 +1,308 @@ +import UIKit + +// Dedicated fader for the Channel Focus overlay — OSC-native, so drags send a +// continuous 0.0-1.0 Double (onSendFloat) instead of the 0-127 Int the shared +// FaderWidget sends for MIDI-CC-bound preset faders. Kept as its own class, +// not a flag on FaderWidget, so the two never interfere with each other. +class FocusFaderWidget: BaseWidget { + + static let size = CGSize(width: 98, height: 540) + + static func defaultFrame(idx: Int) -> CGRect { + let col = idx % 4 + let row = idx / 4 + return CGRect(x: 16 + CGFloat(col) * 118, y: 90 + CGFloat(row) * 560, width: size.width, height: size.height) + } + + // MARK: - Properties + let color: UIColor + private let padHeight: CGFloat = 90 + + var onSendFloat: ((String, Double) -> Void)? + + private var arrangeMode = false + private var isRelative = true + private var isFine = false + private var currentValue: Float = 0 + private var touchStartY: CGFloat = 0 + private var valueAtTouchStart: Float = 0 + private var didInitialLayout = false + private var lastHapticValue: Int = -1 + private var lastSentFloatValue: Double = -1 + + private let haptic = UIImpactFeedbackGenerator(style: .medium) + + // MARK: - Subviews + private let topZone = UIView() + private let botZone = UIView() + private let fillView = UIView() + private let gradLayer = CAGradientLayer() + private let line = UIView() + private let dot = UIView() + private let arrangeCover = UIView() // clear overlay that blocks touches in arrange mode + + private let nameLabel: UILabel = { + let lbl = UILabel() + lbl.textAlignment = .center + lbl.font = .systemFont(ofSize: 13, weight: .medium) + return lbl + }() + + private lazy var modeButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("REL", for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold) + btn.layer.cornerRadius = 5 + btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside) + return btn + }() + + // 10x reduced drag sensitivity for precise automation nudges — overrides + // REL/ABS while engaged (fine adjustment always behaves as a relative nudge). + private lazy var fineButton: UIButton = { + let btn = UIButton(type: .system) + btn.setTitle("FINE", for: .normal) + btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold) + btn.layer.cornerRadius = 5 + btn.addTarget(self, action: #selector(toggleFine), for: .touchUpInside) + return btn + }() + + // Output value readout — commented out of the visible layout per request; + // still updated internally in case it's wanted back later. + private let valueLabel: UILabel = { + let lbl = UILabel() + lbl.textAlignment = .center + lbl.font = .monospacedSystemFont(ofSize: 13, weight: .medium) + lbl.textColor = .white + lbl.isHidden = true + return lbl + }() + + // MARK: - Init + 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.withAlphaComponent(0.3).cgColor + self.layer.borderColor = color.withAlphaComponent(0.3).cgColor + self.currentValue = Float(value) + self.nameLabel.text = label + setupUI() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + private func setupUI() { + backgroundColor = UIColor(white: 0.1, alpha: 1) + layer.cornerRadius = 6 + clipsToBounds = true + + topZone.backgroundColor = UIColor(white: 0.07, alpha: 1) + botZone.backgroundColor = UIColor(white: 0.07, alpha: 1) + addSubview(topZone) + addSubview(botZone) + + gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor] + gradLayer.startPoint = CGPoint(x: 0.5, y: 1) + gradLayer.endPoint = CGPoint(x: 0.5, y: 0) + fillView.layer.addSublayer(gradLayer) + addSubview(fillView) + + line.backgroundColor = color + line.layer.shadowColor = color.cgColor + line.layer.shadowRadius = 4 + line.layer.shadowOpacity = 0.8 + line.layer.shadowOffset = .zero + addSubview(line) + + dot.backgroundColor = color + dot.layer.cornerRadius = 5 + dot.layer.shadowColor = color.cgColor + dot.layer.shadowRadius = 6 + dot.layer.shadowOpacity = 1 + dot.layer.shadowOffset = .zero + addSubview(dot) + + nameLabel.textColor = UIColor(white: 0.4, alpha: 1) + addSubview(nameLabel) + addSubview(fineButton) + addSubview(modeButton) + addSubview(valueLabel) + + arrangeCover.backgroundColor = .clear + arrangeCover.isHidden = true + addSubview(arrangeCover) + + applyModeButtonStyle() + applyFineButtonStyle() + + haptic.prepare() + } + + // MARK: - Layout + override func layoutSubviews() { + super.layoutSubviews() + let w = bounds.width + let h = bounds.height + + topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight) + botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight) + line.frame.size = CGSize(width: w, height: 2) + dot.frame.size = CGSize(width: 10, height: 10) + dot.layer.cornerRadius = 5 + nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight) + + // Buttons fill the whole top dead zone now that valueLabel is hidden — + // bigger targets, easier to tap. + let btnMargin: CGFloat = 6 + let btnGap: CGFloat = 4 + let btnWidth = w - btnMargin * 2 + let btnHeight = (padHeight - btnMargin * 2 - btnGap) / 2 + fineButton.frame = CGRect(x: btnMargin, y: btnMargin, width: btnWidth, height: btnHeight) + modeButton.frame = CGRect(x: btnMargin, y: fineButton.frame.maxY + btnGap, width: btnWidth, height: btnHeight) + + valueLabel.frame = CGRect(x: 0, y: padHeight / 2 + 12, width: w, height: 18) + arrangeCover.frame = bounds + + topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() } + botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() } + addDash(to: topZone, atBottom: true) + addDash(to: botZone, atBottom: false) + + if !didInitialLayout { + didInitialLayout = true + updateDisplay() + } + } + + private func addDash(to zone: UIView, atBottom: Bool) { + let dash = CAShapeLayer() + let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0 + let path = UIBezierPath() + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: zone.bounds.width, y: y)) + dash.path = path.cgPath + dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor + dash.lineWidth = 1 + dash.lineDashPattern = [6, 4] + zone.layer.addSublayer(dash) + } + + // MARK: - Arrange Mode + override func setArrangeMode(_ enabled: Bool) { + arrangeMode = enabled + arrangeCover.isHidden = !enabled + } + + // MARK: - Mode Toggle + @objc private func toggleMode() { + isRelative.toggle() + modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal) + applyModeButtonStyle() + } + + private func applyModeButtonStyle() { + // Unmistakable on/off: solid color fill vs. dim gray, same convention + // as the rest of the app's toggle buttons — not just an alpha shift. + modeButton.backgroundColor = isRelative ? color : UIColor(white: 0.15, alpha: 1) + modeButton.setTitleColor(isRelative ? .black : .lightGray, for: .normal) + } + + @objc private func toggleFine() { + isFine.toggle() + applyFineButtonStyle() + } + + private func applyFineButtonStyle() { + fineButton.backgroundColor = isFine ? color : UIColor(white: 0.15, alpha: 1) + fineButton.setTitleColor(isFine ? .black : .lightGray, for: .normal) + } + + // MARK: - Touch + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard !arrangeMode, let pt = touches.first?.location(in: self) else { return } + touchStartY = pt.y + valueAtTouchStart = currentValue + move(to: pt) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard !arrangeMode, let pt = touches.first?.location(in: self) else { return } + move(to: pt) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) {} + override func touchesCancelled(_ touches: Set, with event: UIEvent?) {} + + private func move(to pt: CGPoint) { + let activeTop = padHeight + let activeBottom = bounds.height - padHeight + let activeHeight = activeBottom - activeTop + + let value: Float + if isFine { + // Fine always behaves as a relative nudge, at 1/10th sensitivity, + // regardless of the REL/ABS setting — for precise automation moves. + let deltaY = pt.y - touchStartY + let deltaPct = Float(-deltaY / activeHeight) + value = max(0, min(127, valueAtTouchStart + deltaPct * 127 * 0.1)) + } else if isRelative { + let deltaY = pt.y - touchStartY + let deltaPct = Float(-deltaY / activeHeight) + value = max(0, min(127, valueAtTouchStart + deltaPct * 127)) + } else { + let clampedY = max(activeTop, min(activeBottom, pt.y)) + value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127))) + } + + currentValue = value + updateDisplay() + + let intValue = Int(value) + if (intValue == 0 || intValue == 127) && intValue != lastHapticValue { + haptic.impactOccurred() + lastHapticValue = intValue + } else if intValue != 0 && intValue != 127 { + lastHapticValue = -1 + } + + let normalized = Double(value) / 127.0 + if lastSentFloatValue < 0 || abs(normalized - lastSentFloatValue) > 0.0005 { + lastSentFloatValue = normalized + onSendFloat?(uid, normalized) + } + } + + // MARK: - External value + override func applyFloatValue(_ value: Double) { + currentValue = Float(value * 127) + updateDisplay() + } + + override func applyLabel(_ label: String) { + super.applyLabel(label) + self.nameLabel.text = label + } + + // MARK: - Display + private func updateDisplay() { + let activeTop = padHeight + let activeBottom = bounds.height - padHeight + let activeHeight = activeBottom - activeTop + let w = bounds.width + let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight + + let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY) + CATransaction.begin() + CATransaction.setDisableActions(true) + fillView.frame = fillFrame + gradLayer.frame = fillView.bounds + CATransaction.commit() + + line.frame.origin = CGPoint(x: 0, y: displayY - 1) + dot.center = CGPoint(x: w / 2, y: displayY) + valueLabel.text = String(format: "%.3f", currentValue / 127) + } +} diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusFeedbackWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusFeedbackWidget.swift new file mode 100644 index 0000000..bb00a12 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusFeedbackWidget.swift @@ -0,0 +1,130 @@ +import UIKit + +// Read-only OSC feedback display for Channel Focus — shows whatever text the +// desktop last learned/received on this widget's bound address (e.g. track +// name, bar, time). A plain large label, same spirit as TitleWidget: no +// background, no border, just text — and always sized to exactly fit that +// text, no baked-in minimum box. Pinch adjusts font size directly (not an +// independent bounds size), so the frame is always a tight fit and aspect +// ratio can't drift out of sync with the text. +class FocusFeedbackWidget: BaseWidget, TextAlignableWidget, TextColorableWidget { + + private static let padding: CGFloat = 8 + static let defaultFontSize: CGFloat = 32 + static let minFontSize: CGFloat = 12 + + static func defaultFrame(idx: Int, text: String = "—") -> CGRect { + let size = fittedSize(for: text, fontSize: defaultFontSize) + let col = idx % 3 + let row = idx / 3 + return CGRect(x: 16 + CGFloat(col) * (size.width + 20), y: 640 + CGFloat(row) * (size.height + 12), + width: size.width, height: size.height) + } + + private static func fittedSize(for text: String, fontSize: CGFloat) -> CGSize { + let font = UIFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold) + let textSize = (text.isEmpty ? "—" : text as NSString).size(withAttributes: [.font: font]) + return CGSize(width: ceil(textSize.width) + padding * 2, height: ceil(textSize.height) + padding * 2) + } + + // MARK: - Properties + private(set) var fontSize: CGFloat = defaultFontSize + + // MARK: - Subviews + private let textLabel: UILabel = { + let lbl = UILabel() + lbl.translatesAutoresizingMaskIntoConstraints = false + lbl.textColor = UIColor(white: 0.92, alpha: 1) + lbl.textAlignment = .center + lbl.numberOfLines = 1 + return lbl + }() + + // MARK: - Init + init(uid: String, label: String, text: String, frame: CGRect) { + super.init(uid: uid, frame: frame) + self.displayName = label + self.backgroundColor = .clear + self.restingBorderColor = UIColor.clear.cgColor + self.layer.borderColor = UIColor.clear.cgColor + self.textLabel.text = text + + // frame may be a fresh default (already sized for defaultFontSize) or + // a restored, possibly pinch-resized, saved layout frame — derive the + // font size that actually matches whichever one we were given, so + // resizeToFitText() converges instead of snapping back to default. + let defaultHeight = Self.fittedSize(for: text, fontSize: Self.defaultFontSize).height + if defaultHeight > 0 { + let ratio = frame.height / defaultHeight + self.fontSize = max(Self.minFontSize, Self.defaultFontSize * ratio) + } + self.textLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold) + setupUI() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Layout + private func setupUI() { + addSubview(textLabel) + textLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding).isActive = true + textLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.padding).isActive = true + textLabel.topAnchor.constraint(equalTo: topAnchor, constant: Self.padding).isActive = true + textLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.padding).isActive = true + } + + // MARK: - Arrange Mode + override func setArrangeMode(_ enabled: Bool) {} + + // MARK: - Sizing + /// Sets the font size and re-fits the frame around the current text, + /// keeping the widget's center fixed. + func setFontSize(_ size: CGFloat) { + fontSize = max(Self.minFontSize, size) + textLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold) + resizeToFitText() + } + + private func resizeToFitText() { + let text = textLabel.text ?? "—" + let newSize = Self.fittedSize(for: text, fontSize: fontSize) + let center = self.center + self.bounds.size = newSize + self.center = center + } + + // MARK: - External updates + override func applyFeedbackText(_ text: String) { + textLabel.text = text + resizeToFitText() + } + + // MARK: - Alignment + var textAlignmentName: String { + get { + switch textLabel.textAlignment { + case .left: return "left" + case .right: return "right" + default: return "center" + } + } + set { + switch newValue { + case "left": textLabel.textAlignment = .left + case "right": textLabel.textAlignment = .right + default: textLabel.textAlignment = .center + } + } + } + + // MARK: - Text Color + private var _textColorName: String = "White" + + var textColorName: String { + get { _textColorName } + set { + _textColorName = newValue + textLabel.textColor = UIColor.fromHex(NamedColorPalette.hex(for: newValue)) + } + } +} diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusToggleWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusToggleWidget.swift new file mode 100644 index 0000000..d2c793e --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/FocusToggleWidget.swift @@ -0,0 +1,127 @@ +import UIKit + +// Dedicated toggle for the Channel Focus overlay — kept as its own class, +// not shared with the regular ToggleWidget, so Channel-Focus-only behavior +// (momentary triggers, color/darken/ring tuning) never affects the plain +// preset toggles that ToggleWidget still serves. +class FocusToggleWidget: BaseWidget { + + static let size = CGSize(width: 75, height: 75) + + static func defaultFrame(idx: Int) -> CGRect { + let gap: CGFloat = 12 + let col = idx % 6 + let row = idx / 6 + return CGRect(x: 16 + CGFloat(col) * (size.width + gap), + y: 420 + CGFloat(row) * (size.height + gap), + width: size.width, height: size.height) + } + + // MARK: - Properties + let color: UIColor + // The desktop's actual palette name for `color` (e.g. "Gray") — tells us + // definitively when the heavy off-state darken/ring treatment should be + // skipped, rather than guessing from the resolved color's saturation + // (which can't tell a true neutral gray apart from white; both read as + // ~0 saturation). + let colorName: String + var isOn: Bool + // When true, the button's own UI feedback on tap shouldn't persist — + // there's no real "on" state for a momentary action to reflect (e.g. Next + // Channel), so only actual feedback from the desktop (applyValue, via a + // widget_update broadcast) should ever light the background. + let isMomentary: Bool + + // MARK: - Subviews + lazy var button: UIButton = { + let btn = UIButton(type: .system) + btn.translatesAutoresizingMaskIntoConstraints = false + btn.layer.cornerRadius = 4 + btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold) + return btn + }() + + // MARK: - Init + init(uid: String, label: String, isOn: Bool, color: UIColor, colorName: String, isMomentary: Bool = false, frame: CGRect) { + self.color = color + self.colorName = colorName + self.isOn = isOn + self.isMomentary = isMomentary + super.init(uid: uid, frame: frame) + self.displayName = label + // Blends the margin between the button and the border into the + // app's black background, instead of BaseWidget's shared gray default. + self.backgroundColor = .black + self.button.setTitle(label, for: .normal) + setupUI() + applyState() + } + + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Layout + func setupUI() { + self.addSubview(self.button) + self.button.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true + self.button.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true + self.button.widthAnchor.constraint(equalToConstant: 63).isActive = true + self.button.heightAnchor.constraint(equalToConstant: 63).isActive = true + + self.button.addTarget(self, action: #selector(self.buttonTappedToggleButton), for: .touchUpInside) + } + + // MARK: - State + func applyState() { + if self.colorName == "Gray" { + // Gray borrows ToggleWidget's plain scheme exactly: flat off-fill, + // and a border that stays the same static neutral gray regardless + // of on/off — Gray has no real "color" of its own to dim or ring. + self.button.backgroundColor = self.isOn ? self.color : UIColor(white: 0.15, alpha: 1) + self.restingBorderColor = UIColor(white: 0.27, alpha: 1).cgColor + } else if self.colorName == "White" { + // White has no hue either, so the 0.8 darken used for saturated + // colors crushes it down to the same dark gray as everything + // else. Keep its off-state noticeably brighter so it still + // reads as "dim white" instead of "generic dark gray." + self.button.backgroundColor = self.isOn ? self.color : UIColor(white: 0.45, alpha: 1) + self.restingBorderColor = UIColor(white: 0.45, alpha: 1).cgColor + } else { + self.button.backgroundColor = self.isOn ? self.color : self.color.darkened(by: 0.8) + let offBorderColor = self.color.withAlphaComponent(0.675) + self.restingBorderColor = self.isOn ? self.color.cgColor : offBorderColor.cgColor + } + self.button.setTitleColor(self.isOn ? .black : .lightGray, for: .normal) + self.layer.borderColor = self.restingBorderColor + } + + override func applyValue(_ value: Int) { + self.isOn = value > 63 + applyState() + } + + override func applyLabel(_ label: String) { + super.applyLabel(label) + self.button.setTitle(label, for: .normal) + } + + // MARK: - Arrange Mode + override func setArrangeMode(_ enabled: Bool) { + self.button.isUserInteractionEnabled = (enabled == false) + } + + // MARK: - Action + @objc private func buttonTappedToggleButton() { + if isMomentary { + // Just send the trigger — don't touch isOn/applyState locally. + // UIButton's own built-in touch-down highlight is the only visual + // feedback a momentary tap gets; the persistent background is + // reserved for real feedback-confirmed state. + self.onSend?(self.uid, 127) + return + } + self.isOn = !self.isOn + applyState() + self.onSend?(self.uid, self.isOn ? 127 : 0) + } + +} diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/GridView.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/GridView.swift new file mode 100644 index 0000000..6cbcba4 --- /dev/null +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/GridView.swift @@ -0,0 +1,35 @@ +import UIKit + +class GridView: UIView { + + var gridSize: CGFloat = 25 { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { fatalError() } + + override func draw(_ rect: CGRect) { + guard let ctx = UIGraphicsGetCurrentContext() else { return } + ctx.setStrokeColor(UIColor(white: 1, alpha: 0.315).cgColor) + ctx.setLineWidth(0.5) + var x: CGFloat = 0 + while x <= rect.width { + ctx.move(to: CGPoint(x: x, y: 0)) + ctx.addLine(to: CGPoint(x: x, y: rect.height)) + x += gridSize + } + var y: CGFloat = 0 + while y <= rect.height { + ctx.move(to: CGPoint(x: 0, y: y)) + ctx.addLine(to: CGPoint(x: rect.width, y: y)) + y += gridSize + } + ctx.strokePath() + } +} diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TitleWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TitleWidget.swift index f5cb7ab..39801d2 100644 --- a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TitleWidget.swift +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TitleWidget.swift @@ -1,6 +1,6 @@ import UIKit -class TitleWidget: BaseWidget { +class TitleWidget: BaseWidget, TextAlignableWidget { static func defaultFrame(idx: Int) -> CGRect { let col = idx % 3 @@ -20,8 +20,14 @@ class TitleWidget: BaseWidget { // MARK: - Init init(uid: String, text: String, frame: CGRect) { - super.init(uid: uid, frame: frame) + let textWidth = ceil((text as NSString).size(withAttributes: [ + .font: UIFont.systemFont(ofSize: 22, weight: .semibold) + ]).width) + 8 + var tightFrame = frame + tightFrame.size.width = textWidth + super.init(uid: uid, frame: tightFrame) self.backgroundColor = .clear + self.restingBorderColor = UIColor.clear.cgColor self.layer.borderColor = UIColor.clear.cgColor self.titleLabel.text = text setupUI() @@ -37,4 +43,22 @@ class TitleWidget: BaseWidget { self.titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true } + // MARK: - Alignment + var textAlignmentName: String { + get { + switch titleLabel.textAlignment { + case .center: return "center" + case .right: return "right" + default: return "left" + } + } + set { + switch newValue { + case "center": titleLabel.textAlignment = .center + case "right": titleLabel.textAlignment = .right + default: titleLabel.textAlignment = .left + } + } + } + } diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/ToggleWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/ToggleWidget.swift index 187978a..d3290c9 100644 --- a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/ToggleWidget.swift +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/ToggleWidget.swift @@ -62,6 +62,11 @@ class ToggleWidget: BaseWidget { applyState() } + override func applyLabel(_ label: String) { + super.applyLabel(label) + self.button.setTitle(label, for: .normal) + } + // MARK: - Arrange Mode override func setArrangeMode(_ enabled: Bool) { self.button.isUserInteractionEnabled = (enabled == false) diff --git a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TransportWidget.swift b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TransportWidget.swift index dcc7d55..d401102 100644 --- a/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TransportWidget.swift +++ b/remote-client-ios/remote-client-ios/Core/Widgets-Reusable-UI/TransportWidget.swift @@ -13,6 +13,9 @@ class TransportWidget: BaseWidget { width: size.width, height: size.height) } + // MARK: - Properties + let color: UIColor + // MARK: - Subviews lazy var button: UIButton = { let btn = UIButton(type: .system) @@ -25,7 +28,8 @@ class TransportWidget: BaseWidget { }() // MARK: - Init - init(uid: String, label: String, frame: CGRect) { + init(uid: String, label: String, color: UIColor, frame: CGRect) { + self.color = color super.init(uid: uid, frame: frame) self.displayName = label self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor @@ -54,6 +58,18 @@ class TransportWidget: BaseWidget { // MARK: - Action @objc private func buttonTappedTransportButton() { self.onSend?(self.uid, 127) + flashBackground() + } + + private func flashBackground() { + let restColor = UIColor(white: 0.15, alpha: 1) + UIView.animate(withDuration: 0.015, animations: { + self.button.backgroundColor = self.color + }, completion: { _ in + UIView.animate(withDuration: 0.015) { + self.button.backgroundColor = restColor + } + }) } } diff --git a/remote-client-ios/remote-client-ios/Info.plist b/remote-client-ios/remote-client-ios/Info.plist index dd3c9af..ae70e7c 100644 --- a/remote-client-ios/remote-client-ios/Info.plist +++ b/remote-client-ios/remote-client-ios/Info.plist @@ -2,6 +2,12 @@ + NSLocalNetworkUsageDescription + Used to discover the Virtual Controller desktop app on your local network. + NSBonjourServices + + _vcremote._tcp. + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/requirements.txt b/requirements.txt index 850691c..d0263bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ PyQt6_sip==13.11.1 python-osc==1.10.2 python-rtmidi==1.5.8 uv==0.11.21 +zeroconf==0.150.0 diff --git a/server/ws_server.py b/server/ws_server.py index 526024f..0eb16bc 100644 --- a/server/ws_server.py +++ b/server/ws_server.py @@ -6,6 +6,7 @@ from PyQt6.QtNetwork import QHostAddress class WSServer(QObject): control_received = pyqtSignal(str, int) + control_f_received = pyqtSignal(str, float) layout_saved = pyqtSignal(str, object) widget_visibility_received = pyqtSignal(str, int) client_connected = pyqtSignal() @@ -48,6 +49,11 @@ class WSServer(QObject): val = int(data["value"]) self.log_signal.emit(f"control uid={uid[:8]}… val={val}") self.control_received.emit(uid, val) + elif ev == "control_f": + uid = data["uid"] + val = float(data["value"]) + self.log_signal.emit(f"control_f uid={uid[:8]}… val={val:.4f}") + self.control_f_received.emit(uid, val) elif ev == "save_layout": n = len(data.get("layout", {})) self.log_signal.emit(f"layout saved preset={data['preset_uuid'][:8]}… {n} widgets") @@ -82,6 +88,15 @@ class WSServer(QObject): def broadcast_widget_update(self, uid: str, value: int): self.broadcast({"event": "widget_update", "uid": uid, "value": value}) + def broadcast_widget_update_f(self, uid: str, value: float): + self.broadcast({"event": "widget_update_f", "uid": uid, "value": value}) + + def broadcast_widget_label(self, uid: str, label: str): + self.broadcast({"event": "widget_label", "uid": uid, "label": label}) + + def broadcast_widget_feedback(self, uid: str, text: str): + self.broadcast({"event": "widget_feedback", "uid": uid, "text": text}) + def broadcast_daw_state(self, **kwargs): self.broadcast({"event": "daw_state", **kwargs})