import uuid from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy ) from PyQt6.QtCore import Qt, QEvent from palette import PALETTE_HEX, PALETTE_VIVID from styles import TITLE_STYLE from zonebutton import ZoneButton from colorswatchpopup import ColorSwatchPopup from midi_sender import send_transport_cc from osc_sender import send_osc_message class TransportWidget(QWidget): """Transport button strip — same pattern as ToggleWidget.""" def __init__(self, label, default_cc): super().__init__() self.uid = str(uuid.uuid4()) self.zone = None self.center_index = None self.zone_index = None self.toggle_state = False self.hw_cc = None self.is_learning = False self.current_color = "transparent" self.color_name = "Gray" self.on_select_callback = None self.on_zone_change_callback = None self._label = label self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) self.setFixedWidth(100) 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) # Learn button self.learn_btn = QPushButton("Learn") self.learn_btn.setFixedHeight(20) self.learn_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.learn_btn.customContextMenuRequested.connect(self.on_learn_context_menu) inner.addWidget(self.learn_btn) # CC In label 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;") inner.addWidget(self.cc_input_lbl) # CC spin self.cc_spin = QSpinBox() self.cc_spin.setMinimum(1) self.cc_spin.setMaximum(127) self.cc_spin.setValue(default_cc) self.cc_spin.setFixedWidth(75) inner.addWidget(self.cc_spin, alignment=Qt.AlignmentFlag.AlignHCenter) # CC Out label self.cc_output_lbl = QLabel(f"Out CC{default_cc} [--]") self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") inner.addWidget(self.cc_output_lbl) # MIDI / OSC mode selector mode_row = QHBoxLayout() mode_row.setContentsMargins(0, 0, 0, 0) mode_row.setSpacing(2) self.midi_btn = QPushButton("MIDI") self.midi_btn.setFixedHeight(18) self.midi_btn.setCheckable(True) self.midi_btn.setChecked(True) self.osc_btn = QPushButton("OSC") self.osc_btn.setFixedHeight(18) self.osc_btn.setCheckable(True) self.osc_btn.setChecked(False) self.midi_btn.clicked.connect(lambda: self.set_output_mode("midi")) self.osc_btn.clicked.connect(lambda: self.set_output_mode("osc")) mode_row.addWidget(self.midi_btn) mode_row.addWidget(self.osc_btn) inner.addLayout(mode_row) # Momentary / Toggle 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(True) self.toggle_mode_btn = QPushButton("Tog.") self.toggle_mode_btn.setFixedHeight(18) self.toggle_mode_btn.setCheckable(True) self.toggle_mode_btn.setChecked(False) 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) inner.addLayout(trigger_row) self.trigger_mode = "momentary" # "momentary" or "toggle" # OSC address field (hidden by default) self.osc_addr_edit = QLineEdit("/play") self.osc_addr_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) self.osc_addr_edit.setFixedWidth(75) self.osc_addr_edit.setPlaceholderText("/address") self.osc_addr_edit.setVisible(False) inner.addWidget(self.osc_addr_edit, alignment=Qt.AlignmentFlag.AlignHCenter) self.output_mode = "midi" # "midi" or "osc" # Transport button with LED dot btn_container = QWidget() btn_container.setFixedSize(60, 60) self.btn = QPushButton(label, 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 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 + ID label id_sep = QWidget() id_sep.setFixedHeight(1) id_sep.setStyleSheet("background-color: #3a3a3a;") inner.addWidget(id_sep) 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) for child in self.findChildren(QWidget): child.installEventFilter(self) def eventFilter(self, obj, event): if event.type() == QEvent.Type.MouseButtonPress: if self.on_select_callback: self.on_select_callback(self) return False def mousePressEvent(self, event): if self.on_select_callback: self.on_select_callback(self) super().mousePressEvent(event) 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 set_output_mode(self, mode): self.output_mode = mode if mode == "midi": self.midi_btn.setChecked(True) self.osc_btn.setChecked(False) self.midi_btn.setStyleSheet("font-weight: bold; color: #00e676;") self.osc_btn.setStyleSheet("") self.cc_spin.setVisible(True) self.cc_output_lbl.setVisible(True) self.osc_addr_edit.setVisible(False) else: self.osc_btn.setChecked(True) self.midi_btn.setChecked(False) self.osc_btn.setStyleSheet("font-weight: bold; color: #00e676;") self.midi_btn.setStyleSheet("") self.cc_spin.setVisible(False) self.cc_output_lbl.setVisible(False) self.osc_addr_edit.setVisible(True) def on_click(self): if self.trigger_mode == "momentary": if self.output_mode == "osc": addr = self.osc_addr_edit.text().strip() or "/play" send_osc_message(addr, 1.0) self.cc_output_lbl.setText(f"OSC {addr}") else: cc_num = self.cc_spin.value() send_transport_cc(cc_num, 127) self.cc_output_lbl.setText(f"Out CC{cc_num} [127]") self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") QTimer.singleShot(150, lambda: self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;")) else: self.toggle_state = not self.toggle_state value = 127 if self.toggle_state else 0 if self.output_mode == "osc": addr = self.osc_addr_edit.text().strip() or "/play" send_osc_message(addr, 1.0 if self.toggle_state else 0.0) self.cc_output_lbl.setText(f"OSC {addr}") else: cc_num = self.cc_spin.value() send_transport_cc(cc_num, value) self.cc_output_lbl.setText(f"Out CC{cc_num} [{value}]") self._update_btn_style() 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 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): text = f"L{index+1}" if zone == "left" else f"R{index+1}" if zone == "right" else f"{index+1}" self.id_label.setText(text) 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(name, hex_color)}; border-radius: 2px; border: none;") self.apply_color(hex_color) def on_zone_checked(self, zone, active): self.zone = zone if active else None if self.on_zone_change_callback: self.on_zone_change_callback(self) 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.setStyleSheet("") if self.hw_cc is not None: self.learn_btn.setText(f"HW: CC{self.hw_cc}") self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") else: self.learn_btn.setText("Learn") self.cc_input_lbl.setText("") def bind_hw_cc(self, cc_number): self.hw_cc = cc_number self.stop_learn() def on_learn_context_menu(self, pos): from PyQt6.QtWidgets import QMenu if self.hw_cc is None: return menu = QMenu(self) clear_action = menu.addAction("Clear Input Assignment") action = menu.exec(self.learn_btn.mapToGlobal(pos)) if action == clear_action: self.hw_cc = None self.learn_btn.setText("Learn") self.learn_btn.setStyleSheet("") self.cc_input_lbl.setText("") def get_state(self): return { "uid": self.uid, "label": self.title_edit.text(), "cc": self.cc_spin.value(), "state": self.toggle_state, "color": self.color_name, "zone": self.zone, "center_index": self.center_index, "zone_index": self.zone_index, "hw_cc": self.hw_cc, "output_mode": self.output_mode, "osc_addr": self.osc_addr_edit.text(), "trigger_mode": self.trigger_mode, } def set_state(self, state): if "uid" in state: self.uid = state["uid"] self.title_edit.setText(state.get("label", self._label)) self.cc_spin.setValue(state.get("cc", 1)) 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(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.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.hw_cc = state.get("hw_cc", None) if self.hw_cc is not None: self.learn_btn.setText(f"HW: CC{self.hw_cc}") self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") self.osc_addr_edit.setText(state.get("osc_addr", "/play")) self.set_output_mode(state.get("output_mode", "midi")) self.set_trigger_mode(state.get("trigger_mode", "momentary"))