Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 583ed77a67 | |||
| 0c7b6b4442 |
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git add *)"
|
"Bash(git add *)",
|
||||||
|
"Bash(python3 -c \"import ast; ast.parse\\(open\\('main.py'\\).read\\(\\)\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-10
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QSlider,
|
QWidget, QVBoxLayout, QHBoxLayout, QSlider,
|
||||||
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy,
|
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy,
|
||||||
QPushButton, QCheckBox
|
QPushButton, QCheckBox, QComboBox
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QEvent, QTimer
|
from PyQt6.QtCore import Qt, QEvent, QTimer
|
||||||
|
|
||||||
@@ -55,6 +55,15 @@ class FaderWidget(QWidget):
|
|||||||
self.hw_channel = None
|
self.hw_channel = None
|
||||||
self.is_learning = False
|
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; }"
|
_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)
|
# Group 1: Learn + input readout (border always visible; learn_btn hideable)
|
||||||
@@ -77,6 +86,12 @@ class FaderWidget(QWidget):
|
|||||||
self.cc_input_lbl.setFixedHeight(16)
|
self.cc_input_lbl.setFixedHeight(16)
|
||||||
_grp1_layout.addWidget(self.cc_input_lbl)
|
_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)
|
inner.addWidget(self.grp_learn)
|
||||||
|
|
||||||
_arrow_lbl = QLabel("▼")
|
_arrow_lbl = QLabel("▼")
|
||||||
@@ -130,7 +145,25 @@ class FaderWidget(QWidget):
|
|||||||
|
|
||||||
inner.addWidget(self.grp_cc)
|
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
|
# Fader
|
||||||
self.fader = QSlider(Qt.Orientation.Vertical)
|
self.fader = QSlider(Qt.Orientation.Vertical)
|
||||||
@@ -155,15 +188,6 @@ class FaderWidget(QWidget):
|
|||||||
self.pickup_checkbox.stateChanged.connect(self.on_pickup_changed)
|
self.pickup_checkbox.stateChanged.connect(self.on_pickup_changed)
|
||||||
inner.addWidget(self.pickup_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
|
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
|
# Scribble strip
|
||||||
self.title_edit = QLineEdit(label)
|
self.title_edit = QLineEdit(label)
|
||||||
self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||||
@@ -432,6 +456,10 @@ class FaderWidget(QWidget):
|
|||||||
self.hw_channel = channel
|
self.hw_channel = channel
|
||||||
self.stop_learn()
|
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):
|
def get_state(self):
|
||||||
return {
|
return {
|
||||||
"uid": self.uid,
|
"uid": self.uid,
|
||||||
@@ -444,9 +472,11 @@ class FaderWidget(QWidget):
|
|||||||
"last_sent": self.last_sent,
|
"last_sent": self.last_sent,
|
||||||
"hw_cc": self.hw_cc,
|
"hw_cc": self.hw_cc,
|
||||||
"hw_channel": self.hw_channel,
|
"hw_channel": self.hw_channel,
|
||||||
|
"touch_sense_mode": self.touch_sense_mode,
|
||||||
"zone": self.zone,
|
"zone": self.zone,
|
||||||
"center_index": self.center_index,
|
"center_index": self.center_index,
|
||||||
"zone_index": self.zone_index,
|
"zone_index": self.zone_index,
|
||||||
|
"remote": self.remote_checkbox.isChecked(),
|
||||||
"dest_id": self.dest_spin.value(),
|
"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.zone_btn.set_state(self.zone == "left", self.zone == "right")
|
||||||
self.hw_cc = state.get("hw_cc", None)
|
self.hw_cc = state.get("hw_cc", None)
|
||||||
self.hw_channel = state.get("hw_channel", 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.blockSignals(True)
|
||||||
self.dest_spin.setValue(state.get("dest_id", 1))
|
self.dest_spin.setValue(state.get("dest_id", 1))
|
||||||
self.dest_spin.blockSignals(False)
|
self.dest_spin.blockSignals(False)
|
||||||
|
self.dest_spin.setEnabled(remote)
|
||||||
if self.hw_cc is not None:
|
if self.hw_cc is not None:
|
||||||
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
||||||
self.cc_input_lbl.setText(self._cc_input_label())
|
self.cc_input_lbl.setText(self._cc_input_label())
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+805
-24
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ class MidiReceiver(QObject):
|
|||||||
midi_out_signal = pyqtSignal(list)
|
midi_out_signal = pyqtSignal(list)
|
||||||
transport_out_signal = pyqtSignal()
|
transport_out_signal = pyqtSignal()
|
||||||
hw_cc_signal = pyqtSignal(str, int)
|
hw_cc_signal = pyqtSignal(str, int)
|
||||||
|
controller_in_signal = pyqtSignal(list)
|
||||||
|
|
||||||
midi_receiver = MidiReceiver()
|
midi_receiver = MidiReceiver()
|
||||||
|
|
||||||
|
|||||||
+26
-9
@@ -137,6 +137,26 @@ class ToggleWidget(QWidget):
|
|||||||
|
|
||||||
inner.addWidget(self.grp_cc)
|
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
|
# Toggle button with LED dot overlay
|
||||||
btn_container = QWidget()
|
btn_container = QWidget()
|
||||||
btn_container.setFixedSize(60, 60)
|
btn_container.setFixedSize(60, 60)
|
||||||
@@ -180,15 +200,6 @@ class ToggleWidget(QWidget):
|
|||||||
toggle_btn_row.addWidget(self.plus_btn)
|
toggle_btn_row.addWidget(self.plus_btn)
|
||||||
inner.addLayout(toggle_btn_row)
|
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
|
# Zone button
|
||||||
self.zone_btn = ZoneButton()
|
self.zone_btn = ZoneButton()
|
||||||
self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active)
|
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_cc": self.hw_cc,
|
||||||
"hw_channel": self.hw_channel,
|
"hw_channel": self.hw_channel,
|
||||||
"trigger_mode": self.trigger_mode,
|
"trigger_mode": self.trigger_mode,
|
||||||
|
"remote": self.remote_checkbox.isChecked(),
|
||||||
"dest_id": self.dest_spin.value(),
|
"dest_id": self.dest_spin.value(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,9 +416,14 @@ class ToggleWidget(QWidget):
|
|||||||
self.hw_cc = state.get("hw_cc", None)
|
self.hw_cc = state.get("hw_cc", None)
|
||||||
self.hw_channel = state.get("hw_channel", None)
|
self.hw_channel = state.get("hw_channel", None)
|
||||||
self.set_trigger_mode(state.get("trigger_mode", "toggle"))
|
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.blockSignals(True)
|
||||||
self.dest_spin.setValue(state.get("dest_id", 1))
|
self.dest_spin.setValue(state.get("dest_id", 1))
|
||||||
self.dest_spin.blockSignals(False)
|
self.dest_spin.blockSignals(False)
|
||||||
|
self.dest_spin.setEnabled(remote)
|
||||||
if self.hw_cc is not None:
|
if self.hw_cc is not None:
|
||||||
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
||||||
self.cc_input_lbl.setText(self._cc_input_label())
|
self.cc_input_lbl.setText(self._cc_input_label())
|
||||||
|
|||||||
+27
-10
@@ -152,6 +152,26 @@ class TransportWidget(QWidget):
|
|||||||
|
|
||||||
self.output_mode = "midi"
|
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
|
# Transport button with LED dot
|
||||||
btn_container = QWidget()
|
btn_container = QWidget()
|
||||||
btn_container.setFixedSize(60, 60)
|
btn_container.setFixedSize(60, 60)
|
||||||
@@ -195,15 +215,6 @@ class TransportWidget(QWidget):
|
|||||||
btn_row.addWidget(self.plus_btn)
|
btn_row.addWidget(self.plus_btn)
|
||||||
inner.addLayout(btn_row)
|
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
|
# Zone button
|
||||||
self.zone_btn = ZoneButton()
|
self.zone_btn = ZoneButton()
|
||||||
self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active)
|
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,
|
"output_mode": self.output_mode,
|
||||||
"osc_addr": self.osc_addr_edit.text(),
|
"osc_addr": self.osc_addr_edit.text(),
|
||||||
"trigger_mode": self.trigger_mode,
|
"trigger_mode": self.trigger_mode,
|
||||||
|
"remote": self.remote_checkbox.isChecked(),
|
||||||
"dest_id": self.dest_spin.value(),
|
"dest_id": self.dest_spin.value(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +426,11 @@ class TransportWidget(QWidget):
|
|||||||
self.osc_addr_edit.setText(state.get("osc_addr", "/play"))
|
self.osc_addr_edit.setText(state.get("osc_addr", "/play"))
|
||||||
self.set_output_mode(state.get("output_mode", "midi"))
|
self.set_output_mode(state.get("output_mode", "midi"))
|
||||||
self.set_trigger_mode(state.get("trigger_mode", "momentary"))
|
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.blockSignals(True)
|
||||||
self.dest_spin.setValue(state.get("dest_id", 1))
|
self.dest_spin.setValue(state.get("dest_id", 1))
|
||||||
self.dest_spin.blockSignals(False)
|
self.dest_spin.blockSignals(False)
|
||||||
|
self.dest_spin.setEnabled(remote)
|
||||||
@@ -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
|
||||||
+512
-64
@@ -1,55 +1,58 @@
|
|||||||
{
|
{
|
||||||
"__last_used__": "rename whatever",
|
"__last_used__": "preset5",
|
||||||
"presets": {
|
"presets": {
|
||||||
"preset1": {
|
"preset1": {
|
||||||
"faders": [
|
"faders": [
|
||||||
{
|
{
|
||||||
"uid": "c98d2009-d186-489a-8883-ccbd612cb4af",
|
"uid": "c98d2009-d186-489a-8883-ccbd612cb4af",
|
||||||
"label": "Fader 1",
|
"label": "fuckin a",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 69,
|
"value": 117,
|
||||||
"color": "Red",
|
"color": "Red",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 69,
|
"last_sent": 117,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
|
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
|
||||||
"label": "Fader 2",
|
"label": "Fader 2",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 96,
|
"value": 107,
|
||||||
"color": "Gray",
|
"color": "Gray",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 96,
|
"last_sent": 107,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"zone": "right",
|
"zone": "right",
|
||||||
"center_index": 1,
|
"center_index": 1,
|
||||||
"zone_index": -1,
|
"zone_index": -1,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
|
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
|
||||||
"label": "Fader 3",
|
"label": "Fader 3",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 56,
|
"value": 83,
|
||||||
"color": "Orange",
|
"color": "Orange",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 56,
|
"last_sent": 83,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"zone": "left",
|
"zone": "left",
|
||||||
"center_index": 2,
|
"center_index": 2,
|
||||||
"zone_index": -1,
|
"zone_index": -1,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"toggles": [
|
"toggles": [
|
||||||
@@ -66,14 +69,15 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "73962e37-53b3-400f-a7e2-25ef7f1ea583",
|
"uid": "73962e37-53b3-400f-a7e2-25ef7f1ea583",
|
||||||
"label": "Toggle 2",
|
"label": "Toggle 2",
|
||||||
"cc": 20,
|
"cc": 20,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"state": true,
|
"state": false,
|
||||||
"color": "Blue",
|
"color": "Blue",
|
||||||
"zone": "left",
|
"zone": "left",
|
||||||
"center_index": 1,
|
"center_index": 1,
|
||||||
@@ -81,7 +85,8 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "b3cf419a-5183-4272-bd6f-44f858afbec8",
|
"uid": "b3cf419a-5183-4272-bd6f-44f858afbec8",
|
||||||
@@ -96,7 +101,8 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "momentary",
|
"trigger_mode": "momentary",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "c179bfea-deec-416a-b4aa-74e81b5cbe03",
|
"uid": "c179bfea-deec-416a-b4aa-74e81b5cbe03",
|
||||||
@@ -111,14 +117,15 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "a5a50618-fc4c-446e-868e-e4eafa6254e7",
|
"uid": "a5a50618-fc4c-446e-868e-e4eafa6254e7",
|
||||||
"label": "Toggle 6",
|
"label": "Toggle 6",
|
||||||
"cc": 20,
|
"cc": 20,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"state": true,
|
"state": false,
|
||||||
"color": "Gray",
|
"color": "Gray",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
@@ -126,7 +133,8 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "f1775380-3390-4fbb-bd8b-8c30ff18f72a",
|
"uid": "f1775380-3390-4fbb-bd8b-8c30ff18f72a",
|
||||||
@@ -141,7 +149,8 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "2064f2ca-6d18-468b-bf45-9bcb1fa1247d",
|
"uid": "2064f2ca-6d18-468b-bf45-9bcb1fa1247d",
|
||||||
@@ -156,7 +165,8 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
@@ -207,18 +217,44 @@
|
|||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null
|
"hw_channel": null,
|
||||||
|
"trigger_mode": "toggle",
|
||||||
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
"back_cc": 22,
|
"back": {
|
||||||
"fwd_cc": 23
|
"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",
|
"preset_uuid": "30ff84d1-9240-41f6-b499-1d18ef7ab2eb",
|
||||||
"show_hide": {
|
"show_hide": {
|
||||||
"fader": false,
|
"fader": false,
|
||||||
"toggle": 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": {
|
"preset3": {
|
||||||
@@ -237,7 +273,8 @@
|
|||||||
"zone": "left",
|
"zone": "left",
|
||||||
"center_index": 0,
|
"center_index": 0,
|
||||||
"zone_index": -1,
|
"zone_index": -1,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
|
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
|
||||||
@@ -253,23 +290,25 @@
|
|||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
|
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
|
||||||
"label": "Fader 3",
|
"label": "Fader 3",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 93,
|
"value": 48,
|
||||||
"color": "Yellow",
|
"color": "Yellow",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 93,
|
"last_sent": 48,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"zone": "right",
|
"zone": "right",
|
||||||
"center_index": 2,
|
"center_index": 2,
|
||||||
"zone_index": -1,
|
"zone_index": -1,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"toggles": [
|
"toggles": [
|
||||||
@@ -286,7 +325,8 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
@@ -311,7 +351,7 @@
|
|||||||
"preset_uuid": "7483fb5b-f47f-49e4-bccb-e8d20195078c",
|
"preset_uuid": "7483fb5b-f47f-49e4-bccb-e8d20195078c",
|
||||||
"show_hide": {
|
"show_hide": {
|
||||||
"fader": false,
|
"fader": false,
|
||||||
"toggle": false,
|
"toggle": true,
|
||||||
"transport": true,
|
"transport": true,
|
||||||
"browser": true
|
"browser": true
|
||||||
},
|
},
|
||||||
@@ -367,118 +407,132 @@
|
|||||||
"label": "Fader 1",
|
"label": "Fader 1",
|
||||||
"cc": 90,
|
"cc": 90,
|
||||||
"ch": 15,
|
"ch": 15,
|
||||||
"value": 103,
|
"value": 50,
|
||||||
"color": "Gray",
|
"color": "Yellow",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 103,
|
"last_sent": 50,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
|
"touch_sense_mode": "off",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "5aaecf41-3455-4fa2-bef1-ca02f5933a69",
|
"uid": "5aaecf41-3455-4fa2-bef1-ca02f5933a69",
|
||||||
"label": "Fader 2",
|
"label": "VERB",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 89,
|
"value": 57,
|
||||||
"color": "Gray",
|
"color": "Orange",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 89,
|
"last_sent": 57,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
|
"touch_sense_mode": "off",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "e8e8cca8-3bad-49a5-8c91-d3a78d25a1eb",
|
"uid": "e8e8cca8-3bad-49a5-8c91-d3a78d25a1eb",
|
||||||
"label": "Fader 3",
|
"label": "VELOCITY",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 64,
|
"value": 64,
|
||||||
"color": "Gray",
|
"color": "Yellow",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 64,
|
"last_sent": 64,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
|
"touch_sense_mode": "off",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "ae9cd47a-96af-45a4-83e6-816916a65e0e",
|
"uid": "ae9cd47a-96af-45a4-83e6-816916a65e0e",
|
||||||
"label": "Fader 4",
|
"label": "oh shit",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 64,
|
"value": 76,
|
||||||
"color": "Gray",
|
"color": "Gray",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 64,
|
"last_sent": 76,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
|
"touch_sense_mode": "off",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "fee53fde-2f59-4800-8873-3d75ca619366",
|
"uid": "fee53fde-2f59-4800-8873-3d75ca619366",
|
||||||
"label": "Fader 5",
|
"label": "Fader 5",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 64,
|
"value": 0,
|
||||||
"color": "Gray",
|
"color": "Gray",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 64,
|
"last_sent": 0,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
|
"touch_sense_mode": "off",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "1783a98d-16c9-4a06-ae4d-0f6ab36d4421",
|
"uid": "1783a98d-16c9-4a06-ae4d-0f6ab36d4421",
|
||||||
"label": "WOOOOW",
|
"label": "WOOOOW",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 64,
|
"value": 36,
|
||||||
"color": "Gray",
|
"color": "Gray",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 64,
|
"last_sent": 36,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
|
"touch_sense_mode": "off",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"uid": "2a55bdef-83aa-458f-a37d-e12a61e3e6a0",
|
"uid": "2a55bdef-83aa-458f-a37d-e12a61e3e6a0",
|
||||||
"label": "Fader 7",
|
"label": "Fader 7",
|
||||||
"cc": 1,
|
"cc": 1,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"value": 64,
|
"value": 18,
|
||||||
"color": "Gray",
|
"color": "Gray",
|
||||||
"pickup": false,
|
"pickup": false,
|
||||||
"last_sent": 64,
|
"last_sent": 18,
|
||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
|
"touch_sense_mode": "off",
|
||||||
"zone": null,
|
"zone": null,
|
||||||
"center_index": null,
|
"center_index": null,
|
||||||
"zone_index": null,
|
"zone_index": null,
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"toggles": [
|
"toggles": [
|
||||||
{
|
{
|
||||||
"uid": "46f17f40-1bb2-4527-b6c5-fd26e535beed",
|
"uid": "46f17f40-1bb2-4527-b6c5-fd26e535beed",
|
||||||
"label": "Toggle 1",
|
"label": "whatever tap",
|
||||||
"cc": 20,
|
"cc": 20,
|
||||||
"ch": 1,
|
"ch": 1,
|
||||||
"state": false,
|
"state": false,
|
||||||
@@ -489,7 +543,8 @@
|
|||||||
"hw_cc": null,
|
"hw_cc": null,
|
||||||
"hw_channel": null,
|
"hw_channel": null,
|
||||||
"trigger_mode": "toggle",
|
"trigger_mode": "toggle",
|
||||||
"remote": true
|
"remote": true,
|
||||||
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"browser": {
|
"browser": {
|
||||||
@@ -515,7 +570,7 @@
|
|||||||
"show_hide": {
|
"show_hide": {
|
||||||
"fader": false,
|
"fader": false,
|
||||||
"toggle": true,
|
"toggle": true,
|
||||||
"transport": true,
|
"transport": false,
|
||||||
"browser": true
|
"browser": true
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -789,6 +844,7 @@
|
|||||||
"output_mode": "osc",
|
"output_mode": "osc",
|
||||||
"osc_addr": "t/1/play",
|
"osc_addr": "t/1/play",
|
||||||
"trigger_mode": "momentary",
|
"trigger_mode": "momentary",
|
||||||
|
"remote": true,
|
||||||
"dest_id": 1
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -804,6 +860,7 @@
|
|||||||
"output_mode": "osc",
|
"output_mode": "osc",
|
||||||
"osc_addr": "t/1/stop",
|
"osc_addr": "t/1/stop",
|
||||||
"trigger_mode": "momentary",
|
"trigger_mode": "momentary",
|
||||||
|
"remote": true,
|
||||||
"dest_id": 1
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -819,6 +876,7 @@
|
|||||||
"output_mode": "osc",
|
"output_mode": "osc",
|
||||||
"osc_addr": "t/1/record",
|
"osc_addr": "t/1/record",
|
||||||
"trigger_mode": "momentary",
|
"trigger_mode": "momentary",
|
||||||
|
"remote": true,
|
||||||
"dest_id": 1
|
"dest_id": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -834,16 +892,406 @@
|
|||||||
"output_mode": "osc",
|
"output_mode": "osc",
|
||||||
"osc_addr": "t/1/recenable/1/@",
|
"osc_addr": "t/1/recenable/1/@",
|
||||||
"trigger_mode": "momentary",
|
"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
|
"dest_id": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"transport_show_hide": true,
|
"transport_show_hide": false,
|
||||||
"io_config": {
|
"io_config": {
|
||||||
"midi_out": "IAC Driver Bus 5",
|
"midi_out": "Not Connected",
|
||||||
"midi_in": "Not Connected",
|
"midi_in": "Not Connected",
|
||||||
|
"midi_controller_in": "Not Connected",
|
||||||
"transport_out": "Not Connected",
|
"transport_out": "Not Connected",
|
||||||
"osc_ip": "127.0.0.1",
|
"osc_ip": "127.0.0.1",
|
||||||
"osc_listen_port": 9000,
|
"osc_listen_port": 9001,
|
||||||
"osc_send_port": 8000
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
||||||
|
guard let pt = touches.first?.location(in: self) else { return }
|
||||||
|
moveDot(to: pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ struct ModelPresetLoadToRemoteDevice: Codable {
|
|||||||
let faders: [ModelFaderPayload]
|
let faders: [ModelFaderPayload]
|
||||||
let toggles: [ModelTogglePayload]
|
let toggles: [ModelTogglePayload]
|
||||||
let transports: [ModelTransportPayload]
|
let transports: [ModelTransportPayload]
|
||||||
|
let focusFaders: [ModelFocusFaderPayload]
|
||||||
|
let focusToggles: [ModelFocusTogglePayload]
|
||||||
|
let focusFeedbacks: [ModelFocusFeedbackPayload]
|
||||||
|
|
||||||
var displayTitle: String {
|
var displayTitle: String {
|
||||||
bigTitle.isEmpty ? "No Title" : bigTitle
|
bigTitle.isEmpty ? "No Title" : bigTitle
|
||||||
@@ -27,10 +30,42 @@ struct ModelTogglePayload: Codable {
|
|||||||
let state: Bool
|
let state: Bool
|
||||||
let destId: Int
|
let destId: Int
|
||||||
let color: String
|
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 {
|
struct ModelTransportPayload: Codable {
|
||||||
let uid: String
|
let uid: String
|
||||||
let label: String
|
let label: String
|
||||||
let destId: Int
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
-9
@@ -21,10 +21,34 @@ extension ArrangeObjects {
|
|||||||
guard checkerIPAddressInput(ip: ip) else { return }
|
guard checkerIPAddressInput(ip: ip) else { return }
|
||||||
guard checkerPortInput(port: port) else { return }
|
guard checkerPortInput(port: port) else { return }
|
||||||
guard let url = buildWebSocketURL(ip: ip, 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)
|
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() {
|
@objc func buttonTappedShowLogging() {
|
||||||
let visible = (self.logWidget.isHidden == false)
|
let visible = (self.logWidget.isHidden == false)
|
||||||
self.logWidget.isHidden = visible
|
self.logWidget.isHidden = visible
|
||||||
@@ -39,17 +63,69 @@ extension ArrangeObjects {
|
|||||||
loggingView.log("auto switch \(autoSwitchEnabled ? "enabled" : "disabled")", category: .local)
|
loggingView.log("auto switch \(autoSwitchEnabled ? "enabled" : "disabled")", category: .local)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func buttonTappedArrangeButton() {
|
@objc func buttonTappedChannelFocus() {
|
||||||
self.isLocked = false
|
guard currentPreset != nil else {
|
||||||
self.updateUIArrangeLockButtons()
|
loggingView.log("channel focus: no preset received yet", category: .local)
|
||||||
self.widgets.values.forEach { $0.setArrangeMode(true) }
|
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() {
|
@objc func buttonTappedArrangeButton() {
|
||||||
self.isLocked = true
|
isLocked.toggle()
|
||||||
self.updateUIArrangeLockButtons()
|
updateUIArrangeLockButtons()
|
||||||
self.saveLayout()
|
if isLocked {
|
||||||
self.widgets.values.forEach { $0.setArrangeMode(false) }
|
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()) ]")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-3
@@ -1,19 +1,109 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
extension ArrangeObjects: UIGestureRecognizerDelegate {
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension ArrangeObjects {
|
extension ArrangeObjects {
|
||||||
|
|
||||||
// MARK: - Gestures
|
// MARK: - Gestures
|
||||||
|
|
||||||
func addDragGesture(to view: UIView) {
|
func addDragGesture(to view: UIView) {
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGesturePan(_:)))
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGesturePan(_:)))
|
||||||
|
pan.cancelsTouchesInView = false
|
||||||
view.addGestureRecognizer(pan)
|
view.addGestureRecognizer(pan)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleGesturePan(_ gesture: UIPanGestureRecognizer) {
|
@objc func handleGesturePan(_ gesture: UIPanGestureRecognizer) {
|
||||||
guard self.isLocked == false, let view = gesture.view else { return }
|
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)
|
if gesture.state == .began {
|
||||||
gesture.setTranslation(.zero, in: self.containerView)
|
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) {
|
func addContextMenuGesture(to view: UIView) {
|
||||||
@@ -31,6 +121,56 @@ extension ArrangeObjects {
|
|||||||
let alert = UIAlertController(title: widget.displayName, message: nil, preferredStyle: .actionSheet)
|
let alert = UIAlertController(title: widget.displayName, message: nil, preferredStyle: .actionSheet)
|
||||||
alert.overrideUserInterfaceStyle = .dark
|
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
|
alert.addAction(UIAlertAction(title: "Hide", style: .destructive) { [weak self] _ in
|
||||||
self?.hideWidget(widget)
|
self?.hideWidget(widget)
|
||||||
})
|
})
|
||||||
@@ -44,6 +184,28 @@ extension ArrangeObjects {
|
|||||||
self.present(alert, animated: true)
|
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) {
|
func hideWidget(_ widget: BaseWidget) {
|
||||||
widget.removeFromSuperview()
|
widget.removeFromSuperview()
|
||||||
self.widgets.removeValue(forKey: widget.uid)
|
self.widgets.removeValue(forKey: widget.uid)
|
||||||
@@ -51,4 +213,17 @@ extension ArrangeObjects {
|
|||||||
self.wsClientSendJSON(["event": "widget_visibility", "uid": widget.uid, "dest_id": 0])
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-6
@@ -19,13 +19,18 @@ extension ArrangeObjects {
|
|||||||
self.presetUuid = preset.presetUuid
|
self.presetUuid = preset.presetUuid
|
||||||
self.widgets = [:]
|
self.widgets = [:]
|
||||||
self.feedbackWidgets = []
|
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
|
self.presetLabel.text = preset.presetName
|
||||||
|
|
||||||
let titleUid = "title-big"
|
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)
|
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.addDragGesture(to: titleWidget)
|
||||||
|
self.addContextMenuGesture(to: titleWidget)
|
||||||
self.containerView.addSubview(titleWidget)
|
self.containerView.addSubview(titleWidget)
|
||||||
self.widgets[titleUid] = titleWidget
|
self.widgets[titleUid] = titleWidget
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ extension ArrangeObjects {
|
|||||||
frame: frame)
|
frame: frame)
|
||||||
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
||||||
self.addDragGesture(to: w)
|
self.addDragGesture(to: w)
|
||||||
|
// self.addPinchGesture(to: w)
|
||||||
self.addContextMenuGesture(to: w)
|
self.addContextMenuGesture(to: w)
|
||||||
self.containerView.addSubview(w)
|
self.containerView.addSubview(w)
|
||||||
self.widgets[f.uid] = w
|
self.widgets[f.uid] = w
|
||||||
@@ -56,6 +62,7 @@ extension ArrangeObjects {
|
|||||||
frame: frame)
|
frame: frame)
|
||||||
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
||||||
self.addDragGesture(to: w)
|
self.addDragGesture(to: w)
|
||||||
|
self.addPinchGesture(to: w)
|
||||||
self.addContextMenuGesture(to: w)
|
self.addContextMenuGesture(to: w)
|
||||||
self.containerView.addSubview(w)
|
self.containerView.addSubview(w)
|
||||||
self.widgets[t.uid] = 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 frame = savedFrame(uid: t.uid, in: savedLayout) ?? TransportWidget.defaultFrame(idx: squareIdx)
|
||||||
let w = TransportWidget(uid: t.uid,
|
let w = TransportWidget(uid: t.uid,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
|
color: UIColor.fromHex(t.color),
|
||||||
frame: frame)
|
frame: frame)
|
||||||
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
|
||||||
self.addDragGesture(to: w)
|
self.addDragGesture(to: w)
|
||||||
|
self.addPinchGesture(to: w)
|
||||||
self.addContextMenuGesture(to: w)
|
self.addContextMenuGesture(to: w)
|
||||||
self.containerView.addSubview(w)
|
self.containerView.addSubview(w)
|
||||||
self.widgets[t.uid] = w
|
self.widgets[t.uid] = w
|
||||||
@@ -80,24 +89,110 @@ extension ArrangeObjects {
|
|||||||
self.showStatus("[ \(preset.presetName) \(self.statusTimestamp()) ]")
|
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() {
|
func saveLayout() {
|
||||||
guard self.presetUuid.isEmpty == false else {
|
guard let key = currentLayoutKey else {
|
||||||
self.loggingView.log("saveLayout: aborted — presetUuid is empty", category: .local)
|
self.loggingView.log("saveLayout: aborted — presetUuid is empty", category: .local)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
saveLayoutWidgets(key: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLayoutWidgets(key: String) {
|
||||||
var layoutDict: [String: Any] = [:]
|
var layoutDict: [String: Any] = [:]
|
||||||
for (uid, w) in self.widgets {
|
for (uid, w) in self.widgets {
|
||||||
let f = w.frame
|
let f = w.frame
|
||||||
layoutDict[uid] = [
|
var entry: [String: Any] = [
|
||||||
"x": Double(f.origin.x),
|
"x": Double(f.origin.x),
|
||||||
"y": Double(f.origin.y),
|
"y": Double(f.origin.y),
|
||||||
"w": Double(f.width),
|
"w": Double(f.width),
|
||||||
"h": Double(f.height)
|
"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.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),
|
if let data = try? JSONSerialization.data(withJSONObject: layoutDict),
|
||||||
let jsonStr = String(data: data, encoding: .utf8) {
|
let jsonStr = String(data: data, encoding: .utf8) {
|
||||||
self.loggingView.log(jsonStr, category: .local)
|
self.loggingView.log(jsonStr, category: .local)
|
||||||
@@ -112,4 +207,14 @@ extension ArrangeObjects {
|
|||||||
return CGRect(x: x, y: y, width: w, height: h)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -29,21 +29,30 @@ extension ArrangeObjects {
|
|||||||
self.connectButton.backgroundColor = red
|
self.connectButton.backgroundColor = red
|
||||||
self.connectButton.setTitleColor(.white, for: .normal)
|
self.connectButton.setTitleColor(.white, for: .normal)
|
||||||
self.connectButton.isEnabled = true
|
self.connectButton.isEnabled = true
|
||||||
|
stopBonjourDiscovery()
|
||||||
case .disconnected:
|
case .disconnected:
|
||||||
self.statusLabel.text = "disconnected"
|
self.statusLabel.text = "disconnected"
|
||||||
self.connectButton.setTitle("Reconnect", for: .normal)
|
self.connectButton.setTitle("Reconnect", for: .normal)
|
||||||
self.connectButton.backgroundColor = green
|
self.connectButton.backgroundColor = green
|
||||||
self.connectButton.setTitleColor(.black, for: .normal)
|
self.connectButton.setTitleColor(.black, for: .normal)
|
||||||
self.connectButton.isEnabled = true
|
self.connectButton.isEnabled = true
|
||||||
|
startBonjourDiscovery()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIArrangeLockButtons() {
|
func updateUIArrangeLockButtons() {
|
||||||
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
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)
|
arrangeButton.setTitle(isLocked ? "Arrange" : "Lock / Save", for: .normal)
|
||||||
self.lockButton.setTitleColor(self.isLocked ? .black : .lightGray, for: .normal)
|
arrangeButton.backgroundColor = isLocked ? UIColor(white: 0.15, alpha: 1) : green
|
||||||
self.arrangeButton.backgroundColor = self.isLocked ? UIColor(white: 0.15, alpha: 1) : green
|
arrangeButton.setTitleColor(isLocked ? .lightGray : .black, for: .normal)
|
||||||
self.arrangeButton.setTitleColor(self.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]) {
|
func updateUIFromDawState(_ json: [String: Any]) {
|
||||||
@@ -64,6 +73,10 @@ extension ArrangeObjects {
|
|||||||
self.widgets[uid]?.update(value: value)
|
self.widgets[uid]?.update(value: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateWidgetLabel(uid: String, label: String) {
|
||||||
|
self.widgets[uid]?.applyLabel(label)
|
||||||
|
}
|
||||||
|
|
||||||
func showStatus(_ message: String) {
|
func showStatus(_ message: String) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.statusToken += 1
|
self.statusToken += 1
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ extension ArrangeObjects {
|
|||||||
self.loggingView.log(jsonStr, category: .local)
|
self.loggingView.log(jsonStr, category: .local)
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
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
|
let isNewPreset = preset.presetUuid != self.presetUuid
|
||||||
if self.isLocked == false && isNewPreset {
|
if self.isLocked == false && isNewPreset {
|
||||||
let alert = UIAlertController(
|
let alert = UIAlertController(
|
||||||
@@ -98,6 +103,18 @@ extension ArrangeObjects {
|
|||||||
if let uid = json["uid"] as? String, let value = json["value"] as? Int {
|
if let uid = json["uid"] as? String, let value = json["value"] as? Int {
|
||||||
DispatchQueue.main.async { self.updateWidget(uid: uid, value: value) }
|
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":
|
case "daw_state":
|
||||||
DispatchQueue.main.async { self.updateUIFromDawState(json) }
|
DispatchQueue.main.async { self.updateUIFromDawState(json) }
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ class ArrangeObjects: UIViewController {
|
|||||||
|
|
||||||
// MARK: - WebSocket
|
// MARK: - WebSocket
|
||||||
var wsClient: URLSessionWebSocketTask?
|
var wsClient: URLSessionWebSocketTask?
|
||||||
|
|
||||||
var urlSession: URLSession?
|
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
|
// MARK: - Connection State
|
||||||
enum ConnectionState {
|
enum ConnectionState {
|
||||||
case notStarted, connecting, connected, disconnected
|
case notStarted, connecting, connected, disconnected
|
||||||
@@ -14,10 +21,25 @@ class ArrangeObjects: UIViewController {
|
|||||||
var connectionState: ConnectionState = .notStarted
|
var connectionState: ConnectionState = .notStarted
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
|
var currentPreset: ModelPresetLoadToRemoteDevice? = nil
|
||||||
var presetName: String = ""
|
var presetName: String = ""
|
||||||
var presetUuid: String = ""
|
var presetUuid: String = ""
|
||||||
var isLocked: Bool = true
|
var isLocked: Bool = true
|
||||||
var autoSwitchEnabled: 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 {
|
var myTabletId: Int {
|
||||||
get {
|
get {
|
||||||
let stored = UserDefaults.standard.integer(forKey: "tablet_id")
|
let stored = UserDefaults.standard.integer(forKey: "tablet_id")
|
||||||
@@ -32,11 +54,25 @@ class ArrangeObjects: UIViewController {
|
|||||||
|
|
||||||
// MARK: - Layout
|
// MARK: - Layout
|
||||||
var layout: [String: CGRect] = [:]
|
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 containerView: UIView = {
|
||||||
let view = UIView()
|
let view = UIView()
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.backgroundColor = UIColor(white: 0.1, alpha: 1)
|
view.backgroundColor = .black
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -53,6 +89,21 @@ class ArrangeObjects: UIViewController {
|
|||||||
return btn
|
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 = {
|
lazy var arrangeButton: UIButton = {
|
||||||
let btn = UIButton(type: .system)
|
let btn = UIButton(type: .system)
|
||||||
btn.translatesAutoresizingMaskIntoConstraints = false
|
btn.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -67,6 +118,21 @@ class ArrangeObjects: UIViewController {
|
|||||||
return btn
|
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 = {
|
lazy var autoSwitchButton: UIButton = {
|
||||||
let btn = UIButton(type: .system)
|
let btn = UIButton(type: .system)
|
||||||
btn.translatesAutoresizingMaskIntoConstraints = false
|
btn.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -79,18 +145,21 @@ class ArrangeObjects: UIViewController {
|
|||||||
return btn
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var lockButton: UIButton = {
|
lazy var channelFocusButton: UIButton = {
|
||||||
let btn = UIButton(type: .system)
|
let btn = UIButton(type: .system)
|
||||||
btn.translatesAutoresizingMaskIntoConstraints = false
|
btn.translatesAutoresizingMaskIntoConstraints = false
|
||||||
btn.setTitle("Lock / Save", for: .normal)
|
btn.setTitle("Channel Focus", for: .normal)
|
||||||
btn.setTitleColor(.black, for: .normal)
|
btn.setTitleColor(.lightGray, for: .normal)
|
||||||
btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
|
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.layer.cornerRadius = 4
|
||||||
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
|
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
|
return btn
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Connection Fields
|
// MARK: - Connection Fields
|
||||||
let ipField: UITextField = {
|
let ipField: UITextField = {
|
||||||
let tf = UITextField()
|
let tf = UITextField()
|
||||||
@@ -157,6 +226,20 @@ class ArrangeObjects: UIViewController {
|
|||||||
return w
|
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 = {
|
lazy var showLoggingButton: UIButton = {
|
||||||
let btn = UIButton(type: .system)
|
let btn = UIButton(type: .system)
|
||||||
btn.translatesAutoresizingMaskIntoConstraints = false
|
btn.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ extension ArrangeObjects {
|
|||||||
func setupUI() {
|
func setupUI() {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
||||||
self.view.backgroundColor = UIColor(white: 0.1, alpha: 1)
|
self.view.backgroundColor = .black
|
||||||
|
|
||||||
// containerView
|
// containerView
|
||||||
self.view.addSubview(self.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.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true
|
||||||
self.containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
|
self.containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
|
||||||
|
|
||||||
// statusLabel
|
// grid overlay — pinned to containerView, behind all widgets
|
||||||
self.view.addSubview(self.statusLabel)
|
self.containerView.addSubview(self.gridOverlay)
|
||||||
self.statusLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
self.gridOverlay.topAnchor.constraint(equalTo: self.containerView.topAnchor).isActive = true
|
||||||
self.statusLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
|
self.gridOverlay.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor).isActive = true
|
||||||
|
self.gridOverlay.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor).isActive = true
|
||||||
// presetLabel
|
self.gridOverlay.bottomAnchor.constraint(equalTo: self.containerView.bottomAnchor).isActive = true
|
||||||
self.view.addSubview(self.presetLabel)
|
self.gridOverlay.isHidden = true
|
||||||
self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true
|
|
||||||
self.presetLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
|
|
||||||
|
|
||||||
// toolbar buttons (top-right)
|
// 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.view.addSubview(self.showLoggingButton)
|
||||||
self.showLoggingButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
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.view.addSubview(self.toolbarStatusLabel)
|
||||||
self.toolbarStatusLabel.topAnchor.constraint(equalTo: self.showLoggingButton.bottomAnchor, constant: 4).isActive = true
|
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.toolbarStatusLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
|
||||||
|
|
||||||
self.view.addSubview(self.connectButton)
|
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.connectButton.trailingAnchor.constraint(equalTo: self.showLoggingButton.leadingAnchor, constant: -6).isActive = true
|
||||||
|
|
||||||
self.view.addSubview(self.tabletIdField)
|
self.view.addSubview(self.tabletIdField)
|
||||||
@@ -43,6 +54,9 @@ extension ArrangeObjects {
|
|||||||
self.tabletIdField.widthAnchor.constraint(equalToConstant: 40).isActive = true
|
self.tabletIdField.widthAnchor.constraint(equalToConstant: 40).isActive = true
|
||||||
self.tabletIdField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
|
self.tabletIdField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
|
||||||
self.tabletIdField.text = "\(self.myTabletId)"
|
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.view.addSubview(self.portField)
|
||||||
self.portField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
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.widthAnchor.constraint(equalToConstant: 120).isActive = true
|
||||||
self.ipField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).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.view.addSubview(self.autoSwitchButton)
|
||||||
self.autoSwitchButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
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.view.addSubview(self.arrangeButton)
|
||||||
self.arrangeButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
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.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.containerView.addSubview(self.logWidget)
|
||||||
self.addDragGesture(to: self.logWidget)
|
self.addDragGesture(to: self.logWidget)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ArrangeView: ArrangeObjects {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupUI()
|
setupUI()
|
||||||
|
startBonjourDiscovery()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ class BaseWidget: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyValue(_ value: Int) {}
|
func applyValue(_ value: Int) {}
|
||||||
|
func applyFloatValue(_ value: Double) {}
|
||||||
|
func applyFeedbackText(_ text: String) {}
|
||||||
func setArrangeMode(_ enabled: Bool) {}
|
func setArrangeMode(_ enabled: Bool) {}
|
||||||
|
|
||||||
|
func applyLabel(_ label: String) {
|
||||||
|
self.displayName = label
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Activity Ring
|
// MARK: - Activity Ring
|
||||||
|
|
||||||
private func setupActivityRingGesture() {
|
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 {
|
extension BaseWidget: UIGestureRecognizerDelegate {
|
||||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
return true
|
return true
|
||||||
@@ -87,4 +127,14 @@ extension UIColor {
|
|||||||
alpha: CGFloat((int >> 24) & 0xFF) / 255
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,35 +2,62 @@ import UIKit
|
|||||||
|
|
||||||
class FaderWidget: BaseWidget {
|
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 {
|
static func defaultFrame(idx: Int) -> CGRect {
|
||||||
let col = idx % 3
|
let col = idx % 4
|
||||||
let row = idx / 3
|
let row = idx / 4
|
||||||
return CGRect(x: 16 + CGFloat(col) * 170, y: 48 + CGFloat(row) * 380, width: size.width, height: size.height)
|
return CGRect(x: 16 + CGFloat(col) * 118, y: 90 + CGFloat(row) * 560, width: size.width, height: size.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
let color: UIColor
|
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
|
// 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()
|
let lbl = UILabel()
|
||||||
lbl.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
lbl.textColor = UIColor(white: 0.53, alpha: 1)
|
|
||||||
lbl.font = .systemFont(ofSize: 14)
|
|
||||||
lbl.textAlignment = .center
|
lbl.textAlignment = .center
|
||||||
|
lbl.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
return lbl
|
return lbl
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var slider: UISlider = {
|
private lazy var modeButton: UIButton = {
|
||||||
let s = UISlider()
|
let btn = UIButton(type: .system)
|
||||||
s.translatesAutoresizingMaskIntoConstraints = false
|
btn.setTitle("REL", for: .normal)
|
||||||
s.minimumValue = 0
|
btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||||
s.maximumValue = 127
|
btn.setTitleColor(.white, for: .normal)
|
||||||
s.transform = CGAffineTransform(rotationAngle: -.pi / 2)
|
btn.backgroundColor = color.withAlphaComponent(0.35)
|
||||||
return s
|
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
|
// MARK: - Init
|
||||||
@@ -38,51 +65,190 @@ class FaderWidget: BaseWidget {
|
|||||||
self.color = color
|
self.color = color
|
||||||
super.init(uid: uid, frame: frame)
|
super.init(uid: uid, frame: frame)
|
||||||
self.displayName = label
|
self.displayName = label
|
||||||
self.restingBorderColor = color.cgColor
|
self.restingBorderColor = color.withAlphaComponent(0.3).cgColor
|
||||||
self.layer.borderColor = color.cgColor
|
self.layer.borderColor = color.withAlphaComponent(0.3).cgColor
|
||||||
|
self.currentValue = Float(value)
|
||||||
self.nameLabel.text = label
|
self.nameLabel.text = label
|
||||||
self.slider.value = Float(value)
|
|
||||||
self.slider.minimumTrackTintColor = color
|
|
||||||
setupUI()
|
setupUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
// MARK: - Layout
|
// MARK: - Setup
|
||||||
func setupUI() {
|
private func setupUI() {
|
||||||
self.addSubview(self.nameLabel)
|
backgroundColor = UIColor(white: 0.1, alpha: 1)
|
||||||
self.nameLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 8).isActive = true
|
layer.cornerRadius = 6
|
||||||
self.nameLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4).isActive = true
|
clipsToBounds = true
|
||||||
self.nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4).isActive = true
|
|
||||||
|
|
||||||
self.addSubview(self.slider)
|
topZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
|
||||||
self.slider.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
|
botZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
|
||||||
self.slider.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: 10).isActive = true
|
addSubview(topZone)
|
||||||
let wc = self.slider.widthAnchor.constraint(equalToConstant: 216)
|
addSubview(botZone)
|
||||||
wc.isActive = true
|
|
||||||
self.sliderWidthConstraint = wc
|
|
||||||
|
|
||||||
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() {
|
override func layoutSubviews() {
|
||||||
super.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
|
private func addDash(to zone: UIView, atBottom: Bool) {
|
||||||
override func applyValue(_ value: Int) {
|
let dash = CAShapeLayer()
|
||||||
self.slider.value = Float(value)
|
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
|
// MARK: - Arrange Mode
|
||||||
override func setArrangeMode(_ enabled: Bool) {
|
override func setArrangeMode(_ enabled: Bool) {
|
||||||
self.slider.isUserInteractionEnabled = (enabled == false)
|
arrangeMode = enabled
|
||||||
|
arrangeCover.isHidden = !enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Action
|
// MARK: - Mode Toggle
|
||||||
@objc private func sliderChanged() {
|
@objc private func toggleMode() {
|
||||||
self.onSend?(self.uid, Int(self.slider.value))
|
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<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
||||||
|
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
|
||||||
|
move(to: pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, 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))"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<UITouch>, 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<UITouch>, with event: UIEvent?) {
|
||||||
|
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
|
||||||
|
move(to: pt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
|
||||||
|
override func touchesCancelled(_ touches: Set<UITouch>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
class TitleWidget: BaseWidget {
|
class TitleWidget: BaseWidget, TextAlignableWidget {
|
||||||
|
|
||||||
static func defaultFrame(idx: Int) -> CGRect {
|
static func defaultFrame(idx: Int) -> CGRect {
|
||||||
let col = idx % 3
|
let col = idx % 3
|
||||||
@@ -20,8 +20,14 @@ class TitleWidget: BaseWidget {
|
|||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
init(uid: String, text: String, frame: CGRect) {
|
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.backgroundColor = .clear
|
||||||
|
self.restingBorderColor = UIColor.clear.cgColor
|
||||||
self.layer.borderColor = UIColor.clear.cgColor
|
self.layer.borderColor = UIColor.clear.cgColor
|
||||||
self.titleLabel.text = text
|
self.titleLabel.text = text
|
||||||
setupUI()
|
setupUI()
|
||||||
@@ -37,4 +43,22 @@ class TitleWidget: BaseWidget {
|
|||||||
self.titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ class ToggleWidget: BaseWidget {
|
|||||||
applyState()
|
applyState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func applyLabel(_ label: String) {
|
||||||
|
super.applyLabel(label)
|
||||||
|
self.button.setTitle(label, for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Arrange Mode
|
// MARK: - Arrange Mode
|
||||||
override func setArrangeMode(_ enabled: Bool) {
|
override func setArrangeMode(_ enabled: Bool) {
|
||||||
self.button.isUserInteractionEnabled = (enabled == false)
|
self.button.isUserInteractionEnabled = (enabled == false)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class TransportWidget: BaseWidget {
|
|||||||
width: size.width, height: size.height)
|
width: size.width, height: size.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
let color: UIColor
|
||||||
|
|
||||||
// MARK: - Subviews
|
// MARK: - Subviews
|
||||||
lazy var button: UIButton = {
|
lazy var button: UIButton = {
|
||||||
let btn = UIButton(type: .system)
|
let btn = UIButton(type: .system)
|
||||||
@@ -25,7 +28,8 @@ class TransportWidget: BaseWidget {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Init
|
// 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)
|
super.init(uid: uid, frame: frame)
|
||||||
self.displayName = label
|
self.displayName = label
|
||||||
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
|
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
|
||||||
@@ -54,6 +58,18 @@ class TransportWidget: BaseWidget {
|
|||||||
// MARK: - Action
|
// MARK: - Action
|
||||||
@objc private func buttonTappedTransportButton() {
|
@objc private func buttonTappedTransportButton() {
|
||||||
self.onSend?(self.uid, 127)
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Used to discover the Virtual Controller desktop app on your local network.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_vcremote._tcp.</string>
|
||||||
|
</array>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ PyQt6_sip==13.11.1
|
|||||||
python-osc==1.10.2
|
python-osc==1.10.2
|
||||||
python-rtmidi==1.5.8
|
python-rtmidi==1.5.8
|
||||||
uv==0.11.21
|
uv==0.11.21
|
||||||
|
zeroconf==0.150.0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from PyQt6.QtNetwork import QHostAddress
|
|||||||
|
|
||||||
class WSServer(QObject):
|
class WSServer(QObject):
|
||||||
control_received = pyqtSignal(str, int)
|
control_received = pyqtSignal(str, int)
|
||||||
|
control_f_received = pyqtSignal(str, float)
|
||||||
layout_saved = pyqtSignal(str, object)
|
layout_saved = pyqtSignal(str, object)
|
||||||
widget_visibility_received = pyqtSignal(str, int)
|
widget_visibility_received = pyqtSignal(str, int)
|
||||||
client_connected = pyqtSignal()
|
client_connected = pyqtSignal()
|
||||||
@@ -48,6 +49,11 @@ class WSServer(QObject):
|
|||||||
val = int(data["value"])
|
val = int(data["value"])
|
||||||
self.log_signal.emit(f"control uid={uid[:8]}… val={val}")
|
self.log_signal.emit(f"control uid={uid[:8]}… val={val}")
|
||||||
self.control_received.emit(uid, 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":
|
elif ev == "save_layout":
|
||||||
n = len(data.get("layout", {}))
|
n = len(data.get("layout", {}))
|
||||||
self.log_signal.emit(f"layout saved preset={data['preset_uuid'][:8]}… {n} widgets")
|
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):
|
def broadcast_widget_update(self, uid: str, value: int):
|
||||||
self.broadcast({"event": "widget_update", "uid": uid, "value": value})
|
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):
|
def broadcast_daw_state(self, **kwargs):
|
||||||
self.broadcast({"event": "daw_state", **kwargs})
|
self.broadcast({"event": "daw_state", **kwargs})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user