From 9503ae42c0b9717453070bcb600f3fdf700aa7b5 Mon Sep 17 00:00:00 2001 From: Aarron Lee Date: Tue, 28 Nov 2023 10:37:11 -0500 Subject: [PATCH] Per game controller maps (#8) * create + fix up controllers slice * add backend methods for controller settings * remap action dropdown successfully saving to redux * bootstrap current game profile values to store * persist controller mappings to backend * start setting up controller remap sync * enable updating hardware button remappings * add per-game controller map toggle * immediately save per-game controller maps to backend + update debounce time * update labels on components --- main.py | 10 ++ py_modules/controller_settings.py | 47 ++++-- src/components/RemapActionDropdown.tsx | 22 +-- src/components/RemapButtons.tsx | 44 +++--- src/hooks/controller.tsx | 38 +++++ src/index.tsx | 4 +- src/redux-modules/controllerSlice.tsx | 200 +++++++++++++++++++++++++ src/redux-modules/logger.tsx | 19 +++ src/redux-modules/rgbSlice.tsx | 2 +- src/redux-modules/store.tsx | 14 +- 10 files changed, 355 insertions(+), 45 deletions(-) create mode 100644 src/hooks/controller.tsx create mode 100644 src/redux-modules/controllerSlice.tsx create mode 100644 src/redux-modules/logger.tsx diff --git a/main.py b/main.py index e8377e0..6a660cd 100644 --- a/main.py +++ b/main.py @@ -39,6 +39,16 @@ async def get_settings(self): async def save_rgb_per_game_profiles_enabled(self, enabled: bool): return settings.set_setting('rgbPerGameProfilesEnabled', enabled) + async def save_controller_settings(self, controller, currentGameId): + controllerProfiles = controller.get('controllerProfiles') + controllerPerGameProfilesEnabled = controller.get('perGameProfilesEnabled') or False + + settings.set_setting('controllerPerGameProfilesEnabled', controllerPerGameProfilesEnabled) + result = settings.set_all_controller_profiles(controllerProfiles) + if currentGameId: + settings.sync_controller_profile_settings(currentGameId) + return result + async def save_rgb_settings(self, rgbProfiles, currentGameId): result = settings.set_all_rgb_profiles(rgbProfiles) if currentGameId: diff --git a/py_modules/controller_settings.py b/py_modules/controller_settings.py index 4442e45..c290be7 100644 --- a/py_modules/controller_settings.py +++ b/py_modules/controller_settings.py @@ -1,5 +1,8 @@ import os +import decky_plugin +import legion_configurator from settings import SettingsManager +from controller_enums import Controller, RemappableButtons, RemapActions settings_directory = os.environ["DECKY_PLUGIN_SETTINGS_DIR"] settings_path = os.path.join(settings_directory, 'settings.json') @@ -78,15 +81,35 @@ def set_all_rgb_profiles(rgb_profiles): ) -def set_game_profile_setting(profileName: str, key: str, value): - setting_file.read() - if not setting_file.settings.get('gameProfiles'): - setting_file.settings['gameProfiles'] = {} - game_profiles = setting_file.settings['gameProfiles'] - if not game_profiles.get(profileName): - game_profiles[profileName] = {} - - setting_file.settings['gameProfiles'][profileName][key] = value - - # save to settings file - setting_file.commit() \ No newline at end of file +def set_all_controller_profiles(controller_profiles): + settings = get_settings() + + if not settings.get('controller'): + settings['controller'] = {} + profiles = settings['controller'] + deep_merge(controller_profiles, profiles) + + setting_file.settings['controller'] = profiles + setting_file.commit() + +def sync_controller_profile_settings(current_game_id): + settings = get_settings() + controller_profile = settings.get('controller').get(current_game_id, {}) + + try: + for remappable_button_name, remap_action in controller_profile.items(): + controller_code = None + if remappable_button_name in ['Y3', 'M2', 'M3']: + controller_code = Controller['RIGHT'].value + elif remappable_button_name in ['Y1', 'Y2']: + controller_code = Controller['LEFT'].value + if not controller_code: + return + + btn_code = RemappableButtons[remappable_button_name].value + action_code = RemapActions[remap_action].value + remap_command = legion_configurator.create_button_remap_command(controller_code, btn_code, action_code) + + legion_configurator.send_command(remap_command) + except Exception as e: + decky_plugin.logger.error(f"sync_controller_profile_settings: {e}") \ No newline at end of file diff --git a/src/components/RemapActionDropdown.tsx b/src/components/RemapActionDropdown.tsx index 6f4b35c..8aeedab 100644 --- a/src/components/RemapActionDropdown.tsx +++ b/src/components/RemapActionDropdown.tsx @@ -1,21 +1,20 @@ -import { useState, FC } from 'react'; +import { FC } from 'react'; import { DropdownItem } from 'decky-frontend-lib'; import { RemapActions, RemappableButtons } from '../backend/constants'; +import { useRemapAction } from '../hooks/controller'; type PropType = { label: string; btn: RemappableButtons; description?: string; - onChange?: any; - logInfo?: any; }; -const RemapActionDropdown: FC = ({ label, btn, onChange }) => { - const [selected, setSelected] = useState(RemapActions.DISABLED); +const RemapActionDropdown: FC = ({ label, btn }) => { + const { remapAction, setRemapAction } = useRemapAction(btn); const dropdownOptions = Object.values(RemapActions).map((action) => { return { data: action, - label: `${action}`, + label: `${action.split('_').join(' ')}`, value: action }; }); @@ -32,12 +31,13 @@ const RemapActionDropdown: FC = ({ label, btn, onChange }) => { value: o.value }; })} - selectedOption={dropdownOptions.find((o) => { - return o.data === selected; - })} + selectedOption={ + dropdownOptions.find((o) => { + return o.data === remapAction; + })?.data || RemapActions.DISABLED + } onChange={(value: any) => { - onChange && onChange(btn, value.data); - setSelected(value.data); + setRemapAction(value.data); }} /> diff --git a/src/components/RemapButtons.tsx b/src/components/RemapButtons.tsx index 91424ff..69e4bcd 100644 --- a/src/components/RemapButtons.tsx +++ b/src/components/RemapButtons.tsx @@ -1,27 +1,37 @@ -import { ServerAPI } from 'decky-frontend-lib'; import { FC } from 'react'; import { RemappableButtons } from '../backend/constants'; import RemapActionDropdown from './RemapActionDropdown'; -import { createServerApiHelpers } from '../backend/utils'; - -const RemapButtons: FC<{ serverAPI: ServerAPI }> = ({ serverAPI }) => { - const { remapButton } = createServerApiHelpers(serverAPI); +import { PanelSection, PanelSectionRow, ToggleField } from 'decky-frontend-lib'; +import { + useControllerPerGameEnabled, + useControllerProfileDisplayName +} from '../hooks/controller'; +const RemapButtons: FC = () => { const btns = Object.values(RemappableButtons); + const displayName = useControllerProfileDisplayName(); + const { controllerPerGameEnabled, setControllerPerGameEnabled } = + useControllerPerGameEnabled(); + + const title = controllerPerGameEnabled + ? `Remap Buttons -\n${displayName.substring(0, 20)}${ + displayName.length > 20 ? '...' : '' + }` + : 'Remap Buttons'; return ( - <> - {btns.map((btn, idx) => { - return ( - - ); - })} - + + + + {btns.map((btn, idx) => { + return ; + })} + + ); }; diff --git a/src/hooks/controller.tsx b/src/hooks/controller.tsx new file mode 100644 index 0000000..1438f30 --- /dev/null +++ b/src/hooks/controller.tsx @@ -0,0 +1,38 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { + controllerSlice, + selectButtonRemapAction, + selectControllerPerGameProfilesEnabled, + selectControllerProfileDisplayName +} from '../redux-modules/controllerSlice'; +import { RemapActions, RemappableButtons } from '../backend/constants'; + +export const useControllerPerGameEnabled = () => { + const controllerPerGameEnabled = useSelector( + selectControllerPerGameProfilesEnabled + ); + const dispatch = useDispatch(); + + const setControllerPerGameEnabled = (enabled: boolean) => { + return dispatch(controllerSlice.actions.setPerGameProfilesEnabled(enabled)); + }; + + return { controllerPerGameEnabled, setControllerPerGameEnabled }; +}; + +export const useRemapAction = (btn: RemappableButtons) => { + const remapAction = useSelector(selectButtonRemapAction(btn)); + const dispatch = useDispatch(); + + const setRemapAction = (remapAction: RemapActions) => { + return dispatch( + controllerSlice.actions.remapButton({ button: btn, remapAction }) + ); + }; + + return { remapAction, setRemapAction }; +}; + +export const useControllerProfileDisplayName = () => { + return useSelector(selectControllerProfileDisplayName); +}; diff --git a/src/index.tsx b/src/index.tsx index 48765a8..0502267 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import { memo, VFC } from 'react'; import { FaShip } from 'react-icons/fa'; // import { createServerApiHelpers } from './backend/utils'; -// import RemapButtons from './components/RemapButtons'; +import RemapButtons from './components/RemapButtons'; import ControllerLightingPanel from './components/ControllerLightingPanel'; import { createServerApiHelpers, saveServerApi } from './backend/utils'; import { store } from './redux-modules/store'; @@ -20,7 +20,7 @@ const Content: VFC<{ serverAPI: ServerAPI }> = memo(({ serverAPI }) => { return ( <> - {/* */} + ); }); diff --git a/src/redux-modules/controllerSlice.tsx b/src/redux-modules/controllerSlice.tsx new file mode 100644 index 0000000..02c57a7 --- /dev/null +++ b/src/redux-modules/controllerSlice.tsx @@ -0,0 +1,200 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import { get, set, merge } from 'lodash'; +import type { RootState } from './store'; +import { setCurrentGameId, setInitialState } from './extraActions'; +import { extractCurrentGameId, getServerApi, logInfo } from '../backend/utils'; +import { RemapActions, RemappableButtons } from '../backend/constants'; +import { Router } from 'decky-frontend-lib'; + +const DEFAULT_CONTROLLER_VALUES = { + Y1: RemapActions.DISABLED, + Y2: RemapActions.DISABLED, + Y3: RemapActions.DISABLED, + M2: RemapActions.DISABLED, + M3: RemapActions.DISABLED +}; + +type ControllerProfile = { + Y1: RemapActions; + Y2: RemapActions; + Y3: RemapActions; + M2: RemapActions; + M3: RemapActions; +}; + +type ControllerProfiles = { + [gameId: string]: ControllerProfile; +}; + +type ControllerState = { + controllerProfiles: ControllerProfiles; + perGameProfilesEnabled: boolean; +}; + +const initialState: ControllerState = { + controllerProfiles: {}, + perGameProfilesEnabled: false +}; + +export const controllerSlice = createSlice({ + name: 'controller', + initialState, + reducers: { + setPerGameProfilesEnabled: (state, action: PayloadAction) => { + const enabled = action.payload; + state.perGameProfilesEnabled = enabled; + if (enabled) { + bootstrapControllerProfile(state, extractCurrentGameId()); + } + }, + remapButton: ( + state, + action: PayloadAction<{ + button: RemappableButtons; + remapAction: RemapActions; + }> + ) => { + const { button, remapAction } = action.payload; + setStateValue({ + sliceState: state, + key: button, + value: remapAction + }); + }, + updateControllerProfiles: ( + state, + action: PayloadAction + ) => { + merge(state.controllerProfiles, action.payload); + } + }, + extraReducers: (builder) => { + builder.addCase(setInitialState, (state, action) => { + const controllerProfiles = action.payload + .controller as ControllerProfiles; + + const perGameProfilesEnabled = Boolean( + action.payload.controllerPerGameProfilesEnabled + ); + + state.controllerProfiles = controllerProfiles; + state.perGameProfilesEnabled = perGameProfilesEnabled; + }); + builder.addCase(setCurrentGameId, (state, action) => { + /* + currentGameIdChanged, check if exists in redux store. + if not exists, bootstrap it on frontend + */ + const newGameId = action.payload as string; + bootstrapControllerProfile(state, newGameId); + }); + } +}); + +// ------------- +// selectors +// ------------- + +export const selectControllerPerGameProfilesEnabled = (state: RootState) => { + return state.controller.perGameProfilesEnabled; +}; + +export const selectButtonRemapAction = + (button: RemappableButtons) => (state: RootState) => { + if (state.controller.perGameProfilesEnabled) { + return get( + state, + `controller.controllerProfiles.${extractCurrentGameId()}.${button}` + ); + } else { + return get(state, `controller.controllerProfiles.default.${button}`); + } + }; + +export const selectControllerProfileDisplayName = (state: RootState) => { + if (state.controller.perGameProfilesEnabled) { + return Router.MainRunningApp?.display_name || 'Default'; + } else { + return 'Default'; + } +}; + +// ------------- +// middleware +// ------------- + +const mutatingActionTypes = [ + controllerSlice.actions.setPerGameProfilesEnabled.type, + controllerSlice.actions.remapButton.type, + controllerSlice.actions.updateControllerProfiles.type, + setCurrentGameId.type +]; + +export const saveControllerSettingsMiddleware = + (store: any) => (next: any) => (action: any) => { + const { type } = action; + const serverApi = getServerApi(); + + const result = next(action); + + if (mutatingActionTypes.includes(type)) { + // save changes to backend + const { + controller: { controllerProfiles, perGameProfilesEnabled } + } = store.getState(); + let currentGameId; + if (perGameProfilesEnabled) { + currentGameId = extractCurrentGameId(); + } else { + currentGameId = 'default'; + } + + const controller = { controllerProfiles, perGameProfilesEnabled }; + + serverApi?.callPluginMethod('save_controller_settings', { + controller, + currentGameId + }); + } + + return result; + }; + +// ------------- +// Slice Util functions +// ------------- + +function setStateValue({ + sliceState, + key, + value +}: { + sliceState: ControllerState; + key: string; + value: any; +}) { + if (sliceState.perGameProfilesEnabled) { + const currentGameId = extractCurrentGameId(); + set(sliceState, `controllerProfiles.${currentGameId}.${key}`, value); + } else { + set(sliceState, `controllerProfiles.default.${key}`, value); + } +} + +function bootstrapControllerProfile(state: ControllerState, newGameId: string) { + if (!state.controllerProfiles) { + state.controllerProfiles = {}; + } + if ( + // only initialize profile if perGameProfiles are enabled + (!state.controllerProfiles[newGameId] && state.perGameProfilesEnabled) || + // always initialize default + newGameId === 'default' + ) { + const defaultProfile = state.controllerProfiles?.default; + const newControllerProfile = defaultProfile || DEFAULT_CONTROLLER_VALUES; + + state.controllerProfiles[newGameId] = newControllerProfile; + } +} diff --git a/src/redux-modules/logger.tsx b/src/redux-modules/logger.tsx new file mode 100644 index 0000000..31db9c2 --- /dev/null +++ b/src/redux-modules/logger.tsx @@ -0,0 +1,19 @@ +import { logInfo } from '../backend/utils'; + +export const logger = (store: any) => (next: any) => (action: any) => { + const { type } = action; + logInfo('before-------------'); + logInfo(type); + logInfo(action.payload); + logInfo(store.getState()); + logInfo('-------------'); + + const result = next(action); + + logInfo('after-------------'); + logInfo(type); + logInfo(action.payload); + logInfo(store.getState()); + logInfo('-------------'); + return result; +}; diff --git a/src/redux-modules/rgbSlice.tsx b/src/redux-modules/rgbSlice.tsx index 2cdcdb6..5f354dc 100644 --- a/src/redux-modules/rgbSlice.tsx +++ b/src/redux-modules/rgbSlice.tsx @@ -300,7 +300,7 @@ const saveRgbSettings = (store: any) => { }); }; -const debouncedSaveRgbSettings = debounce(saveRgbSettings, 100); +const debouncedSaveRgbSettings = debounce(saveRgbSettings, 500); export const saveRgbSettingsMiddleware = (store: any) => (next: any) => (action: any) => { diff --git a/src/redux-modules/store.tsx b/src/redux-modules/store.tsx index 66134c3..e48f978 100644 --- a/src/redux-modules/store.tsx +++ b/src/redux-modules/store.tsx @@ -1,14 +1,24 @@ import { configureStore } from '@reduxjs/toolkit'; import { rgbSlice, saveRgbSettingsMiddleware } from './rgbSlice'; import { uiSlice } from './uiSlice'; +import { + controllerSlice, + saveControllerSettingsMiddleware +} from './controllerSlice'; +// import { logger } from './logger'; export const store = configureStore({ reducer: { ui: uiSlice.reducer, - rgb: rgbSlice.reducer + rgb: rgbSlice.reducer, + controller: controllerSlice.reducer }, middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat([saveRgbSettingsMiddleware]) + getDefaultMiddleware().concat([ + saveRgbSettingsMiddleware, + saveControllerSettingsMiddleware + // logger + ]) }); // Infer the `RootState` and `AppDispatch` types from the store itself