mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-08-06 13:18:27 +02:00
Controller customization feature
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
import type { ControllerCustomizationPresetData, ControllerCustomizationPresetRecord } from "@/types/presets";
|
||||
import { BaseProfileManagerDialog } from "./base-profile-manager-dialog";
|
||||
import { ControllerCustomizationsTable } from "@/utils/local-db/controller-customizations-table";
|
||||
import { t } from "@/utils/translation";
|
||||
import { GamepadKey, GamepadKeyName } from "@/enums/gamepad";
|
||||
import { ButtonStyle, CE, createButton, createSettingRow } from "@/utils/html";
|
||||
import { BxSelectElement } from "@/web-components/bx-select";
|
||||
import { PrefKey } from "@/enums/pref-keys";
|
||||
import { getPref } from "@/utils/settings-storages/global-settings-storage";
|
||||
import { BxEvent } from "@/utils/bx-event";
|
||||
import { deepClone } from "@/utils/global";
|
||||
import { StreamSettings } from "@/utils/stream-settings";
|
||||
import { BxDualNumberStepper } from "@/web-components/bx-dual-number-stepper";
|
||||
import { NavigationDirection, type NavigationElement } from "../navigation-dialog";
|
||||
import { setNearby } from "@/utils/navigation-utils";
|
||||
import type { DualNumberStepperParams } from "@/types/setting-definition";
|
||||
import { BxNumberStepper } from "@/web-components/bx-number-stepper";
|
||||
|
||||
export class ControllerCustomizationsManagerDialog extends BaseProfileManagerDialog<ControllerCustomizationPresetRecord> {
|
||||
private static instance: ControllerCustomizationsManagerDialog;
|
||||
public static getInstance = () => ControllerCustomizationsManagerDialog.instance ?? (ControllerCustomizationsManagerDialog.instance = new ControllerCustomizationsManagerDialog(t('controller-customization')));
|
||||
|
||||
declare protected $content: HTMLElement;
|
||||
private $vibrationIntensity!: BxNumberStepper;
|
||||
private $leftTriggerRange!: BxDualNumberStepper;
|
||||
private $rightTriggerRange!: BxDualNumberStepper;
|
||||
private $leftStickDeadzone!: BxDualNumberStepper;
|
||||
private $rightStickDeadzone!: BxDualNumberStepper;
|
||||
private $btnDetect!: HTMLButtonElement;
|
||||
|
||||
protected BLANK_PRESET_DATA = {
|
||||
mapping: {},
|
||||
settings: {
|
||||
leftTriggerRange: [0, 100],
|
||||
rightTriggerRange: [0, 100],
|
||||
leftStickDeadzone: [0, 100],
|
||||
rightStickDeadzone: [0, 100],
|
||||
|
||||
vibrationIntensity: 100,
|
||||
},
|
||||
} satisfies ControllerCustomizationPresetData;
|
||||
|
||||
private selectsMap: Partial<Record<GamepadKey, HTMLSelectElement>> = {};
|
||||
private selectsOrder: GamepadKey[] = [];
|
||||
|
||||
private isDetectingButton: boolean = false;
|
||||
private detectIntervalId: number | null = null;
|
||||
|
||||
private readonly BUTTONS_ORDER = [
|
||||
GamepadKey.A, GamepadKey.B,
|
||||
GamepadKey.X, GamepadKey.Y,
|
||||
|
||||
GamepadKey.UP, GamepadKey.RIGHT,
|
||||
GamepadKey.DOWN, GamepadKey.LEFT,
|
||||
|
||||
GamepadKey.LB, GamepadKey.RB,
|
||||
GamepadKey.LT, GamepadKey.RT,
|
||||
|
||||
GamepadKey.SELECT, GamepadKey.START,
|
||||
|
||||
GamepadKey.L3, GamepadKey.R3,
|
||||
GamepadKey.LS, GamepadKey.RS,
|
||||
|
||||
GamepadKey.SHARE,
|
||||
];
|
||||
|
||||
constructor(title: string) {
|
||||
super(title, ControllerCustomizationsTable.getInstance());
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
const isControllerFriendly = getPref(PrefKey.UI_CONTROLLER_FRIENDLY);
|
||||
const $rows = CE('div', { class: 'bx-buttons-grid' });
|
||||
|
||||
const $baseSelect = CE('select', { class: 'bx-full-width' },
|
||||
CE('option', { value: '' }, '---'),
|
||||
CE('option', { value: 'false', _dataset: { label: '🚫' } }, isControllerFriendly ? '🚫' : t('off')),
|
||||
);
|
||||
const $baseButtonSelect = $baseSelect.cloneNode(true);
|
||||
const $baseStickSelect = $baseSelect.cloneNode(true);
|
||||
|
||||
const onButtonChanged = (e: Event) => {
|
||||
// Update preset
|
||||
if (!(e as any).ignoreOnChange) {
|
||||
this.updatePreset();
|
||||
}
|
||||
};
|
||||
|
||||
const boundUpdatePreset = this.updatePreset.bind(this);
|
||||
|
||||
for (const gamepadKey of this.BUTTONS_ORDER) {
|
||||
if (gamepadKey === GamepadKey.SHARE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = GamepadKeyName[gamepadKey][isControllerFriendly ? 1 : 0];
|
||||
const $target = (gamepadKey === GamepadKey.LS || gamepadKey === GamepadKey.RS) ? $baseStickSelect : $baseButtonSelect;
|
||||
$target.appendChild(CE('option', {
|
||||
value: gamepadKey,
|
||||
_dataset: { label: GamepadKeyName[gamepadKey][1] },
|
||||
}, name));
|
||||
}
|
||||
|
||||
for (const gamepadKey of this.BUTTONS_ORDER) {
|
||||
const [buttonName, buttonPrompt] = GamepadKeyName[gamepadKey];
|
||||
const $sourceSelect = (gamepadKey === GamepadKey.LS || gamepadKey === GamepadKey.RS) ? $baseStickSelect : $baseButtonSelect;
|
||||
|
||||
// Remove current button from selection
|
||||
const $clonedSelect = $sourceSelect.cloneNode(true) as HTMLSelectElement;
|
||||
$clonedSelect.querySelector(`option[value="${gamepadKey}"]`)?.remove();
|
||||
|
||||
const $select = BxSelectElement.create($clonedSelect);
|
||||
$select.dataset.index = gamepadKey.toString();
|
||||
$select.addEventListener('input', onButtonChanged);
|
||||
|
||||
this.selectsMap[gamepadKey] = $select;
|
||||
this.selectsOrder.push(gamepadKey);
|
||||
|
||||
const $row = CE('div', {
|
||||
class: 'bx-controller-key-row',
|
||||
_nearby: { orientation: 'horizontal' },
|
||||
},
|
||||
CE('label', { title: buttonName }, buttonPrompt),
|
||||
$select,
|
||||
);
|
||||
|
||||
$rows.append($row);
|
||||
}
|
||||
|
||||
// Map nearby elenemts for controller-friendly UI
|
||||
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
|
||||
for (let i = 0; i < this.selectsOrder.length; i++) {
|
||||
const $select = this.selectsMap[this.selectsOrder[i] as unknown as GamepadKey] as NavigationElement;
|
||||
const directions = {
|
||||
[NavigationDirection.UP]: i - 2,
|
||||
[NavigationDirection.DOWN]: i + 2,
|
||||
[NavigationDirection.LEFT]: i - 1,
|
||||
[NavigationDirection.RIGHT]: i + 1,
|
||||
};
|
||||
|
||||
for (const dir in directions) {
|
||||
const idx = directions[dir as unknown as NavigationDirection];
|
||||
if (typeof this.selectsOrder[idx] === 'undefined') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const $targetSelect = this.selectsMap[this.selectsOrder[idx] as unknown as GamepadKey];
|
||||
setNearby($select, {
|
||||
[dir]: $targetSelect,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const params: DualNumberStepperParams = {
|
||||
min: 0,
|
||||
minDiff: 1,
|
||||
max: 100,
|
||||
|
||||
steps: 1,
|
||||
};
|
||||
this.$content = CE('div', { class: 'bx-controller-customizations-container' },
|
||||
// Detect button
|
||||
this.$btnDetect = createButton({
|
||||
label: t('detect-controller-button'),
|
||||
classes: ['bx-btn-detect'],
|
||||
style: ButtonStyle.NORMAL_CASE | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH,
|
||||
onClick: () => {
|
||||
this.startDetectingButton();
|
||||
},
|
||||
}),
|
||||
|
||||
// Mapping
|
||||
$rows,
|
||||
|
||||
// Vibration intensity
|
||||
createSettingRow(t('vibration-intensity'),
|
||||
this.$vibrationIntensity = BxNumberStepper.create('controller_vibration_intensity', 50, 0, 100, {
|
||||
steps: 10,
|
||||
suffix: '%',
|
||||
exactTicks: 20,
|
||||
customTextValue: (value: any) => {
|
||||
value = parseInt(value);
|
||||
return value === 0 ? t('off') : value + '%';
|
||||
},
|
||||
}, boundUpdatePreset),
|
||||
),
|
||||
|
||||
// Range settings
|
||||
createSettingRow(t('left-trigger-range'),
|
||||
this.$leftTriggerRange = BxDualNumberStepper.create('left-trigger-range', this.BLANK_PRESET_DATA.settings.leftTriggerRange, params, boundUpdatePreset),
|
||||
),
|
||||
|
||||
createSettingRow(t('right-trigger-range'),
|
||||
this.$rightTriggerRange = BxDualNumberStepper.create('right-trigger-range', this.BLANK_PRESET_DATA.settings.rightTriggerRange, params, boundUpdatePreset),
|
||||
),
|
||||
|
||||
createSettingRow(t('left-stick-deadzone'),
|
||||
this.$leftStickDeadzone = BxDualNumberStepper.create('left-stick-deadzone', this.BLANK_PRESET_DATA.settings.leftStickDeadzone, params, boundUpdatePreset),
|
||||
),
|
||||
|
||||
createSettingRow(t('right-stick-deadzone'),
|
||||
this.$rightStickDeadzone = BxDualNumberStepper.create('right-stick-deadzone', this.BLANK_PRESET_DATA.settings.rightStickDeadzone, params, boundUpdatePreset),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private startDetectingButton() {
|
||||
this.isDetectingButton = true;
|
||||
|
||||
const { $btnDetect } = this;
|
||||
$btnDetect.classList.add('bx-monospaced', 'bx-blink-me');
|
||||
$btnDetect.disabled = true;
|
||||
|
||||
let count = 4;
|
||||
$btnDetect.textContent = `[${count}] ${t('press-any-button')}`;
|
||||
|
||||
this.detectIntervalId = window.setInterval(() => {
|
||||
count -= 1;
|
||||
if (count === 0) {
|
||||
this.stopDetectingButton();
|
||||
|
||||
// Re-focus the Detect button
|
||||
$btnDetect.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
$btnDetect.textContent = `[${count}] ${t('press-any-button')}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopDetectingButton() {
|
||||
const { $btnDetect } = this;
|
||||
$btnDetect.classList.remove('bx-monospaced', 'bx-blink-me');
|
||||
$btnDetect.textContent = t('detect-controller-button');
|
||||
$btnDetect.disabled = false;
|
||||
|
||||
this.isDetectingButton = false;
|
||||
this.detectIntervalId && window.clearInterval(this.detectIntervalId);
|
||||
this.detectIntervalId = null;
|
||||
}
|
||||
|
||||
async onBeforeMount() {
|
||||
this.stopDetectingButton();
|
||||
super.onBeforeMount(...arguments);
|
||||
}
|
||||
|
||||
onBeforeUnmount(): void {
|
||||
this.stopDetectingButton();
|
||||
StreamSettings.refreshControllerSettings();
|
||||
super.onBeforeUnmount();
|
||||
}
|
||||
|
||||
handleGamepad(button: GamepadKey): boolean {
|
||||
if (!this.isDetectingButton) {
|
||||
return super.handleGamepad(button);
|
||||
}
|
||||
|
||||
if (button in this.BUTTONS_ORDER) {
|
||||
this.stopDetectingButton();
|
||||
|
||||
const $select = this.selectsMap[button]!;
|
||||
const $label = $select.previousElementSibling!;
|
||||
$label.addEventListener('animationend', () => {
|
||||
$label.classList.remove('bx-horizontal-shaking');
|
||||
}, { once: true });
|
||||
$label.classList.add('bx-horizontal-shaking');
|
||||
|
||||
// Focus select
|
||||
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
|
||||
this.dialogManager.focus($select);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected switchPreset(id: number): void {
|
||||
const preset = this.allPresets.data[id];
|
||||
if (!preset) {
|
||||
this.currentPresetId = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
$btnDetect,
|
||||
$vibrationIntensity,
|
||||
$leftStickDeadzone,
|
||||
$rightStickDeadzone,
|
||||
$leftTriggerRange,
|
||||
$rightTriggerRange,
|
||||
selectsMap,
|
||||
} = this;
|
||||
|
||||
const presetData = preset.data;
|
||||
this.currentPresetId = id;
|
||||
const isDefaultPreset = id <= 0;
|
||||
this.updateButtonStates();
|
||||
|
||||
// Show/hide Detect button
|
||||
$btnDetect.classList.toggle('bx-gone', isDefaultPreset);
|
||||
|
||||
// Set mappings
|
||||
let buttonIndex: unknown;
|
||||
for (buttonIndex in selectsMap) {
|
||||
buttonIndex = buttonIndex as GamepadKey;
|
||||
|
||||
const $select = selectsMap[buttonIndex as GamepadKey];
|
||||
if (!$select) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mappedButton = presetData.mapping[buttonIndex as GamepadKey];
|
||||
|
||||
$select.value = typeof mappedButton === 'undefined' ? '' : mappedButton.toString();
|
||||
$select.disabled = isDefaultPreset;
|
||||
|
||||
BxEvent.dispatch($select, 'input', {
|
||||
ignoreOnChange: true,
|
||||
manualTrigger: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Add missing settings
|
||||
presetData.settings = Object.assign(this.BLANK_PRESET_DATA.settings, presetData.settings);
|
||||
|
||||
// Vibration intensity
|
||||
$vibrationIntensity.value = presetData.settings.vibrationIntensity.toString();
|
||||
$vibrationIntensity.dataset.disabled = isDefaultPreset.toString();
|
||||
|
||||
// Set extra settings
|
||||
$leftStickDeadzone.dataset.disabled = $rightStickDeadzone.dataset.disabled = $leftTriggerRange.dataset.disabled = $rightTriggerRange.dataset.disabled = isDefaultPreset.toString();
|
||||
$leftStickDeadzone.setValue(presetData.settings.leftStickDeadzone);
|
||||
$rightStickDeadzone.setValue(presetData.settings.rightStickDeadzone);
|
||||
$leftTriggerRange.setValue(presetData.settings.leftTriggerRange);
|
||||
$rightTriggerRange.setValue(presetData.settings.rightTriggerRange);
|
||||
}
|
||||
|
||||
private updatePreset() {
|
||||
const newData: ControllerCustomizationPresetData = deepClone(this.BLANK_PRESET_DATA);
|
||||
|
||||
// Set mappings
|
||||
let gamepadKey: unknown;
|
||||
for (gamepadKey in this.selectsMap) {
|
||||
const $select = this.selectsMap[gamepadKey as GamepadKey]!;
|
||||
const value = $select.value;
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapTo = (value === 'false') ? false : parseInt(value);
|
||||
newData.mapping[gamepadKey as GamepadKey] = mapTo;
|
||||
}
|
||||
|
||||
// Set extra settings
|
||||
Object.assign(newData.settings, {
|
||||
vibrationIntensity: parseInt(this.$vibrationIntensity.value),
|
||||
|
||||
leftStickDeadzone: this.$leftStickDeadzone.getValue(),
|
||||
rightStickDeadzone: this.$rightStickDeadzone.getValue(),
|
||||
leftTriggerRange: this.$leftTriggerRange.getValue(),
|
||||
rightTriggerRange: this.$rightTriggerRange.getValue(),
|
||||
} satisfies typeof newData.settings);
|
||||
|
||||
// Update preset
|
||||
const preset = this.allPresets.data[this.currentPresetId!];
|
||||
preset.data = newData;
|
||||
this.presetsDb.updatePreset(preset);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user