Initial public-safe Steerify commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
spotify_config.json
|
||||||
166
abstractify.py
Normal file
166
abstractify.py
Normal file
@@ -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.")
|
||||||
802
controller_gui.py
Normal file
802
controller_gui.py
Normal file
@@ -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())
|
||||||
247
controller_profiles.py
Normal file
247
controller_profiles.py
Normal file
@@ -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
|
||||||
34
launch.bat
Normal file
34
launch.bat
Normal file
@@ -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
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pygame
|
||||||
|
PySide6
|
||||||
|
spotipy
|
||||||
4
spotify.py
Normal file
4
spotify.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from controller_gui import runGui
|
||||||
|
|
||||||
|
|
||||||
|
runGui()
|
||||||
43
spotify_settings.py
Normal file
43
spotify_settings.py
Normal file
@@ -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
|
||||||
40
wheel_bindings.json
Normal file
40
wheel_bindings.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user