mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-06-06 23:57:19 +02:00
414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
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";
|
|
import { getGamepadPrompt } from "@/utils/gamepad";
|
|
|
|
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;
|
|
|
|
private selectsMap: PartialRecord<GamepadKey, HTMLSelectElement> = {};
|
|
private selectsOrder: GamepadKey[] = [];
|
|
|
|
private isDetectingButton: boolean = false;
|
|
private detectIntervalId: number | null = null;
|
|
|
|
static 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.L3, GamepadKey.R3,
|
|
GamepadKey.LS, GamepadKey.RS,
|
|
|
|
GamepadKey.SELECT, GamepadKey.START,
|
|
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 ControllerCustomizationsManagerDialog.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 ControllerCustomizationsManagerDialog.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 blankSettings = this.presetsDb.BLANK_PRESET_DATA.settings;
|
|
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', blankSettings.leftTriggerRange!, params, boundUpdatePreset),
|
|
),
|
|
|
|
createSettingRow(t('right-trigger-range'),
|
|
this.$rightTriggerRange = BxDualNumberStepper.create('right-trigger-range', blankSettings.rightTriggerRange!, params, boundUpdatePreset),
|
|
),
|
|
|
|
createSettingRow(t('left-stick-deadzone'),
|
|
this.$leftStickDeadzone = BxDualNumberStepper.create('left-stick-deadzone', blankSettings.leftStickDeadzone!, params, boundUpdatePreset),
|
|
),
|
|
|
|
createSettingRow(t('right-stick-deadzone'),
|
|
this.$rightStickDeadzone = BxDualNumberStepper.create('right-stick-deadzone', blankSettings.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 ControllerCustomizationsManagerDialog.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.presetsDb.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.presetsDb.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);
|
|
}
|
|
|
|
async renderSummary(presetId: number) {
|
|
const preset = await this.presetsDb.getPreset(presetId);
|
|
if (!preset) {
|
|
return null;
|
|
}
|
|
|
|
const presetData = preset.data;
|
|
let $content: HTMLElement | undefined;
|
|
let showNote = false;
|
|
|
|
if (Object.keys(presetData.mapping).length > 0) {
|
|
$content = CE('div', { class: 'bx-controller-customization-summary'});
|
|
|
|
for (const gamepadKey of ControllerCustomizationsManagerDialog.BUTTONS_ORDER) {
|
|
if (!(gamepadKey in presetData.mapping)) {
|
|
continue;
|
|
}
|
|
|
|
const mappedKey = presetData.mapping[gamepadKey]!;
|
|
$content.append(CE('span', { class: 'bx-prompt' }, getGamepadPrompt(gamepadKey) + ' > ' + (mappedKey === false ? '🚫' : getGamepadPrompt(mappedKey))));
|
|
}
|
|
|
|
showNote = true;
|
|
}
|
|
|
|
// Show note if it has settings other than 'vibrationIntensity'
|
|
let key: keyof typeof presetData.settings;
|
|
for (key in presetData.settings) {
|
|
if (key === 'vibrationIntensity') {
|
|
continue;
|
|
}
|
|
|
|
const value = presetData.settings[key];
|
|
// Non-default value
|
|
if (Array.isArray(value) && (value[0] !== 0 || value[1] !== 100)) {
|
|
showNote = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
if (showNote) {
|
|
const $note = CE('div', { class: 'bx-settings-dialog-note' }, 'ⓘ ' + t('slightly-increases-input-latency'));
|
|
fragment.appendChild($note);
|
|
}
|
|
|
|
if ($content) {
|
|
fragment.appendChild($content);
|
|
}
|
|
|
|
return fragment.childElementCount ? fragment : null;
|
|
}
|
|
}
|