import copy import sys import time import pygame from PySide6.QtCore import Qt, QTimer from PySide6.QtWidgets import ( QApplication, QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHeaderView, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QDoubleSpinBox, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from abstractify import spotifyAbstract from controller_profiles import ( ACTION_LABELS, ACTION_ORDER, controllerFromJoystick, findMatchingProfile, getDefaultProfile, getProfile, loadConfig, profileHasController, saveConfig, saveControllerProfile, ) from spotify_settings import DEFAULT_REDIRECT_URI, saveSpotifySettings, spotifySettingsReady ACTION_HANDLERS = { "spotify pause play": spotifyAbstract.togglePlayPause, "spotify next track": spotifyAbstract.nextTrack, "spotify previous track": spotifyAbstract.previousTrack, "spotify mute toggle": spotifyAbstract.muteToggle, } VOLUME_ACTIONS = ("spotify vol up", "spotify vol down") class ControllerWindow(QMainWindow): def __init__(self): super().__init__() pygame.init() pygame.joystick.init() self.config = loadConfig() self.controllers = [] self.joysticks = {} self.currentBindings = copy.deepcopy(getDefaultProfile(self.config)["bindings"]) self.currentButtonSettings = copy.deepcopy(getDefaultProfile(self.config).get("button_settings", {})) self.lockedBindings = copy.deepcopy(self.currentBindings) self.lockedButtonSettings = copy.deepcopy(self.currentButtonSettings) self.currentProfileId = None self.saveTargetProfileId = None self.lockedController = None self.lockedInstanceId = None self.lastButtonPress = {} self.lastLockedPressedButtons = set() self.remapButtonsAlreadyDown = set() self.buttonCooldown = self.globalDebounceSeconds() self.remapAction = None self.updatingControls = False self.setWindowTitle("Rae's Steering Mapper") self.buildGui() self.refreshControllers() self.timer = QTimer(self) self.timer.timeout.connect(self.readControllerEvents) self.timer.start(16) def buildGui(self): self.statusLabel = QLabel("Checking for controllers...") self.debugLabel = QLabel("Last input: none") self.debugLabel.setTextInteractionFlags(Qt.TextSelectableByMouse) self.controllerBox = QComboBox() self.controllerBox.currentIndexChanged.connect(self.controllerSelectionChanged) self.refreshButton = QPushButton("Refresh") self.refreshButton.clicked.connect(self.refreshControllers) self.lockButton = QPushButton("Lock Controller") self.lockButton.clicked.connect(self.lockSelectedController) controllerRow = QHBoxLayout() controllerRow.addWidget(QLabel("Controller")) controllerRow.addWidget(self.controllerBox, 1) controllerRow.addWidget(self.refreshButton) controllerRow.addWidget(self.lockButton) self.profileBox = QComboBox() self.profileBox.currentIndexChanged.connect(self.profileSelectionChanged) self.profileName = QLineEdit() self.saveButton = QPushButton("Save Controller Profile") self.saveButton.clicked.connect(self.saveCurrentProfile) profileRow = QHBoxLayout() profileRow.addWidget(QLabel("Mapping")) profileRow.addWidget(self.profileBox, 1) profileRow.addWidget(QLabel("Name")) profileRow.addWidget(self.profileName, 1) profileRow.addWidget(self.saveButton) self.buttonPressesLogged = QCheckBox("Log button presses") self.buttonPressesLogged.setChecked(bool(self.config.get("button_presses_logged", True))) self.buttonPressesLogged.stateChanged.connect(self.buttonLoggingChanged) self.globalDebounceInput = QDoubleSpinBox() self.globalDebounceInput.setRange(0.01, 5.00) self.globalDebounceInput.setSingleStep(0.05) self.globalDebounceInput.setDecimals(2) self.globalDebounceInput.setValue(self.globalDebounceSeconds()) self.globalDebounceInput.valueChanged.connect(self.globalSettingsChanged) self.globalVolumeInput = QSpinBox() self.globalVolumeInput.setRange(1, 100) self.globalVolumeInput.setValue(self.globalVolumeIncrement()) self.globalVolumeInput.valueChanged.connect(self.globalSettingsChanged) self.globalPollingInput = QSpinBox() self.globalPollingInput.setRange(25, 5000) self.globalPollingInput.setSingleStep(25) self.globalPollingInput.setValue(self.globalPollingIntervalMs()) self.globalPollingInput.valueChanged.connect(self.globalSettingsChanged) settingsRow = QHBoxLayout() settingsRow.addWidget(self.buttonPressesLogged) settingsRow.addStretch(1) settingsRow.addWidget(QLabel("Global debounce")) settingsRow.addWidget(self.globalDebounceInput) settingsRow.addWidget(QLabel("Global volume step")) settingsRow.addWidget(self.globalVolumeInput) settingsRow.addWidget(QLabel("Volume repeat ms")) settingsRow.addWidget(self.globalPollingInput) self.bindingTable = QTableWidget(0, 5) self.bindingTable.setHorizontalHeaderLabels(["Button", "Spotify API Call", "Debounce", "Vol Step", "Repeat MS"]) self.bindingTable.setSelectionBehavior(QAbstractItemView.SelectRows) self.bindingTable.setSelectionMode(QAbstractItemView.SingleSelection) self.bindingTable.verticalHeader().setVisible(False) self.bindingTable.setAlternatingRowColors(True) self.bindingTable.setShowGrid(False) self.bindingTable.setMinimumHeight(250) self.bindingTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.bindingTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.bindingTable.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.bindingTable.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) self.bindingTable.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents) self.mapButton = QPushButton("Capture Button For Selected Action") self.mapButton.clicked.connect(self.startRemap) self.clearButton = QPushButton("Clear Selected Mapping") self.clearButton.clicked.connect(self.clearSelectedMapping) buttonRow = QHBoxLayout() buttonRow.addWidget(self.mapButton) buttonRow.addWidget(self.clearButton) buttonRow.addStretch(1) layout = QVBoxLayout() layout.addWidget(self.statusLabel) layout.addWidget(self.debugLabel) layout.addLayout(controllerRow) layout.addLayout(profileRow) layout.addLayout(settingsRow) layout.addWidget(self.bindingTable) layout.addLayout(buttonRow) root = QWidget() root.setLayout(layout) self.setCentralWidget(root) self.resize(760, 420) self.populateProfiles() self.updateBindingTable() def populateProfiles(self): selectedProfileId = self.profileBox.currentData() self.updatingControls = True self.profileBox.clear() for profile in self.config.get("profiles", []): self.profileBox.addItem(profile.get("name", "Controller"), profile.get("id")) if selectedProfileId is not None: self.selectProfile(selectedProfileId) else: self.selectProfile(self.config.get("default_profile_id")) self.updatingControls = False def selectProfile(self, profileId): for index in range(self.profileBox.count()): if self.profileBox.itemData(index) == profileId: self.profileBox.setCurrentIndex(index) return def refreshControllers(self): pygame.joystick.init() selectedInstanceId = None selectedController = self.selectedController() if selectedController is not None: selectedInstanceId = selectedController.get("instance_id") self.controllers = [] self.joysticks = {} for index in range(pygame.joystick.get_count()): joystick = pygame.joystick.Joystick(index) controller = controllerFromJoystick(joystick) self.controllers.append(controller) self.joysticks[controller["instance_id"]] = joystick self.updatingControls = True self.controllerBox.clear() for controller in self.controllers: label = f"{controller['name']} ({controller['numbuttons']} buttons)" self.controllerBox.addItem(label, controller["instance_id"]) if selectedInstanceId is not None: for index in range(self.controllerBox.count()): if self.controllerBox.itemData(index) == selectedInstanceId: self.controllerBox.setCurrentIndex(index) break self.updatingControls = False self.controllerSelectionChanged() def selectedController(self): instanceId = self.controllerBox.currentData() for controller in self.controllers: if controller.get("instance_id") == instanceId: return controller return None def controllerSelectionChanged(self): if self.updatingControls: return controller = self.selectedController() if controller is None: self.statusLabel.setText("No controller connected.") self.saveTargetProfileId = None self.profileName.setText("") self.currentBindings = copy.deepcopy(getDefaultProfile(self.config)["bindings"]) self.updateBindingTable() return matchedProfile = findMatchingProfile(self.config, controller) if matchedProfile is not None: self.currentProfileId = matchedProfile["id"] self.saveTargetProfileId = matchedProfile["id"] self.currentBindings = copy.deepcopy(matchedProfile["bindings"]) self.currentButtonSettings = copy.deepcopy(matchedProfile.get("button_settings", {})) self.profileName.setText(matchedProfile["name"]) self.selectProfile(matchedProfile["id"]) self.statusLabel.setText(f"Known controller selected: {controller['name']}") else: defaultProfile = getDefaultProfile(self.config) self.currentProfileId = defaultProfile["id"] self.saveTargetProfileId = None self.currentBindings = copy.deepcopy(defaultProfile["bindings"]) self.currentButtonSettings = copy.deepcopy(defaultProfile.get("button_settings", {})) self.profileName.setText(f"{controller['name']} Profile") self.selectProfile(defaultProfile["id"]) self.statusLabel.setText(f"Unknown controller selected: {controller['name']}. Using temporary mapping.") self.updateBindingTable() def profileSelectionChanged(self): if self.updatingControls: return profile = getProfile(self.config, self.profileBox.currentData()) if profile is None: return self.currentProfileId = profile["id"] self.currentBindings = copy.deepcopy(profile["bindings"]) self.currentButtonSettings = copy.deepcopy(profile.get("button_settings", {})) if self.saveTargetProfileId is None: controller = self.selectedController() if controller is not None: self.profileName.setText(f"{controller['name']} Profile") else: self.profileName.setText(profile["name"]) elif not profileHasController(profile): self.profileName.setText(profile["name"]) self.statusLabel.setText(f"Mapping selected: {profile['name']}") self.updateBindingTable() def buttonLoggingChanged(self): self.config["button_presses_logged"] = self.buttonPressesLogged.isChecked() saveConfig(self.config) def globalSettingsChanged(self): self.config.setdefault("global_settings", {}) self.config["global_settings"]["debounce_seconds"] = self.globalDebounceInput.value() self.config["global_settings"]["volume_increment"] = self.globalVolumeInput.value() self.config["global_settings"]["polling_interval_ms"] = self.globalPollingInput.value() saveConfig(self.config) def lockSelectedController(self): controller = self.selectedController() if controller is None: self.statusLabel.setText("No controller selected.") return self.lockedInstanceId = controller["instance_id"] self.lockedController = dict(controller) self.lockedBindings = copy.deepcopy(self.currentBindings) self.lockedButtonSettings = copy.deepcopy(self.currentButtonSettings) self.lastButtonPress = {} self.lastLockedPressedButtons = self.pressedButtonsForController(controller) self.statusLabel.setText(f"Locked controller: {controller['name']}") def saveCurrentProfile(self): controller = self.selectedController() if controller is None: self.statusLabel.setText("No controller selected.") return profileName = self.profileName.text().strip() if not profileName: profileName = f"{controller['name']} Profile" savedProfile = saveControllerProfile( self.config, controller, profileName, self.currentBindings, self.currentButtonSettings, self.saveTargetProfileId, ) self.saveTargetProfileId = savedProfile["id"] self.currentProfileId = savedProfile["id"] self.populateProfiles() self.selectProfile(savedProfile["id"]) self.statusLabel.setText(f"Saved controller profile: {savedProfile['name']}") def updateBindingTable(self): self.bindingTable.setRowCount(0) for row, button in enumerate(self.buttonsForTable()): binding = f"button:{button}" self.bindingTable.insertRow(row) buttonItem = QTableWidgetItem(binding) buttonItem.setData(Qt.UserRole, button) buttonItem.setFlags(buttonItem.flags() & ~Qt.ItemIsEditable) actionBox = QComboBox() actionBox.addItem(ACTION_LABELS[""], "") for action in ACTION_ORDER: actionBox.addItem(ACTION_LABELS[action], action) actionBox.setCurrentIndex(actionBox.findData(self.currentBindings.get(binding, ""))) actionBox.currentIndexChanged.connect(lambda ignored=None, row=row: self.tableRowChanged(row)) debounceInput = QDoubleSpinBox() debounceInput.setRange(0.00, 5.00) debounceInput.setSingleStep(0.05) debounceInput.setDecimals(2) debounceInput.setSpecialValueText("Global") debounceInput.setValue(self.buttonSetting(binding, "debounce_seconds", 0)) debounceInput.valueChanged.connect(lambda ignored=None, row=row: self.tableRowChanged(row)) volumeInput = QSpinBox() volumeInput.setRange(0, 100) volumeInput.setSpecialValueText("Global") volumeInput.setValue(self.buttonSetting(binding, "volume_increment", 0)) volumeInput.valueChanged.connect(lambda ignored=None, row=row: self.tableRowChanged(row)) pollingInput = QSpinBox() pollingInput.setRange(0, 5000) pollingInput.setSingleStep(25) pollingInput.setSpecialValueText("Global") pollingInput.setValue(self.buttonSetting(binding, "polling_interval_ms", 0)) pollingInput.valueChanged.connect(lambda ignored=None, row=row: self.tableRowChanged(row)) self.bindingTable.setItem(row, 0, buttonItem) self.bindingTable.setCellWidget(row, 1, actionBox) self.bindingTable.setCellWidget(row, 2, debounceInput) self.bindingTable.setCellWidget(row, 3, volumeInput) self.bindingTable.setCellWidget(row, 4, pollingInput) self.bindingTable.setRowHeight(row, 34) self.bindingTable.resizeColumnsToContents() def buttonsForTable(self): buttons = set() controller = self.selectedController() if controller is not None: for button in range(controller.get("numbuttons", 0)): buttons.add(button) for binding in self.currentBindings.keys(): if binding.startswith("button:"): buttons.add(int(binding.split(":", 1)[1])) return sorted(buttons) def buttonSetting(self, binding, settingName, defaultValue): return self.currentButtonSettings.get(binding, {}).get(settingName, defaultValue) def tableRowChanged(self, row): buttonItem = self.bindingTable.item(row, 0) if buttonItem is None: return binding = buttonItem.text() actionBox = self.bindingTable.cellWidget(row, 1) debounceInput = self.bindingTable.cellWidget(row, 2) volumeInput = self.bindingTable.cellWidget(row, 3) pollingInput = self.bindingTable.cellWidget(row, 4) action = actionBox.currentData() if action: self.currentBindings[binding] = action elif binding in self.currentBindings: del self.currentBindings[binding] settings = {} if debounceInput.value() > 0: settings["debounce_seconds"] = debounceInput.value() if volumeInput.value() > 0: settings["volume_increment"] = volumeInput.value() if pollingInput.value() > 0: settings["polling_interval_ms"] = pollingInput.value() if settings: self.currentButtonSettings[binding] = settings elif binding in self.currentButtonSettings: del self.currentButtonSettings[binding] if self.selectedController() is not None and self.selectedController().get("instance_id") == self.lockedInstanceId: self.lockedBindings = copy.deepcopy(self.currentBindings) self.lockedButtonSettings = copy.deepcopy(self.currentButtonSettings) def selectedAction(self): selectedRows = self.bindingTable.selectionModel().selectedRows() if not selectedRows: return None row = selectedRows[0].row() actionBox = self.bindingTable.cellWidget(row, 1) if actionBox is None: return None return actionBox.currentData() def startRemap(self): action = self.selectedAction() if action is None: self.statusLabel.setText("Select a row with a Spotify action to map.") return controller = self.selectedController() if controller is None: self.statusLabel.setText("Select a controller before mapping.") return self.remapAction = action self.remapButtonsAlreadyDown = self.pressedButtonsForController(controller) self.statusLabel.setText(f"Press a controller button for {ACTION_LABELS[action]}.") def clearSelectedMapping(self): selectedRows = self.bindingTable.selectionModel().selectedRows() if not selectedRows: self.statusLabel.setText("Select a button mapping to clear.") return row = selectedRows[0].row() buttonItem = self.bindingTable.item(row, 0) actionBox = self.bindingTable.cellWidget(row, 1) if buttonItem is None or actionBox is None: return binding = buttonItem.text() actionBox.setCurrentIndex(actionBox.findData("")) if binding in self.currentBindings: del self.currentBindings[binding] self.updateBindingTable() self.statusLabel.setText(f"Cleared mapping for {binding}.") def readControllerEvents(self): for event in pygame.event.get(): self.showRawEvent(event) if event.type == pygame.JOYDEVICEADDED: self.refreshControllers() self.statusLabel.setText("New controller connected.") elif event.type == pygame.JOYDEVICEREMOVED: if getattr(event, "instance_id", None) == self.lockedInstanceId: self.lockedController = None self.lockedInstanceId = None self.lastLockedPressedButtons = set() self.statusLabel.setText("Locked controller disconnected.") self.refreshControllers() elif event.type in (pygame.JOYBUTTONDOWN, pygame.CONTROLLERBUTTONDOWN): self.handleButtonPress(event) self.pollControllerButtons() def handleButtonPress(self, event): if self.remapAction is not None: if not self.eventIsFromSelectedController(event): return self.setButtonMapping(event.button, self.remapAction) self.statusLabel.setText(f"Mapped {ACTION_LABELS[self.remapAction]} to button:{event.button}.") self.remapAction = None return if self.lockedController is None: return if not self.eventMatchesController(event, self.lockedController): return self.runMappedButton(event.button) def runMappedButton(self, buttonPressed): currentTime = time.time() binding = f"button:{buttonPressed}" action = self.lockedBindings.get(binding) if action is None: return throttleSeconds = self.debounceSecondsForButton(binding) if action in VOLUME_ACTIONS: throttleSeconds = self.pollingSecondsForButton(binding) if buttonPressed in self.lastButtonPress: if currentTime - self.lastButtonPress[buttonPressed] < throttleSeconds: return self.lastButtonPress[buttonPressed] = currentTime if self.buttonPressesLogged.isChecked(): print(f"Button pressed: {buttonPressed}, Action: {action}") if action == "spotify vol up": spotifyAbstract.volumeUpBy(self.volumeIncrementForButton(binding)) return if action == "spotify vol down": spotifyAbstract.volumeDownBy(self.volumeIncrementForButton(binding)) return handler = ACTION_HANDLERS.get(action) if handler is not None: handler() def pollControllerButtons(self): if self.remapAction is not None: controller = self.selectedController() if controller is None: return pressedButtons = self.pressedButtonsForController(controller) self.showPolledButtons(controller, pressedButtons) newPressedButtons = pressedButtons - self.remapButtonsAlreadyDown if newPressedButtons: buttonPressed = min(newPressedButtons) self.setButtonMapping(buttonPressed, self.remapAction) self.statusLabel.setText(f"Mapped {ACTION_LABELS[self.remapAction]} to button:{buttonPressed}.") self.remapAction = None self.remapButtonsAlreadyDown = set() elif not pressedButtons: self.remapButtonsAlreadyDown = set() return if self.lockedController is None: return pressedButtons = self.pressedButtonsForController(self.lockedController) self.showPolledButtons(self.lockedController, pressedButtons) newPressedButtons = pressedButtons - self.lastLockedPressedButtons self.lastLockedPressedButtons = pressedButtons for buttonPressed in sorted(pressedButtons): binding = f"button:{buttonPressed}" if self.lockedBindings.get(binding) in VOLUME_ACTIONS: self.runMappedButton(buttonPressed) for buttonPressed in sorted(newPressedButtons): binding = f"button:{buttonPressed}" if self.lockedBindings.get(binding) not in VOLUME_ACTIONS: self.runMappedButton(buttonPressed) def pressedButtonsForController(self, controller): if controller is None: return set() try: pygame.event.pump() joystick = self.joysticks.get(controller.get("instance_id")) if joystick is None: joystick = pygame.joystick.Joystick(controller["index"]) joystick.init() pressedButtons = set() for button in range(joystick.get_numbuttons()): if joystick.get_button(button): pressedButtons.add(button) return pressedButtons except pygame.error: return set() def eventIsFromSelectedController(self, event): controller = self.selectedController() if controller is None: return False return self.eventMatchesController(event, controller) def eventMatchesController(self, event, controller): eventInstanceId = getattr(event, "instance_id", None) if eventInstanceId is not None and eventInstanceId == controller.get("instance_id"): return True eventJoyIndex = getattr(event, "joy", None) if eventJoyIndex is not None and eventJoyIndex == controller.get("index"): return True eventWhich = getattr(event, "which", None) if eventWhich is not None and eventWhich == controller.get("instance_id"): return True return False def showRawEvent(self, event): eventName = pygame.event.event_name(event.type) if event.type in (pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP, pygame.CONTROLLERBUTTONDOWN, pygame.CONTROLLERBUTTONUP): self.debugLabel.setText( f"Last input: event={eventName}, button={getattr(event, 'button', None)}, " f"instance_id={getattr(event, 'instance_id', None)}, joy={getattr(event, 'joy', None)}, " f"which={getattr(event, 'which', None)}" ) elif event.type in (pygame.JOYDEVICEADDED, pygame.JOYDEVICEREMOVED): self.debugLabel.setText( f"Last input: event={eventName}, instance_id={getattr(event, 'instance_id', None)}, " f"device_index={getattr(event, 'device_index', None)}, which={getattr(event, 'which', None)}" ) def showPolledButtons(self, controller, pressedButtons): self.debugLabel.setText( f"Last input: polling {controller.get('name')} index={controller.get('index')} " f"instance_id={controller.get('instance_id')} pressed={sorted(pressedButtons)}" ) def setButtonMapping(self, button, action): self.currentBindings[f"button:{button}"] = action self.updateBindingTable() if self.selectedController() is not None and self.selectedController().get("instance_id") == self.lockedInstanceId: self.lockedBindings = copy.deepcopy(self.currentBindings) self.lockedButtonSettings = copy.deepcopy(self.currentButtonSettings) def globalDebounceSeconds(self): return self.config.get("global_settings", {}).get("debounce_seconds", 0.25) def globalVolumeIncrement(self): return self.config.get("global_settings", {}).get("volume_increment", 5) def globalPollingIntervalMs(self): return self.config.get("global_settings", {}).get("polling_interval_ms", 120) def debounceSecondsForButton(self, binding): return self.lockedButtonSettings.get(binding, {}).get("debounce_seconds", self.globalDebounceSeconds()) def volumeIncrementForButton(self, binding): return self.lockedButtonSettings.get(binding, {}).get("volume_increment", self.globalVolumeIncrement()) def pollingSecondsForButton(self, binding): intervalMs = self.lockedButtonSettings.get(binding, {}).get("polling_interval_ms", self.globalPollingIntervalMs()) return intervalMs / 1000 class SpotifySetupDialog(QDialog): def __init__(self): super().__init__() self.setWindowTitle("Spotify Setup") self.clientIdInput = QLineEdit() self.clientSecretInput = QLineEdit() self.clientSecretInput.setEchoMode(QLineEdit.Password) self.redirectUriInput = QLineEdit(DEFAULT_REDIRECT_URI) self.messageLabel = QLabel("Enter your Spotify app credentials before controller setup.") form = QFormLayout() form.addRow("Client ID", self.clientIdInput) form.addRow("Client Secret", self.clientSecretInput) form.addRow("Redirect URI", self.redirectUriInput) self.buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) self.buttons.accepted.connect(self.saveAndAccept) self.buttons.rejected.connect(self.reject) layout = QVBoxLayout() layout.addWidget(self.messageLabel) layout.addLayout(form) layout.addWidget(self.buttons) self.setLayout(layout) self.resize(480, 180) def saveAndAccept(self): clientId = self.clientIdInput.text().strip() clientSecret = self.clientSecretInput.text().strip() redirectUri = self.redirectUriInput.text().strip() if not clientId or not clientSecret: self.messageLabel.setText("Client ID and Client Secret are required.") return saveSpotifySettings(clientId, clientSecret, redirectUri) self.accept() def runGui(): app = QApplication(sys.argv) if not spotifySettingsReady(): setupDialog = SpotifySetupDialog() if setupDialog.exec() != QDialog.Accepted: sys.exit(1) window = ControllerWindow() window.show() sys.exit(app.exec())