From 80239d27a6e6f5c9864dc78ec42d2081c01fdf8d Mon Sep 17 00:00:00 2001 From: Chelsea Date: Tue, 12 May 2026 17:58:47 -0500 Subject: [PATCH] Initial public-safe Steerify commit --- .gitignore | 1 + abstractify.py | 166 +++++++++ controller_gui.py | 802 +++++++++++++++++++++++++++++++++++++++++ controller_profiles.py | 247 +++++++++++++ launch.bat | 34 ++ requirements.txt | 3 + spotify.py | 4 + spotify_settings.py | 43 +++ wheel_bindings.json | 40 ++ 9 files changed, 1340 insertions(+) create mode 100644 .gitignore create mode 100644 abstractify.py create mode 100644 controller_gui.py create mode 100644 controller_profiles.py create mode 100644 launch.bat create mode 100644 requirements.txt create mode 100644 spotify.py create mode 100644 spotify_settings.py create mode 100644 wheel_bindings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f194ba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +spotify_config.json diff --git a/abstractify.py b/abstractify.py new file mode 100644 index 0000000..2d319f2 --- /dev/null +++ b/abstractify.py @@ -0,0 +1,166 @@ +import spotipy +from spotipy.oauth2 import SpotifyOAuth +from spotipy.exceptions import SpotifyException +from spotify_settings import getSpotifySettings + + +class spotifyAbstract: + sp = None + previousVolume = 20 + + @staticmethod + def SpotifyControl(): + if spotifyAbstract.sp is None: + spotifySettings = getSpotifySettings() + spotifyAbstract.sp = spotipy.Spotify(auth_manager=SpotifyOAuth( + client_id=spotifySettings["client_id"], + client_secret=spotifySettings["client_secret"], + redirect_uri=spotifySettings["redirect_uri"], + scope="user-modify-playback-state user-read-playback-state" + )) + + return spotifyAbstract.sp + + @staticmethod + def currentPlayback(): + try: + return spotifyAbstract.SpotifyControl().current_playback() + except SpotifyException as error: + print("Could not check Spotify playback.") + print(error) + return None + + @staticmethod + def somethingPlaying(): + playback = spotifyAbstract.currentPlayback() + + if playback is None: + return False + + return playback["is_playing"] + + @staticmethod + def getCurrentVolume(): + playback = spotifyAbstract.currentPlayback() + + if playback is None: + return None + + if playback["device"] is None: + return None + + return playback["device"]["volume_percent"] + + @staticmethod + def setVolume(volume): + if volume < 0: + volume = 0 + + if volume > 100: + volume = 100 + + try: + spotifyAbstract.SpotifyControl().volume(volume) + print(f"Spotify volume set to {volume}%") + except SpotifyException as error: + print("Could not set Spotify volume.") + print(error) + + @staticmethod + def pause(): + if not spotifyAbstract.somethingPlaying(): + print("Spotify is not currently playing. Not sending pause command.") + return + + try: + spotifyAbstract.SpotifyControl().pause_playback() + print("Spotify paused.") + except SpotifyException as error: + print("Could not pause Spotify.") + print(error) + + @staticmethod + def play(): + try: + spotifyAbstract.SpotifyControl().start_playback() + print("Spotify started.") + except SpotifyException as error: + print("Could not start Spotify.") + print(error) + + @staticmethod + def togglePlayPause(): + if spotifyAbstract.somethingPlaying(): + spotifyAbstract.pause() + else: + spotifyAbstract.play() + + @staticmethod + def nextTrack(): + if not spotifyAbstract.somethingPlaying(): + print("Spotify is not currently playing. Not sending next-track command.") + return + + try: + spotifyAbstract.SpotifyControl().next_track() + print("Skipped to next track.") + except SpotifyException as error: + print("Could not skip track.") + print(error) + + @staticmethod + def previousTrack(): + if not spotifyAbstract.somethingPlaying(): + print("Spotify is not currently playing. Not sending previous-track command.") + return + + try: + spotifyAbstract.SpotifyControl().previous_track() + print("Went to previous track.") + except SpotifyException as error: + print("Could not go to previous track.") + print(error) + + @staticmethod + def volumeUp(): + spotifyAbstract.volumeUpBy(5) + + @staticmethod + def volumeUpBy(increment): + volume = spotifyAbstract.getCurrentVolume() + + if volume is None: + print("Could not read current Spotify volume.") + return + + spotifyAbstract.setVolume(volume + increment) + + @staticmethod + def volumeDown(): + spotifyAbstract.volumeDownBy(5) + + @staticmethod + def volumeDownBy(increment): + volume = spotifyAbstract.getCurrentVolume() + + if volume is None: + print("Could not read current Spotify volume.") + return + + spotifyAbstract.setVolume(volume - increment) + + @staticmethod + def muteToggle(): + volume = spotifyAbstract.getCurrentVolume() + + if volume is None: + print("Could not read current Spotify volume.") + return + + if volume > 0: + spotifyAbstract.previousVolume = volume + spotifyAbstract.setVolume(0) + print("Spotify muted.") + else: + spotifyAbstract.setVolume(spotifyAbstract.previousVolume) + print("Spotify unmuted.") diff --git a/controller_gui.py b/controller_gui.py new file mode 100644 index 0000000..4aec7be --- /dev/null +++ b/controller_gui.py @@ -0,0 +1,802 @@ +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()) diff --git a/controller_profiles.py b/controller_profiles.py new file mode 100644 index 0000000..cbefd4a --- /dev/null +++ b/controller_profiles.py @@ -0,0 +1,247 @@ +import copy +import json +import uuid +from pathlib import Path + + +CONFIG_PATH = Path(__file__).with_name("wheel_bindings.json") + +ACTION_ORDER = [ + "spotify pause play", + "spotify next track", + "spotify previous track", + "spotify vol up", + "spotify vol down", + "spotify mute toggle", +] + +ACTION_LABELS = { + "": "None", + "spotify pause play": "Pause / Play", + "spotify next track": "Next Track", + "spotify previous track": "Previous Track", + "spotify vol up": "Volume Up", + "spotify vol down": "Volume Down", + "spotify mute toggle": "Mute Toggle", +} + +DEFAULT_WHEEL_PROFILE_ID = "friend_directinput_wheel" + +DEFAULT_WHEEL_BINDINGS = { + "button:33": "spotify vol up", + "button:31": "spotify vol down", + "button:22": "spotify next track", + "button:16": "spotify previous track", + "button:21": "spotify pause play", + "button:19": "spotify mute toggle", +} + +XBOX_360_BINDINGS = { + "button:0": "spotify pause play", + "button:1": "spotify next track", + "button:2": "spotify previous track", + "button:3": "spotify vol up", + "button:4": "spotify vol down", + "button:5": "spotify mute toggle", +} + + +def makeDefaultConfig(): + return { + "version": 1, + "default_profile_id": DEFAULT_WHEEL_PROFILE_ID, + "button_presses_logged": True, + "global_settings": { + "debounce_seconds": 0.25, + "volume_increment": 5, + "polling_interval_ms": 120, + }, + "profiles": [ + { + "id": DEFAULT_WHEEL_PROFILE_ID, + "name": "Friend DirectInput Wheel", + "controller": None, + "bindings": copy.deepcopy(DEFAULT_WHEEL_BINDINGS), + "button_settings": {}, + }, + { + "id": "xbox_360_default", + "name": "Xbox 360 Default", + "controller": None, + "bindings": copy.deepcopy(XBOX_360_BINDINGS), + "button_settings": {}, + }, + ], + } + + +def loadConfig(): + if not CONFIG_PATH.exists(): + config = makeDefaultConfig() + saveConfig(config) + return config + + with CONFIG_PATH.open("r", encoding="utf-8") as file: + data = json.load(file) + + if isFlatBindingFile(data): + config = makeDefaultConfig() + config["profiles"][0]["bindings"] = data + saveConfig(config) + return config + + config = normalizeConfig(data) + saveConfig(config) + return config + + +def saveConfig(config): + with CONFIG_PATH.open("w", encoding="utf-8") as file: + json.dump(config, file, indent=4) + file.write("\n") + + +def isFlatBindingFile(data): + if not isinstance(data, dict): + return False + + if "profiles" in data: + return False + + return all(key.startswith("button:") for key in data.keys()) + + +def normalizeConfig(config): + defaultConfig = makeDefaultConfig() + + if not isinstance(config, dict): + return defaultConfig + + config.setdefault("version", 1) + config.setdefault("default_profile_id", DEFAULT_WHEEL_PROFILE_ID) + config.setdefault("button_presses_logged", True) + config.setdefault("global_settings", {}) + config["global_settings"].setdefault("debounce_seconds", 0.25) + config["global_settings"].setdefault("volume_increment", 5) + config["global_settings"].setdefault("polling_interval_ms", 120) + config.setdefault("profiles", []) + + existingProfileIds = {profile.get("id") for profile in config["profiles"] if isinstance(profile, dict)} + + for defaultProfile in defaultConfig["profiles"]: + if defaultProfile["id"] not in existingProfileIds: + config["profiles"].append(defaultProfile) + + for profile in config["profiles"]: + profile.setdefault("id", makeProfileId(profile.get("name", "Controller"))) + profile.setdefault("name", "Controller") + profile.setdefault("controller", None) + profile.setdefault("bindings", {}) + profile.setdefault("button_settings", {}) + + return config + + +def getProfile(config, profileId): + for profile in config.get("profiles", []): + if profile.get("id") == profileId: + return profile + + return None + + +def getDefaultProfile(config): + profile = getProfile(config, config.get("default_profile_id")) + + if profile is not None: + return profile + + return config["profiles"][0] + + +def makeProfileId(profileName): + cleaned = "".join(character.lower() if character.isalnum() else "_" for character in profileName) + cleaned = "_".join(part for part in cleaned.split("_") if part) + + if not cleaned: + cleaned = "controller" + + return f"{cleaned}_{uuid.uuid4().hex[:8]}" + + +def controllerFromJoystick(joystick): + joystick.init() + + return { + "index": joystick.get_id(), + "instance_id": joystick.get_instance_id(), + "name": joystick.get_name(), + "guid": joystick.get_guid(), + "numaxes": joystick.get_numaxes(), + "numbuttons": joystick.get_numbuttons(), + "numhats": joystick.get_numhats(), + } + + +def stableControllerIdentity(controller): + return { + "name": controller.get("name"), + "guid": controller.get("guid"), + "numaxes": controller.get("numaxes"), + "numbuttons": controller.get("numbuttons"), + "numhats": controller.get("numhats"), + } + + +def profileHasController(profile): + controller = profile.get("controller") + + return isinstance(controller, dict) and bool(controller.get("name") or controller.get("guid")) + + +def controllerMatches(profile, controller): + storedController = profile.get("controller") + + if not isinstance(storedController, dict): + return False + + if storedController.get("guid") and controller.get("guid"): + return storedController.get("guid") == controller.get("guid") + + return ( + storedController.get("name") == controller.get("name") + and storedController.get("numaxes") == controller.get("numaxes") + and storedController.get("numbuttons") == controller.get("numbuttons") + and storedController.get("numhats") == controller.get("numhats") + ) + + +def findMatchingProfile(config, controller): + for profile in config.get("profiles", []): + if controllerMatches(profile, controller): + return profile + + return None + + +def saveControllerProfile(config, controller, profileName, bindings, buttonSettings=None, existingProfileId=None): + profile = getProfile(config, existingProfileId) if existingProfileId else None + + if profile is None or not profileHasController(profile): + profile = { + "id": makeProfileId(profileName), + "name": profileName, + "controller": stableControllerIdentity(controller), + "bindings": {}, + "button_settings": {}, + } + config["profiles"].append(profile) + + profile["name"] = profileName + profile["controller"] = stableControllerIdentity(controller) + profile["bindings"] = copy.deepcopy(bindings) + profile["button_settings"] = copy.deepcopy(buttonSettings or {}) + + saveConfig(config) + + return profile diff --git a/launch.bat b/launch.bat new file mode 100644 index 0000000..7234b66 --- /dev/null +++ b/launch.bat @@ -0,0 +1,34 @@ +@echo off +setlocal + +cd /d "%~dp0" + +where py >nul 2>nul +if not errorlevel 1 ( + set "PYTHON=py" + goto python_found +) + +where python >nul 2>nul +if not errorlevel 1 ( + set "PYTHON=python" + goto python_found +) + +echo Python was not found. Install Python 3 and try again. +pause +exit /b 1 + +:python_found +echo Installing requirements... +%PYTHON% -m pip install -r requirements.txt +if errorlevel 1 ( + echo Failed to install requirements. + pause + exit /b 1 +) + +echo Launching spotify.py... +%PYTHON% spotify.py + +pause diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e0b1507 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pygame +PySide6 +spotipy diff --git a/spotify.py b/spotify.py new file mode 100644 index 0000000..9d8ab92 --- /dev/null +++ b/spotify.py @@ -0,0 +1,4 @@ +from controller_gui import runGui + + +runGui() diff --git a/spotify_settings.py b/spotify_settings.py new file mode 100644 index 0000000..3cd0dd2 --- /dev/null +++ b/spotify_settings.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path + + +CONFIG_PATH = Path(__file__).with_name("spotify_config.json") +DEFAULT_REDIRECT_URI = "http://127.0.0.1:4002/auth" + + +def loadSpotifySettings(): + if not CONFIG_PATH.exists(): + return {} + + with CONFIG_PATH.open("r", encoding="utf-8") as file: + return json.load(file) + + +def saveSpotifySettings(clientId, clientSecret, redirectUri=None): + settings = { + "client_id": clientId.strip(), + "client_secret": clientSecret.strip(), + "redirect_uri": (redirectUri or DEFAULT_REDIRECT_URI).strip(), + } + + with CONFIG_PATH.open("w", encoding="utf-8") as file: + json.dump(settings, file, indent=4) + file.write("\n") + + +def spotifySettingsReady(): + settings = loadSpotifySettings() + + return bool(settings.get("client_id") and settings.get("client_secret")) + + +def getSpotifySettings(): + settings = loadSpotifySettings() + + if not settings.get("client_id") or not settings.get("client_secret"): + raise RuntimeError("Spotify setup is incomplete.") + + settings.setdefault("redirect_uri", DEFAULT_REDIRECT_URI) + + return settings diff --git a/wheel_bindings.json b/wheel_bindings.json new file mode 100644 index 0000000..5731b8b --- /dev/null +++ b/wheel_bindings.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "default_profile_id": "friend_directinput_wheel", + "button_presses_logged": true, + "global_settings": { + "debounce_seconds": 0.25, + "volume_increment": 5, + "polling_interval_ms": 120 + }, + "profiles": [ + { + "id": "friend_directinput_wheel", + "name": "Friend DirectInput Wheel", + "controller": null, + "bindings": { + "button:33": "spotify vol up", + "button:31": "spotify vol down", + "button:22": "spotify next track", + "button:16": "spotify previous track", + "button:21": "spotify pause play", + "button:19": "spotify mute toggle" + }, + "button_settings": {} + }, + { + "id": "xbox_360_default", + "name": "Xbox 360 Default", + "controller": null, + "bindings": { + "button:0": "spotify pause play", + "button:1": "spotify next track", + "button:2": "spotify previous track", + "button:3": "spotify vol up", + "button:4": "spotify vol down", + "button:5": "spotify mute toggle" + }, + "button_settings": {} + } + ] +}