Controller customization feature

This commit is contained in:
redphx
2024-12-22 17:17:03 +07:00
parent 8ef5a95c88
commit 7b60ba3a3e
89 changed files with 3286 additions and 1188 deletions

View File

@@ -106,14 +106,14 @@ export const BxExposed = {
}
// Remove native MKB support on mobile browsers or by user's choice
if (getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.OFF) {
if (getPref(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.OFF) {
supportedInputTypes = supportedInputTypes.filter(i => i !== SupportedInputType.MKB);
}
titleInfo.details.hasMkbSupport = supportedInputTypes.includes(SupportedInputType.MKB);
if (STATES.userAgent.capabilities.touch) {
let touchControllerAvailability = getPref<TouchControllerMode>(PrefKey.TOUCH_CONTROLLER_MODE);
let touchControllerAvailability = getPref(PrefKey.TOUCH_CONTROLLER_MODE);
// Disable touch control when gamepad found
if (touchControllerAvailability !== TouchControllerMode.OFF && getPref(PrefKey.TOUCH_CONTROLLER_AUTO_OFF)) {
@@ -185,8 +185,8 @@ export const BxExposed = {
}
},
handleControllerShortcut: isFullVersion() && ControllerShortcut.handle,
resetControllerShortcut: isFullVersion() && ControllerShortcut.reset,
handleControllerShortcut: isFullVersion() ? ControllerShortcut.handle : () => {},
resetControllerShortcut: isFullVersion() ? ControllerShortcut.reset : () => {},
overrideSettings: {
Tv_settings: {

View File

@@ -12,6 +12,7 @@ import iconEyeSlash from "@assets/svg/eye-slash.svg" with { type: "text" };
import iconHome from "@assets/svg/home.svg" with { type: "text" };
import iconNativeMkb from "@assets/svg/native-mkb.svg" with { type: "text" };
import iconNew from "@assets/svg/new.svg" with { type: "text" };
import iconPencil from "@assets/svg/pencil-simple-line.svg" with { type: "text" };
import iconPower from "@assets/svg/power.svg" with { type: "text" };
import iconQuestion from "@assets/svg/question.svg" with { type: "text" };
import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" };
@@ -53,6 +54,7 @@ export const BxIcon = {
HOME: iconHome,
NATIVE_MKB: iconNativeMkb,
NEW: iconNew,
MANAGE: iconPencil,
COPY: iconCopy,
TRASH: iconTrash,
CURSOR_TEXT: iconCursorText,

View File

@@ -9,7 +9,7 @@ export function addCss() {
const STYLUS_CSS = renderStylus() as unknown as string;
let css = STYLUS_CSS;
const PREF_HIDE_SECTIONS = getPref<UiSection[]>(PrefKey.UI_HIDE_SECTIONS);
const PREF_HIDE_SECTIONS = getPref(PrefKey.UI_HIDE_SECTIONS);
const selectorToHide = [];
// Hide "News" section
@@ -18,7 +18,7 @@ export function addCss() {
}
// Hide BYOG section
if (getPref<BlockFeature[]>(PrefKey.BLOCK_FEATURES).includes(BlockFeature.BYOG)) {
if (getPref(PrefKey.BLOCK_FEATURES).includes(BlockFeature.BYOG)) {
selectorToHide.push('#BodyContent > div[class*=ByogRow-module__container___]');
}
@@ -39,7 +39,7 @@ export function addCss() {
}
// Hide "Start a party" button in the Guide menu
if (getPref<BlockFeature[]>(PrefKey.BLOCK_FEATURES).includes(BlockFeature.FRIENDS)) {
if (getPref(PrefKey.BLOCK_FEATURES).includes(BlockFeature.FRIENDS)) {
selectorToHide.push('#gamepass-dialog-root div[class^=AchievementsPreview-module__container] + button[class*=HomeLandingPage-module__button]');
}
@@ -170,7 +170,7 @@ body::-webkit-scrollbar {
export function preloadFonts() {
const $link = CE<HTMLLinkElement>('link', {
const $link = CE('link', {
rel: 'preload',
href: 'https://redphx.github.io/better-xcloud/fonts/promptfont.otf',
as: 'font',

View File

@@ -12,13 +12,13 @@ export let FeatureGates: { [key: string]: boolean } = {
};
// Enable Native Mouse & Keyboard
const nativeMkbMode = getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE);
const nativeMkbMode = getPref(PrefKey.NATIVE_MKB_MODE);
if (nativeMkbMode !== NativeMkbMode.DEFAULT) {
FeatureGates.EnableMouseAndKeyboard = nativeMkbMode === NativeMkbMode.ON;
}
// Disable chat feature
const blockFeatures = getPref<BlockFeature[]>(PrefKey.BLOCK_FEATURES);
const blockFeatures = getPref(PrefKey.BLOCK_FEATURES);
if (blockFeatures.includes(BlockFeature.CHAT)) {
FeatureGates.EnableGuideChatTab = false;
}

View File

@@ -3,6 +3,7 @@ import { setNearby } from "./navigation-utils";
import type { NavigationNearbyElements } from "@/modules/ui/dialog/navigation-dialog";
import type { PresetRecord, AllPresets } from "@/types/presets";
import { t } from "./translation";
import type { BxSelectElement } from "@/web-components/bx-select";
export enum ButtonStyle {
PRIMARY = 1,
@@ -14,10 +15,11 @@ export enum ButtonStyle {
FOCUSABLE = 1 << 6,
FULL_WIDTH = 1 << 7,
FULL_HEIGHT = 1 << 8,
TALL = 1 << 9,
CIRCULAR = 1 << 10,
NORMAL_CASE = 1 << 11,
NORMAL_LINK = 1 << 12,
AUTO_HEIGHT = 1 << 9,
TALL = 1 << 10,
CIRCULAR = 1 << 11,
NORMAL_CASE = 1 << 12,
NORMAL_LINK = 1 << 13,
}
const ButtonStyleClass = {
@@ -30,6 +32,7 @@ const ButtonStyleClass = {
[ButtonStyle.FOCUSABLE]: 'bx-focusable',
[ButtonStyle.FULL_WIDTH]: 'bx-full-width',
[ButtonStyle.FULL_HEIGHT]: 'bx-full-height',
[ButtonStyle.AUTO_HEIGHT]: 'bx-auto-height',
[ButtonStyle.TALL]: 'bx-tall',
[ButtonStyle.CIRCULAR]: 'bx-circular',
[ButtonStyle.NORMAL_CASE]: 'bx-normal-case',
@@ -62,23 +65,41 @@ type CreateElementOptions = {
[ key: string ]: (e: Event) => void;
};
_dataset?: {
[ key: string ]: string | number;
[ key: string ]: string | number | boolean;
};
_nearby?: NavigationNearbyElements;
};
type HTMLElementTagNameMap = {
a: HTMLAnchorElement;
button: HTMLButtonElement;
canvas: HTMLCanvasElement;
datalist: HTMLDataListElement,
div: HTMLDivElement;
fieldset: HTMLFieldSetElement;
input: HTMLInputElement;
label: HTMLLabelElement;
link: HTMLLinkElement;
optgroup: HTMLOptGroupElement;
option: HTMLOptionElement;
p: HTMLParagraphElement;
select: HTMLSelectElement;
span: HTMLSpanElement;
style: HTMLStyleElement;
[key: string] : HTMLElement;
};
function createElement<T=HTMLElement>(elmName: string, props: CreateElementOptions={}, ..._: any): T {
function createElement<T extends keyof HTMLElementTagNameMap>(elmName: T, props: CreateElementOptions={}, ..._: any): HTMLElementTagNameMap[T] {
let $elm;
const hasNs = 'xmlns' in props;
// console.trace('createElement', elmName, props);
if (hasNs) {
$elm = document.createElementNS(props.xmlns, elmName);
$elm = document.createElementNS(props.xmlns, elmName as string);
delete props.xmlns;
} else {
$elm = document.createElement(elmName);
$elm = document.createElement(elmName as string);
}
if (props._nearby) {
@@ -121,7 +142,7 @@ function createElement<T=HTMLElement>(elmName: string, props: CreateElementOptio
}
}
return $elm as T;
return $elm as HTMLElementTagNameMap[T];
}
@@ -137,13 +158,13 @@ export function createButton<T=HTMLButtonElement>(options: BxButtonOptions): T {
// Create base button element
if (options.url) {
$btn = CE<HTMLAnchorElement>('a', {
$btn = CE('a', {
class: 'bx-button',
href: options.url,
target: '_blank',
});
} else {
$btn = CE<HTMLButtonElement>('button', {
$btn = CE('button', {
class: 'bx-button',
type: 'button',
});
@@ -185,7 +206,7 @@ export function createButton<T=HTMLButtonElement>(options: BxButtonOptions): T {
export function createSettingRow(label: string, $control: HTMLElement | false | undefined, options: SettingsRowOptions={}) {
let $label: HTMLElement;
const $row = CE<HTMLLabelElement>('label', { class: 'bx-settings-row' },
const $row = CE('label', { class: 'bx-settings-row' },
$label = CE('span', { class: 'bx-settings-label' },
label,
options.$note,
@@ -267,7 +288,7 @@ export function renderPresetsList<T extends PresetRecord>($select: HTMLSelectEle
removeChildElements($select);
if (options.addOffValue) {
const $option = CE<HTMLOptionElement>('option', { value: 0 }, t('off'));
const $option = CE('option', { value: 0 }, t('off'));
$option.selected = selectedValue === 0;
$select.appendChild($option);
@@ -287,7 +308,7 @@ export function renderPresetsList<T extends PresetRecord>($select: HTMLSelectEle
const selected = selectedValue === record.id;
const name = options.selectedIndicator && selected ? '✅ ' + record.name : record.name;
const $option = CE<HTMLOptionElement>('option', { value: record.id }, name);
const $option = CE('option', { value: record.id }, name);
if (selected) {
$option.selected = true;
}
@@ -301,6 +322,48 @@ export function renderPresetsList<T extends PresetRecord>($select: HTMLSelectEle
}
}
export function calculateSelectBoxes($root: HTMLElement) {
const selects = Array.from<HTMLSelectElement>($root.querySelectorAll('div.bx-select:not([data-calculated]) select'));
for (const $select of selects) {
const $parent = $select.parentElement! as BxSelectElement;
// Don't apply to select.bx-full-width elements
if ($parent.classList.contains('bx-full-width')) {
$parent.dataset.calculated = 'true';
continue;
}
const rect = $select.getBoundingClientRect();
let $label: HTMLElement;
let width = Math.ceil(rect.width);
if (!width) {
continue;
}
$label = $parent.querySelector<HTMLElement>($select.multiple ? '.bx-select-value' : 'div')!;
if ($parent.isControllerFriendly) {
// Adjust width for controller-friendly UI
if ($select.multiple) {
width += 20; // Add checkbox's width
}
// Reduce width if it has <optgroup>
if ($select.querySelector('optgroup')) {
width -= 15;
}
} else {
width += 10;
}
// Set min-width
$select.style.left = '0';
$label.style.minWidth = width + 'px';
$parent.dataset.calculated = 'true';
};
}
// https://stackoverflow.com/a/20732091
const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
export function humanFileSize(size: number) {

View File

@@ -0,0 +1,45 @@
import type { ControllerCustomizationPresetRecord, PresetRecords } from "@/types/presets";
import { LocalDb } from "./local-db";
import { BasePresetsTable } from "./base-presets-table";
import { GamepadKey } from "@/enums/gamepad";
export const enum ControllerCustomizationDefaultPresetId {
OFF = 0,
BAYX = -1,
DEFAULT = OFF,
};
export class ControllerCustomizationsTable extends BasePresetsTable<ControllerCustomizationPresetRecord> {
private static instance: ControllerCustomizationsTable;
public static getInstance = () => ControllerCustomizationsTable.instance ?? (ControllerCustomizationsTable.instance = new ControllerCustomizationsTable(LocalDb.TABLE_CONTROLLER_CUSTOMIZATIONS));
protected readonly TABLE_PRESETS = LocalDb.TABLE_CONTROLLER_CUSTOMIZATIONS;
protected DEFAULT_PRESETS: PresetRecords<ControllerCustomizationPresetRecord> = {
[ControllerCustomizationDefaultPresetId.BAYX]: {
id: ControllerCustomizationDefaultPresetId.BAYX,
name: 'ABXY ⇄ BAYX',
data: {
mapping: {
[GamepadKey.A]: GamepadKey.B,
[GamepadKey.B]: GamepadKey.A,
[GamepadKey.X]: GamepadKey.Y,
[GamepadKey.Y]: GamepadKey.X,
},
settings: {
leftStickDeadzone: [0, 100],
rightStickDeadzone: [0, 100],
leftTriggerRange: [0, 100],
rightTriggerRange: [0, 100],
vibrationIntensity: 100,
},
},
},
};
protected DEFAULT_PRESET_ID = ControllerCustomizationDefaultPresetId.DEFAULT;
}

View File

@@ -2,6 +2,7 @@ import { BaseLocalTable } from "./base-table";
import { LocalDb } from "./local-db";
import { ControllerShortcutDefaultId } from "./controller-shortcuts-table";
import { deepClone } from "../global";
import { ControllerCustomizationDefaultPresetId } from "./controller-customizations-table";
export class ControllerSettingsTable extends BaseLocalTable<ControllerSettingsRecord> {
private static instance: ControllerSettingsTable;
@@ -9,7 +10,7 @@ export class ControllerSettingsTable extends BaseLocalTable<ControllerSettingsRe
static readonly DEFAULT_DATA: ControllerSettingsRecord['data'] = {
shortcutPresetId: ControllerShortcutDefaultId.DEFAULT,
vibrationIntensity: 50,
customizationPresetId: ControllerCustomizationDefaultPresetId.DEFAULT,
};
async getControllerData(id: string): Promise<ControllerSettingsRecord['data']> {
@@ -30,10 +31,7 @@ export class ControllerSettingsTable extends BaseLocalTable<ControllerSettingsRe
continue;
}
const settings = all[key].data;
// Pre-calculate virabtionIntensity
settings.vibrationIntensity /= 100;
const settings = Object.assign(all[key].data, ControllerSettingsTable.DEFAULT_DATA);
results[key] = settings;
}

View File

@@ -4,10 +4,11 @@ export class LocalDb {
// private readonly LOG_TAG = 'LocalDb';
static readonly DB_NAME = 'BetterXcloud';
static readonly DB_VERSION = 3;
static readonly DB_VERSION = 4;
static readonly TABLE_VIRTUAL_CONTROLLERS = 'virtual_controllers';
static readonly TABLE_CONTROLLER_SHORTCUTS = 'controller_shortcuts';
static readonly TABLE_CONTROLLER_CUSTOMIZATIONS = 'controller_customizations';
static readonly TABLE_CONTROLLER_SETTINGS = 'controller_settings';
static readonly TABLE_KEYBOARD_SHORTCUTS = 'keyboard_shortcuts';
@@ -52,6 +53,14 @@ export class LocalDb {
});
}
// Controller mappings
if (!db.objectStoreNames.contains(LocalDb.TABLE_CONTROLLER_CUSTOMIZATIONS)) {
db.createObjectStore(LocalDb.TABLE_CONTROLLER_CUSTOMIZATIONS, {
keyPath: 'id',
autoIncrement: true,
});
}
// Keyboard shortcuts
if (!db.objectStoreNames.contains(LocalDb.TABLE_KEYBOARD_SHORTCUTS)) {
db.createObjectStore(LocalDb.TABLE_KEYBOARD_SHORTCUTS, {

View File

@@ -60,7 +60,7 @@ export function patchVideoApi() {
export function patchRtcCodecs() {
const codecProfile = getPref<CodecProfile>(PrefKey.STREAM_CODEC_PROFILE);
const codecProfile = getPref(PrefKey.STREAM_CODEC_PROFILE);
if (codecProfile === 'default') {
return;
}
@@ -81,8 +81,8 @@ export function patchRtcPeerConnection() {
}
const maxVideoBitrateDef = getPrefDefinition(PrefKey.STREAM_MAX_VIDEO_BITRATE) as Extract<SettingDefinition, { min: number }>;
const maxVideoBitrate = getPref<VideoMaxBitrate>(PrefKey.STREAM_MAX_VIDEO_BITRATE);
const codec = getPref<CodecProfile>(PrefKey.STREAM_CODEC_PROFILE);
const maxVideoBitrate = getPref(PrefKey.STREAM_MAX_VIDEO_BITRATE);
const codec = getPref(PrefKey.STREAM_CODEC_PROFILE);
if (codec !== CodecProfile.DEFAULT || maxVideoBitrate < maxVideoBitrateDef.max) {
const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription;
@@ -134,7 +134,7 @@ export function patchAudioContext() {
ctx.createGain = function() {
const gainNode = nativeCreateGain.apply(this);
gainNode.gain.value = getPref<AudioVolume>(PrefKey.AUDIO_VOLUME) / 100;
gainNode.gain.value = getPref(PrefKey.AUDIO_VOLUME) / 100;
STATES.currentStream.audioGainNode = gainNode;
return gainNode;

View File

@@ -141,7 +141,7 @@ export function interceptHttpRequests() {
// 'https://notificationinbox.xboxlive.com',
// 'https://accounts.xboxlive.com/family/memberXuid',
const blockFeatures = getPref<BlockFeature[]>(PrefKey.BLOCK_FEATURES);
const blockFeatures = getPref(PrefKey.BLOCK_FEATURES);
if (blockFeatures.includes(BlockFeature.CHAT)) {
BLOCKED_URLS.push(
'https://xblmessaging.xboxlive.com/network/xbox/users/me/inbox',

View File

@@ -4,7 +4,7 @@ import { getPref } from "./settings-storages/global-settings-storage";
export function getPreferredServerRegion(shortName = false): string | null {
let preferredRegion = getPref<ServerRegionName>(PrefKey.SERVER_REGION);
let preferredRegion = getPref(PrefKey.SERVER_REGION);
const serverRegions = STATES.serverRegions;
// Return preferred region

View File

@@ -18,9 +18,9 @@ export class ScreenshotManager {
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.$download = CE<HTMLAnchorElement>('a');
this.$download = CE('a');
this.$canvas = CE<HTMLCanvasElement>('canvas', { class: 'bx-gone' });
this.$canvas = CE('canvas', { class: 'bx-gone' });
this.canvasContext = this.$canvas.getContext('2d', {
alpha: false,
willReadFrequently: false,

View File

@@ -23,10 +23,10 @@ export interface BxSelectSettingElement extends HTMLSelectElement, BxBaseSetting
export class SettingElement {
private static renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any): BxSelectSettingElement {
const $control = CE<BxSelectSettingElement>('select', {
const $control = CE('select', {
// title: setting.label,
tabindex: 0,
});
}) as BxSelectSettingElement;
let $parent: HTMLElement;
if (setting.optionsGroup) {
@@ -41,7 +41,7 @@ export class SettingElement {
for (let value in setting.options) {
const label = setting.options[value];
const $option = CE<HTMLOptionElement>('option', { value }, label);
const $option = CE('option', { value }, label);
$parent.appendChild($option);
}
@@ -62,11 +62,11 @@ export class SettingElement {
}
private static renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}): BxSelectSettingElement {
const $control = CE<BxSelectSettingElement>('select', {
const $control = CE('select', {
// title: setting.label,
multiple: true,
tabindex: 0,
});
}) as BxSelectSettingElement;
const totalOptions = Object.keys(setting.multipleOptions!).length;
const size = params.size ? Math.min(params.size, totalOptions) : totalOptions;
@@ -75,7 +75,7 @@ export class SettingElement {
for (const value in setting.multipleOptions) {
const label = setting.multipleOptions[value];
const $option = CE<HTMLOptionElement>('option', { value }, label) as HTMLOptionElement;
const $option = CE('option', { value }, label) as HTMLOptionElement;
$option.selected = currentValue.indexOf(value) > -1;
$option.addEventListener('mousedown', function(e) {
@@ -156,6 +156,7 @@ export class SettingElement {
static fromPref(key: PrefKey, storage: BaseSettingsStore, onChange: any, overrideParams={}) {
const definition = storage.getDefinition(key);
// @ts-ignore
let currentValue = storage.getSetting(key);
let type;

View File

@@ -1,4 +1,4 @@
import type { PrefKey, StorageKey } from "@/enums/pref-keys";
import type { PrefKey, PrefTypeMap, StorageKey } from "@/enums/pref-keys";
import type { NumberStepperParams, SettingAction, SettingDefinitions } from "@/types/setting-definition";
import { t } from "../translation";
import { SCRIPT_VARIANT } from "../global";
@@ -63,20 +63,20 @@ export class BaseSettingsStore {
return this.definitions[key];
}
getSetting<T=boolean>(key: PrefKey, checkUnsupported = true): T {
getSetting<T extends keyof PrefTypeMap>(key: T, checkUnsupported = true): PrefTypeMap[T] {
const definition = this.definitions[key];
// Return default value if build variant is different
if (definition.requiredVariants && !definition.requiredVariants.includes(SCRIPT_VARIANT)) {
return definition.default as T;
return definition.default as PrefTypeMap[T];
}
// Return default value if the feature is not supported
if (checkUnsupported && definition.unsupported) {
if ('unsupportedValue' in definition) {
return definition.unsupportedValue as T;
return definition.unsupportedValue as PrefTypeMap[T];
} else {
return definition.default as T;
return definition.default as PrefTypeMap[T];
}
}
@@ -84,7 +84,7 @@ export class BaseSettingsStore {
this.settings[key] = this.validateValue('get', key, null);
}
return this.settings[key] as T;
return this.settings[key] as PrefTypeMap[T];
}
setSetting<T=any>(key: PrefKey, value: T, emitEvent = false) {

View File

@@ -8,7 +8,7 @@ import { CE } from "../html";
import { t, SUPPORTED_LANGUAGES } from "../translation";
import { UserAgent } from "../user-agent";
import { BaseSettingsStore as BaseSettingsStorage } from "./base-settings-storage";
import { CodecProfile, StreamResolution, TouchControllerMode, TouchControllerStyleStandard, TouchControllerStyleCustom, GameBarPosition, DeviceVibrationMode, NativeMkbMode, UiLayout, UiSection, StreamPlayerType, StreamVideoProcessing, VideoRatio, StreamStat, VideoPosition, BlockFeature } from "@/enums/pref-values";
import { CodecProfile, StreamResolution, TouchControllerMode, TouchControllerStyleStandard, TouchControllerStyleCustom, GameBarPosition, DeviceVibrationMode, NativeMkbMode, UiLayout, UiSection, StreamPlayerType, StreamVideoProcessing, VideoRatio, StreamStat, VideoPosition, BlockFeature, StreamStatPosition, VideoPowerPreference } from "@/enums/pref-values";
import { MkbMappingDefaultPresetId } from "../local-db/mkb-mapping-presets-table";
import { KeyboardShortcutDefaultId } from "../local-db/keyboard-shortcuts-table";
import { GhPagesUtils } from "../gh-pages";
@@ -322,7 +322,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
label: t('enable-local-co-op-support'),
default: false,
note: () => CE('div', {},
CE<HTMLAnchorElement>('a', {
CE('a', {
href: 'https://github.com/redphx/better-xcloud/discussions/275',
target: '_blank',
}, t('enable-local-co-op-support-note')),
@@ -399,7 +399,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
url = 'https://better-xcloud.github.io/mouse-and-keyboard/#disclaimer';
}
setting.unsupportedNote = () => CE<HTMLAnchorElement>('a', {
setting.unsupportedNote = () => CE('a', {
href: url,
target: '_blank',
}, '⚠️ ' + note);
@@ -649,11 +649,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.VIDEO_POWER_PREFERENCE]: {
label: t('renderer-configuration'),
default: 'default',
default: VideoPowerPreference.DEFAULT,
options: {
default: t('default'),
'low-power': t('battery-saving'),
'high-performance': t('high-performance'),
[VideoPowerPreference.DEFAULT]: t('default'),
[VideoPowerPreference.LOW_POWER]: t('battery-saving'),
[VideoPowerPreference.HIGH_PERFORMANCE]: t('high-performance'),
},
suggest: {
highest: 'low-power',
@@ -813,11 +813,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.STATS_POSITION]: {
label: t('position'),
default: 'top-right',
default: StreamStatPosition.TOP_RIGHT,
options: {
'top-left': t('top-left'),
'top-center': t('top-center'),
'top-right': t('top-right'),
[StreamStatPosition.TOP_LEFT]: t('top-left'),
[StreamStatPosition.TOP_CENTER]: t('top-center'),
[StreamStatPosition.TOP_RIGHT]: t('top-right'),
},
},
[PrefKey.STATS_TEXT_SIZE]: {

View File

@@ -1,32 +1,33 @@
import { PrefKey } from "@/enums/pref-keys";
import { PrefKey, type PrefTypeMap } from "@/enums/pref-keys";
import { ControllerSettingsTable } from "./local-db/controller-settings-table";
import { ControllerShortcutsTable } from "./local-db/controller-shortcuts-table";
import { getPref, setPref } from "./settings-storages/global-settings-storage";
import type { ControllerShortcutPresetRecord, KeyboardShortcutConvertedPresetData, MkbConvertedPresetData } from "@/types/presets";
import type { ControllerCustomizationConvertedPresetData, ControllerCustomizationPresetData, ControllerShortcutPresetRecord, KeyboardShortcutConvertedPresetData, MkbConvertedPresetData } from "@/types/presets";
import { STATES } from "./global";
import { DeviceVibrationMode } from "@/enums/pref-values";
import { VIRTUAL_GAMEPAD_ID } from "@/modules/mkb/mkb-handler";
import { hasGamepad } from "./gamepad";
import { MkbMappingPresetsTable } from "./local-db/mkb-mapping-presets-table";
import type { GamepadKey } from "@/enums/gamepad";
import { GamepadKey } from "@/enums/gamepad";
import { MkbPresetKey, MouseConstant } from "@/enums/mkb";
import { KeyboardShortcutDefaultId, KeyboardShortcutsTable } from "./local-db/keyboard-shortcuts-table";
import { ShortcutAction } from "@/enums/shortcut-actions";
import { KeyHelper } from "@/modules/mkb/key-helper";
import { BxEventBus } from "./bx-event-bus";
import { ControllerCustomizationsTable } from "./local-db/controller-customizations-table";
export type StreamSettingsData = {
settings: Partial<Record<PrefKey, any>>;
xCloudPollingMode: 'none' | 'callbacks' | 'navigation' | 'all';
deviceVibrationIntensity: DeviceVibrationIntensity;
deviceVibrationIntensity: number;
controllerPollingRate: ControllerPollingRate;
controllerPollingRate: number;
controllers: {
[gamepadId: string]: {
vibrationIntensity: number;
shortcuts: ControllerShortcutPresetRecord['data']['mapping'] | null;
customization: ControllerCustomizationConvertedPresetData | null;
};
};
@@ -50,7 +51,33 @@ export class StreamSettings {
keyboardShortcuts: {},
};
static getPref<T=boolean>(key: PrefKey) {
private static CONTROLLER_CUSTOMIZATION_MAPPING: { [key in GamepadKey]?: keyof XcloudGamepad } = {
[GamepadKey.A]: 'A',
[GamepadKey.B]: 'B',
[GamepadKey.X]: 'X',
[GamepadKey.Y]: 'Y',
[GamepadKey.UP]: 'DPadUp',
[GamepadKey.RIGHT]: 'DPadRight',
[GamepadKey.DOWN]: 'DPadDown',
[GamepadKey.LEFT]: 'DPadLeft',
[GamepadKey.LB]: 'LeftShoulder',
[GamepadKey.RB]: 'RightShoulder',
[GamepadKey.LT]: 'LeftTrigger',
[GamepadKey.RT]: 'RightTrigger',
[GamepadKey.L3]: 'LeftThumb',
[GamepadKey.R3]: 'RightThumb',
[GamepadKey.LS]: 'LeftStickAxes',
[GamepadKey.RS]: 'RightStickAxes',
[GamepadKey.SELECT]: 'View',
[GamepadKey.START]: 'Menu',
[GamepadKey.SHARE]: 'Share',
};
static getPref<T extends keyof PrefTypeMap>(key: T) {
return getPref<T>(key);
}
@@ -60,6 +87,7 @@ export class StreamSettings {
const settingsTable = ControllerSettingsTable.getInstance();
const shortcutsTable = ControllerShortcutsTable.getInstance();
const mappingTable = ControllerCustomizationsTable.getInstance();
const gamepads = window.navigator.getGamepads();
for (const gamepad of gamepads) {
@@ -74,17 +102,17 @@ export class StreamSettings {
const settingsData = await settingsTable.getControllerData(gamepad.id);
let shortcutsMapping;
const preset = await shortcutsTable.getPreset(settingsData.shortcutPresetId);
if (!preset) {
shortcutsMapping = null;
} else {
shortcutsMapping = preset.data.mapping;
}
// Shortcuts
const shortcutsPreset = await shortcutsTable.getPreset(settingsData.shortcutPresetId);
const shortcutsMapping = !shortcutsPreset ? null : shortcutsPreset.data.mapping;
// Mapping
const customizationPreset = await mappingTable.getPreset(settingsData.customizationPresetId);
const customizationData = StreamSettings.convertControllerCustomization(customizationPreset?.data);
controllers[gamepad.id] = {
vibrationIntensity: settingsData.vibrationIntensity,
shortcuts: shortcutsMapping,
customization: customizationData,
}
}
settings.controllers = controllers;
@@ -95,18 +123,66 @@ export class StreamSettings {
await StreamSettings.refreshDeviceVibration();
}
private static preCalculateControllerRange(obj: Record<string, [number, number]>, target: keyof XcloudGamepad, values: [number, number] | undefined) {
if (values && Array.isArray(values)) {
const [from, to] = values;
if (from > 1 || to < 100) {
obj[target] = [from / 100, to / 100];
}
}
}
private static convertControllerCustomization(customization: ControllerCustomizationPresetData | null | undefined) {
if (!customization) {
return null;
}
const converted = {
mapping: {},
ranges: {},
vibrationIntensity: 1,
} as ControllerCustomizationConvertedPresetData;
// Swap GamepadKey.A with "A"
let gamepadKey: unknown;
for (gamepadKey in customization.mapping) {
const gamepadStr = StreamSettings.CONTROLLER_CUSTOMIZATION_MAPPING[gamepadKey as GamepadKey];
if (!gamepadStr) {
continue;
}
const mappedKey = customization.mapping[gamepadKey as GamepadKey];
if (typeof mappedKey === 'number') {
converted.mapping[gamepadStr] = StreamSettings.CONTROLLER_CUSTOMIZATION_MAPPING[mappedKey as GamepadKey];
} else {
converted.mapping[gamepadStr] = false;
}
}
// Pre-calculate ranges & deadzone
StreamSettings.preCalculateControllerRange(converted.ranges, 'LeftTrigger', customization.settings.leftTriggerRange);
StreamSettings.preCalculateControllerRange(converted.ranges, 'RightTrigger', customization.settings.rightTriggerRange);
StreamSettings.preCalculateControllerRange(converted.ranges, 'LeftThumb', customization.settings.leftStickDeadzone);
StreamSettings.preCalculateControllerRange(converted.ranges, 'RightThumb', customization.settings.rightStickDeadzone);
// Pre-calculate virabtionIntensity
converted.vibrationIntensity = customization.settings.vibrationIntensity / 100;
return converted;
}
private static async refreshDeviceVibration() {
if (!STATES.browser.capabilities.deviceVibration) {
return;
}
const mode = StreamSettings.getPref<DeviceVibrationMode>(PrefKey.DEVICE_VIBRATION_MODE);
const mode = StreamSettings.getPref(PrefKey.DEVICE_VIBRATION_MODE);
let intensity = 0; // Disable
// Enable when no controllers are detected in Auto mode
if (mode === DeviceVibrationMode.ON || (mode === DeviceVibrationMode.AUTO && !hasGamepad())) {
// Set intensity
intensity = StreamSettings.getPref<DeviceVibrationIntensity>(PrefKey.DEVICE_VIBRATION_INTENSITY) / 100;
intensity = StreamSettings.getPref(PrefKey.DEVICE_VIBRATION_INTENSITY) / 100;
}
StreamSettings.settings.deviceVibrationIntensity = intensity;
@@ -116,7 +192,7 @@ export class StreamSettings {
static async refreshMkbSettings() {
const settings = StreamSettings.settings;
let presetId = StreamSettings.getPref<MkbPresetId>(PrefKey.MKB_P1_MAPPING_PRESET_ID);
let presetId = StreamSettings.getPref(PrefKey.MKB_P1_MAPPING_PRESET_ID);
const orgPreset = (await MkbMappingPresetsTable.getInstance().getPreset(presetId))!;
const orgPresetData = orgPreset.data;
@@ -154,7 +230,7 @@ export class StreamSettings {
static async refreshKeyboardShortcuts() {
const settings = StreamSettings.settings;
let presetId = StreamSettings.getPref<KeyboardShortcutsPresetId>(PrefKey.KEYBOARD_SHORTCUTS_IN_GAME_PRESET_ID);
let presetId = StreamSettings.getPref(PrefKey.KEYBOARD_SHORTCUTS_IN_GAME_PRESET_ID);
if (presetId === KeyboardShortcutDefaultId.OFF) {
settings.keyboardShortcuts = null;

View File

@@ -96,7 +96,7 @@ export class StreamStatsCollector {
current: -1,
grades: [40, 75, 100],
toString() {
return this.current === -1 ? '???' : this.current.toString().padStart(3, ' ');
return this.current === -1 ? '???' : this.current.toString().padStart(3);
},
},
@@ -104,22 +104,22 @@ export class StreamStatsCollector {
current: 0,
grades: [30, 40, 60],
toString() {
return `${this.current.toFixed(1)}ms`.padStart(6, ' ');
return `${this.current.toFixed(1)}ms`.padStart(6);
},
},
[StreamStat.FPS]: {
current: 0,
toString() {
const maxFps = getPref<VideoMaxFps>(PrefKey.VIDEO_MAX_FPS);
return maxFps < 60 ? `${maxFps}/${this.current}`.padStart(5, ' ') : this.current.toString();
const maxFps = getPref(PrefKey.VIDEO_MAX_FPS);
return maxFps < 60 ? `${maxFps}/${this.current}`.padStart(5) : this.current.toString();
},
},
[StreamStat.BITRATE]: {
current: 0,
toString() {
return `${this.current.toFixed(1)} Mbps`.padStart(9, ' ');
return `${this.current.toFixed(1)} Mbps`.padStart(9);
},
},
@@ -146,14 +146,14 @@ export class StreamStatsCollector {
total: 0,
grades: [6, 9, 12],
toString() {
return isNaN(this.current) ? '??ms' : `${this.current.toFixed(1)}ms`.padStart(6, ' ');
return isNaN(this.current) ? '??ms' : `${this.current.toFixed(1)}ms`.padStart(6);
},
},
[StreamStat.DOWNLOAD]: {
total: 0,
toString() {
return humanFileSize(this.total).padStart(8, ' ');
return humanFileSize(this.total).padStart(8);
},
},

View File

@@ -27,6 +27,7 @@ export const SUPPORTED_LANGUAGES = {
};
const Texts = {
"slightly-increase-input-latency": "Slightly increase input latency",
"achievements": "Achievements",
"activate": "Activate",
"activated": "Activated",
@@ -86,12 +87,10 @@ const Texts = {
"continent-south-america": "South America",
"contrast": "Contrast",
"controller": "Controller",
"controller-customization": "Controller customization",
"controller-friendly-ui": "Controller-friendly UI",
"controller-mapping": "Controller mapping",
"controller-mapping-in-game": "In-game controller mapping",
"controller-shortcuts": "Controller shortcuts",
"controller-shortcuts-connect-note": "Connect a controller to use this feature",
"controller-shortcuts-in-game": "In-game controller shortcuts",
"controller-shortcuts-xbox-note": "Button to open the Guide menu",
"controller-vibration": "Controller vibration",
"copy": "Copy",
@@ -102,12 +101,12 @@ const Texts = {
"default": "Default",
"default-preset-note": "You can't modify default presets. Create a new one to customize it.",
"delete": "Delete",
"detect-controller-button": "Detect controller button",
"device": "Device",
"device-unsupported-touch": "Your device doesn't have touch support",
"device-vibration": "Device vibration",
"device-vibration-not-using-gamepad": "On when not using gamepad",
"disable": "Disable",
"disable-byog-feature": "Disable \"Stream your own game\" feature",
"disable-features": "Disable features",
"disable-home-context-menu": "Disable context menu in Home page",
"disable-post-stream-feedback-dialog": "Disable post-stream feedback dialog",
@@ -153,6 +152,10 @@ const Texts = {
"how-to-improve-app-performance": "How to improve app's performance",
"ignore": "Ignore",
"import": "Import",
"in-game-controller-customization": "In-game controller customization",
"in-game-controller-shortcuts": "In-game controller shortcuts",
"in-game-keyboard-shortcuts": "In-game keyboard shortcuts",
"in-game-shortcuts": "In-game shortcuts",
"increase": "Increase",
"install-android": "Better xCloud app for Android",
"invites": "Invites",
@@ -160,12 +163,13 @@ const Texts = {
"jitter": "Jitter",
"keyboard-key": "Keyboard key",
"keyboard-shortcuts": "Keyboard shortcuts",
"keyboard-shortcuts-in-game": "In-game keyboard shortcuts",
"korea": "Korea",
"language": "Language",
"large": "Large",
"layout": "Layout",
"left-stick": "Left stick",
"left-stick-deadzone": "Left stick deadzone",
"left-trigger-range": "Left trigger range",
"limit-fps": "Limit FPS",
"load-failed-message": "Failed to run Better xCloud",
"loading-screen": "Loading screen",
@@ -173,7 +177,6 @@ const Texts = {
"lowest-quality": "Lowest quality",
"manage": "Manage",
"map-mouse-to": "Map mouse to",
"mapping": "Mapping",
"may-not-work-properly": "May not work properly!",
"menu": "Menu",
"microphone": "Microphone",
@@ -230,6 +233,7 @@ const Texts = {
"preferred-game-language": "Preferred game's language",
"preset": "Preset",
"press": "Press",
"press-any-button": "Press any button...",
"press-esc-to-cancel": "Press Esc to cancel",
"press-key-to-toggle-mkb": [
(e: any) => `Press ${e.key} to toggle this feature`,
@@ -285,6 +289,8 @@ const Texts = {
"renderer-configuration": "Renderer configuration",
"right-click-to-unbind": "Right-click on a key to unbind it",
"right-stick": "Right stick",
"right-stick-deadzone": "Right stick deadzone",
"right-trigger-range": "Right trigger range",
"rocket-always-hide": "Always hide",
"rocket-always-show": "Always show",
"rocket-animation": "Rocket animation",
@@ -294,7 +300,6 @@ const Texts = {
"screen": "Screen",
"screenshot-apply-filters": "Apply video filters to screenshots",
"section-all-games": "All games",
"section-byog": "Stream your own game",
"section-most-popular": "Most popular",
"section-native-mkb": "Play with mouse & keyboard",
"section-news": "News",
@@ -384,7 +389,6 @@ const Texts = {
(e: any) => `觸控遊玩佈局由 ${e.name} 提供`,
],
"touch-controller": "Touch controller",
"transparent-background": "Transparent background",
"true-achievements": "TrueAchievements",
"ui": "UI",
"unexpected-behavior": "May cause unexpected behavior",

View File

@@ -32,7 +32,7 @@ export class TrueAchievements {
onClick: this.onClick,
});
this.$hiddenLink = CE<HTMLAnchorElement>('a', {
this.$hiddenLink = CE('a', {
target: '_blank',
});
}

View File

@@ -18,8 +18,8 @@ export function checkForUpdate() {
const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours
const currentVersion = getPref<VersionCurrent>(PrefKey.VERSION_CURRENT);
const lastCheck = getPref<VersionLastCheck>(PrefKey.VERSION_LAST_CHECK);
const currentVersion = getPref(PrefKey.VERSION_CURRENT);
const lastCheck = getPref(PrefKey.VERSION_LAST_CHECK);
const now = Math.round((+new Date) / 1000);
if (currentVersion === SCRIPT_VERSION && now - lastCheck < CHECK_INTERVAL_SECONDS) {
@@ -77,13 +77,10 @@ export function hashCode(str: string): number {
export function renderString(str: string, obj: any){
return str.replace(/\$\{.+?\}/g, match => {
const key = match.substring(2, match.length - 1);
if (key in obj) {
return obj[key];
}
return match;
// Accept ${var} and $var$
return str.replace(/\$\{([A-Za-z0-9_$]+)\}|\$([A-Za-z0-9_$]+)\$/g, (match, p1, p2) => {
const name = p1 || p2;
return name in obj ? obj[name] : match;
});
}
@@ -158,13 +155,13 @@ export function clearAllData() {
}
export function blockAllNotifications() {
const blockFeatures = getPref<BlockFeature[]>(PrefKey.BLOCK_FEATURES);
const blockFeatures = getPref(PrefKey.BLOCK_FEATURES);
const blockAll = [BlockFeature.FRIENDS, BlockFeature.NOTIFICATIONS_ACHIEVEMENTS, BlockFeature.NOTIFICATIONS_INVITES].every(value => blockFeatures.includes(value));
return blockAll;
}
export function blockSomeNotifications() {
const blockFeatures = getPref<BlockFeature[]>(PrefKey.BLOCK_FEATURES);
const blockFeatures = getPref(PrefKey.BLOCK_FEATURES);
if (blockAllNotifications()) {
return false;
}

View File

@@ -11,7 +11,7 @@ import { getPreferredServerRegion } from "./region";
import { BypassServerIps } from "@/enums/bypass-servers";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
import { NativeMkbMode, StreamResolution, TouchControllerMode } from "@/enums/pref-values";
import { NativeMkbMode, TouchControllerMode } from "@/enums/pref-values";
import { BxEventBus } from "./bx-event-bus";
export class XcloudInterceptor {
@@ -43,7 +43,7 @@ export class XcloudInterceptor {
};
private static async handleLogin(request: RequestInfo | URL, init?: RequestInit) {
const bypassServer = getPref<string>(PrefKey.SERVER_BYPASS_RESTRICTION);
const bypassServer = getPref(PrefKey.SERVER_BYPASS_RESTRICTION);
if (bypassServer !== 'off') {
const ip = BypassServerIps[bypassServer as keyof typeof BypassServerIps];
ip && (request as Request).headers.set('X-Forwarded-For', ip);
@@ -110,8 +110,8 @@ export class XcloudInterceptor {
private static async handlePlay(request: RequestInfo | URL, init?: RequestInit) {
BxEventBus.Stream.emit('state.loading', {});
const PREF_STREAM_TARGET_RESOLUTION = getPref<StreamResolution>(PrefKey.STREAM_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref<StreamPreferredLocale>(PrefKey.STREAM_PREFERRED_LOCALE);
const PREF_STREAM_TARGET_RESOLUTION = getPref(PrefKey.STREAM_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE);
const url = (typeof request === 'string') ? request : (request as Request).url;
const parsedUrl = new URL(url);
@@ -174,7 +174,7 @@ export class XcloudInterceptor {
}
// Touch controller for all games
if (isFullVersion() && getPref<TouchControllerMode>(PrefKey.TOUCH_CONTROLLER_MODE) === TouchControllerMode.ALL) {
if (isFullVersion() && getPref(PrefKey.TOUCH_CONTROLLER_MODE) === TouchControllerMode.ALL) {
const titleInfo = STATES.currentStream.titleInfo;
if (titleInfo?.details.hasTouchSupport) {
TouchController.disable();
@@ -200,11 +200,11 @@ export class XcloudInterceptor {
let overrideMkb: boolean | null = null;
if (getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.ON || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
if (getPref(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.ON || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
overrideMkb = true;
}
if (getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.OFF) {
if (getPref(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.OFF) {
overrideMkb = false;
}

View File

@@ -8,7 +8,7 @@ import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
import type { RemotePlayConsoleAddresses } from "@/types/network";
import { RemotePlayManager } from "@/modules/remote-play-manager";
import { StreamResolution, TouchControllerMode } from "@/enums/pref-values";
import { TouchControllerMode } from "@/enums/pref-values";
import { BxEventBus } from "./bx-event-bus";
export class XhomeInterceptor {
@@ -71,7 +71,7 @@ export class XhomeInterceptor {
private static async handleInputConfigs(request: Request | URL, opts: { [index: string]: any }) {
const response = await NATIVE_FETCH(request);
if (getPref<TouchControllerMode>(PrefKey.TOUCH_CONTROLLER_MODE) !== TouchControllerMode.ALL) {
if (getPref(PrefKey.TOUCH_CONTROLLER_MODE) !== TouchControllerMode.ALL) {
return response;
}
@@ -152,7 +152,7 @@ export class XhomeInterceptor {
headers.authorization = `Bearer ${RemotePlayManager.getInstance()!.getXhomeToken()}`;
// Patch resolution
const osName = getOsNameFromResolution(getPref<StreamResolution>(PrefKey.REMOTE_PLAY_STREAM_RESOLUTION));
const osName = getOsNameFromResolution(getPref(PrefKey.REMOTE_PLAY_STREAM_RESOLUTION));
headers['x-ms-device-info'] = JSON.stringify(generateMsDeviceInfo(osName));
const opts: Record<string, any> = {