Merge Global settings and Stream settings into one dialog

This commit is contained in:
redphx
2024-07-27 16:09:13 +07:00
parent 023799232e
commit 9dfdeb8f12
76 changed files with 3281 additions and 2466 deletions

View File

@@ -7,13 +7,18 @@ import { EmulatedMkbHandler } from "./mkb/mkb-handler";
import { StreamStats } from "./stream/stream-stats";
import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone";
import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui";
import { PrefKey, getPref } from "@utils/preferences";
import { SoundShortcut } from "./shortcuts/shortcut-sound";
import { BxEvent } from "@/utils/bx-event";
import { AppInterface } from "@/utils/global";
import { BxSelectElement } from "@/web-components/bx-select";
import { setNearby } from "@/utils/navigation-utils";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { SettingsNavigationDialog } from "./ui/dialog/settings-dialog";
const enum ShortcutAction {
BETTER_XCLOUD_SETTINGS_SHOW = 'bx-settings-show',
enum ShortcutAction {
STREAM_SCREENSHOT_CAPTURE = 'stream-screenshot-capture',
STREAM_MENU_SHOW = 'stream-menu-show',
@@ -42,7 +47,7 @@ export class ControllerShortcut {
static #$selectActions: Partial<{[key in GamepadKey]: HTMLSelectElement}> = {};
static #$container: HTMLElement;
static #ACTIONS: {[key: string]: (ShortcutAction | null)[]} = {};
static #ACTIONS: {[key: string]: (ShortcutAction | null)[]} | null = null;
static reset(index: number) {
ControllerShortcut.#buttonsCache[index] = [];
@@ -50,8 +55,12 @@ export class ControllerShortcut {
}
static handle(gamepad: Gamepad): boolean {
if (!ControllerShortcut.#ACTIONS) {
ControllerShortcut.#ACTIONS = ControllerShortcut.#getActionsFromStorage();
}
const gamepadIndex = gamepad.index;
const actions = ControllerShortcut.#ACTIONS[gamepad.id];
const actions = ControllerShortcut.#ACTIONS![gamepad.id];
if (!actions) {
return false;
}
@@ -83,6 +92,10 @@ export class ControllerShortcut {
static #runAction(action: ShortcutAction) {
switch (action) {
case ShortcutAction.BETTER_XCLOUD_SETTINGS_SHOW:
SettingsNavigationDialog.getInstance().show();
break;
case ShortcutAction.STREAM_SCREENSHOT_CAPTURE:
Screenshot.takeScreenshot();
break;
@@ -122,15 +135,16 @@ export class ControllerShortcut {
}
static #updateAction(profile: string, button: GamepadKey, action: ShortcutAction | null) {
if (!(profile in ControllerShortcut.#ACTIONS)) {
ControllerShortcut.#ACTIONS[profile] = [];
const actions = ControllerShortcut.#ACTIONS!;
if (!(profile in actions)) {
actions[profile] = [];
}
if (!action) {
action = null;
}
ControllerShortcut.#ACTIONS[profile][button] = action;
actions[profile][button] = action;
// Remove empty profiles
for (const key in ControllerShortcut.#ACTIONS) {
@@ -194,7 +208,7 @@ export class ControllerShortcut {
}
static #switchProfile(profile: string) {
let actions = ControllerShortcut.#ACTIONS[profile];
let actions = ControllerShortcut.#ACTIONS![profile];
if (!actions) {
actions = [];
}
@@ -212,11 +226,15 @@ export class ControllerShortcut {
}
}
static #getActionsFromStorage() {
return JSON.parse(window.localStorage.getItem(ControllerShortcut.#STORAGE_KEY) || '{}');
}
static renderSettings() {
const PREF_CONTROLLER_FRIENDLY_UI = getPref(PrefKey.UI_CONTROLLER_FRIENDLY);
// Read actions from localStorage
ControllerShortcut.#ACTIONS = JSON.parse(window.localStorage.getItem(ControllerShortcut.#STORAGE_KEY) || '{}');
ControllerShortcut.#ACTIONS = ControllerShortcut.#getActionsFromStorage();
const buttons: Map<GamepadKey, PrompFont> = new Map();
buttons.set(GamepadKey.Y, PrompFont.Y);
@@ -242,6 +260,10 @@ export class ControllerShortcut {
buttons.set(GamepadKey.R3, PrompFont.R3);
const actions: {[key: string]: Partial<{[key in ShortcutAction]: string | string[]}>} = {
[t('better-xcloud')]: {
[ShortcutAction.BETTER_XCLOUD_SETTINGS_SHOW]: [t('settings'), t('show')],
},
[t('device')]: AppInterface && {
[ShortcutAction.DEVICE_SOUND_TOGGLE]: [t('sound'), t('toggle')],
[ShortcutAction.DEVICE_VOLUME_INC]: [t('volume'), t('increase')],
@@ -261,7 +283,7 @@ export class ControllerShortcut {
[ShortcutAction.STREAM_MENU_SHOW]: [t('menu'), t('show')],
[ShortcutAction.STREAM_STATS_TOGGLE]: [t('stats'), t('show-hide')],
[ShortcutAction.STREAM_MICROPHONE_TOGGLE]: [t('microphone'), t('toggle')],
}
},
};
const $baseSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'}, CE('option', {value: ''}, '---'));
@@ -293,13 +315,24 @@ export class ControllerShortcut {
let $remap: HTMLElement;
const $selectProfile = CE<HTMLSelectElement>('select', {class: 'bx-shortcut-profile', autocomplete: 'off'});
const $container = CE('div', {'data-has-gamepad': 'false'},
const $profile = PREF_CONTROLLER_FRIENDLY_UI ? BxSelectElement.wrap($selectProfile) : $selectProfile;
const $container = CE('div', {
'data-has-gamepad': 'false',
_nearby: {
focus: $profile,
},
},
CE('div', {},
CE('p', {class: 'bx-shortcut-note'}, t('controller-shortcuts-connect-note')),
),
$remap = CE('div', {},
PREF_CONTROLLER_FRIENDLY_UI ? CE('div', {'data-focus-container': 'true'}, BxSelectElement.wrap($selectProfile)) : $selectProfile,
CE('div', {
_nearby: {
focus: $profile,
},
}, $profile),
CE('p', {class: 'bx-shortcut-note'},
CE('span', {class: 'bx-prompt'}, PrompFont.HOME),
': ' + t('controller-shortcuts-xbox-note'),
@@ -337,7 +370,6 @@ export class ControllerShortcut {
for (const [button, prompt] of buttons) {
const $row = CE('div', {
class: 'bx-shortcut-row',
'data-focus-container': 'true',
});
const $label = CE('label', {class: 'bx-prompt'}, `${PrompFont.HOME} + ${prompt}`);
@@ -359,9 +391,16 @@ export class ControllerShortcut {
ControllerShortcut.#$selectActions[button] = $select;
if (PREF_CONTROLLER_FRIENDLY_UI) {
$div.appendChild(BxSelectElement.wrap($select));
const $bxSelect = BxSelectElement.wrap($select);
$div.appendChild($bxSelect);
setNearby($row, {
focus: $bxSelect,
});
} else {
$div.appendChild($select);
setNearby($row, {
focus: $select,
});
}
$row.appendChild($label);

View File

@@ -5,8 +5,9 @@ import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import type { BaseGameBarAction } from "./action-base";
import { STATES } from "@utils/global";
import { PrefKey, getPref } from "@utils/preferences";
import { MicrophoneAction } from "./action-microphone";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
export class GameBar {

View File

@@ -1,8 +1,9 @@
import { CE } from "@utils/html";
import { getPreferredServerRegion } from "@utils/region";
import { PrefKey, getPref } from "@utils/preferences";
import { t } from "@utils/translation";
import { STATES } from "@utils/global";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
export class LoadingScreen {
static #$bgStyle: HTMLElement;

View File

@@ -2,7 +2,6 @@ import { MkbPreset } from "./mkb-preset";
import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo, WheelCode } from "@enums/mkb";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { BxEvent } from "@utils/bx-event";
import { PrefKey, getPref } from "@utils/preferences";
import { Toast } from "@utils/toast";
import { t } from "@utils/translation";
import { LocalDb } from "@utils/local-db";
@@ -14,7 +13,10 @@ import { BxLogger } from "@utils/bx-logger";
import { PointerClient } from "./pointer-client";
import { NativeMkbHandler } from "./native-mkb-handler";
import { MkbHandler, MouseDataProvider } from "./base-mkb-handler";
import { StreamSettings } from "../stream/stream-settings";
import { SettingsNavigationDialog } from "../ui/dialog/settings-dialog";
import { NavigationDialogManager } from "../ui/dialog/navigation-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'MkbHandler';
@@ -507,7 +509,10 @@ export class EmulatedMkbHandler extends MkbHandler {
e.preventDefault();
e.stopPropagation();
StreamSettings.getInstance().show('mkb');
// Show Settings dialog & focus the MKB tab
const dialog = SettingsNavigationDialog.getInstance();
dialog.focusTab('mkb');
NavigationDialogManager.getInstance().show(dialog);
},
}),
),

View File

@@ -1,9 +1,9 @@
import { t } from "@utils/translation";
import { SettingElementType } from "@utils/settings";
import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "@enums/mkb";
import { EmulatedMkbHandler } from "./mkb-handler";
import type { MkbPresetData, MkbConvertedPresetData } from "@/types/mkb";
import type { PreferenceSettings } from "@/types/preferences";
import { SettingElementType } from "@/utils/setting-element";
export class MkbPreset {

View File

@@ -1,16 +1,17 @@
import { CE, createButton, ButtonStyle } from "@utils/html";
import { t } from "@utils/translation";
import { Dialog } from "@modules/dialog";
import { getPref, setPref, PrefKey } from "@utils/preferences";
import { KeyHelper } from "./key-helper";
import { MkbPreset } from "./mkb-preset";
import { EmulatedMkbHandler } from "./mkb-handler";
import { LocalDb } from "@utils/local-db";
import { BxIcon } from "@utils/bx-icon";
import { SettingElement } from "@utils/settings";
import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb";
import { MkbPresetKey, GamepadKey, GamepadKeyName } from "@enums/mkb";
import { deepClone } from "@utils/global";
import { SettingElement } from "@/utils/setting-element";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
type MkbRemapperElements = {
@@ -317,7 +318,7 @@ export class MkbRemapper {
render() {
this.#$.wrapper = CE('div', {'class': 'bx-mkb-settings'});
this.#$.presetsSelect = CE<HTMLSelectElement>('select', {});
this.#$.presetsSelect = CE<HTMLSelectElement>('select', {tabindex: -1});
this.#$.presetsSelect!.addEventListener('change', e => {
this.#switchPreset(parseInt((e.target as HTMLSelectElement).value));
});
@@ -336,80 +337,84 @@ export class MkbRemapper {
};
const $header = CE('div', {'class': 'bx-mkb-preset-tools'},
this.#$.presetsSelect,
// Rename button
createButton({
title: t('rename'),
icon: BxIcon.CURSOR_TEXT,
onClick: e => {
const preset = this.#getCurrentPreset();
this.#$.presetsSelect,
// Rename button
createButton({
title: t('rename'),
icon: BxIcon.CURSOR_TEXT,
tabIndex: -1,
onClick: e => {
const preset = this.#getCurrentPreset();
let newName = promptNewName(preset.name);
if (!newName || newName === preset.name) {
let newName = promptNewName(preset.name);
if (!newName || newName === preset.name) {
return;
}
// Update preset with new name
preset.name = newName;
LocalDb.INSTANCE.updatePreset(preset).then(id => this.#refresh());
},
}),
// New button
createButton({
icon: BxIcon.NEW,
title: t('new'),
tabIndex: -1,
onClick: e => {
let newName = promptNewName('');
if (!newName) {
return;
}
// Update preset with new name
preset.name = newName;
LocalDb.INSTANCE.updatePreset(preset).then(id => this.#refresh());
// Create new preset selected name
LocalDb.INSTANCE.newPreset(newName, MkbPreset.DEFAULT_PRESET).then(id => {
this.#STATE.currentPresetId = id;
this.#refresh();
});
},
}),
// New button
createButton({
icon: BxIcon.NEW,
title: t('new'),
onClick: e => {
let newName = promptNewName('');
if (!newName) {
return;
}
// Copy button
createButton({
icon: BxIcon.COPY,
title: t('copy'),
tabIndex: -1,
onClick: e => {
const preset = this.#getCurrentPreset();
// Create new preset selected name
LocalDb.INSTANCE.newPreset(newName, MkbPreset.DEFAULT_PRESET).then(id => {
this.#STATE.currentPresetId = id;
this.#refresh();
});
},
}),
let newName = promptNewName(`${preset.name} (2)`);
if (!newName) {
return;
}
// Copy button
createButton({
icon: BxIcon.COPY,
title: t('copy'),
onClick: e => {
const preset = this.#getCurrentPreset();
// Create new preset selected name
LocalDb.INSTANCE.newPreset(newName, preset.data).then(id => {
this.#STATE.currentPresetId = id;
this.#refresh();
});
},
}),
let newName = promptNewName(`${preset.name} (2)`);
if (!newName) {
return;
}
// Delete button
createButton({
icon: BxIcon.TRASH,
style: ButtonStyle.DANGER,
title: t('delete'),
tabIndex: -1,
onClick: e => {
if (!confirm(t('confirm-delete-preset'))) {
return;
}
// Create new preset selected name
LocalDb.INSTANCE.newPreset(newName, preset.data).then(id => {
this.#STATE.currentPresetId = id;
this.#refresh();
});
},
}),
// Delete button
createButton({
icon: BxIcon.TRASH,
style: ButtonStyle.DANGER,
title: t('delete'),
onClick: e => {
if (!confirm(t('confirm-delete-preset'))) {
return;
}
LocalDb.INSTANCE.deletePreset(this.#STATE.currentPresetId).then(id => {
this.#STATE.currentPresetId = 0;
this.#refresh();
});
},
}),
);
LocalDb.INSTANCE.deletePreset(this.#STATE.currentPresetId).then(id => {
this.#STATE.currentPresetId = 0;
this.#refresh();
});
},
}),
);
this.#$.wrapper!.appendChild($header);
@@ -426,11 +431,11 @@ export class MkbRemapper {
const $fragment = document.createDocumentFragment();
for (let i = 0; i < keysPerButton; i++) {
$elm = CE('button', {
type: 'button',
'data-prompt': buttonPrompt,
'data-button-index': buttonIndex,
'data-key-slot': i,
}, ' ');
type: 'button',
'data-prompt': buttonPrompt,
'data-button-index': buttonIndex,
'data-key-slot': i,
}, ' ');
$elm.addEventListener('mouseup', this.#onBindingKey);
$elm.addEventListener('contextmenu', this.#onContextMenu);
@@ -440,9 +445,9 @@ export class MkbRemapper {
}
const $keyRow = CE('div', {'class': 'bx-mkb-key-row'},
CE('label', {'title': buttonName}, buttonPrompt),
$fragment,
);
CE('label', {'title': buttonName}, buttonPrompt),
$fragment,
);
$rows.appendChild($keyRow);
}
@@ -460,10 +465,13 @@ export class MkbRemapper {
const onChange = (e: Event, value: any) => {
(this.#STATE.editingPresetData!.mouse as any)[key] = value;
};
const $row = CE('div', {'class': 'bx-stream-settings-row'},
CE('label', {'for': `bx_setting_${key}`}, setting.label),
$elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params),
);
const $row = CE('label', {
class: 'bx-settings-row',
for: `bx_setting_${key}`
},
CE('span', {class: 'bx-settings-label'}, setting.label),
$elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params),
);
$mouseSettings.appendChild($row);
this.#$.allMouseElements[key as MkbPresetKey] = $elm;
@@ -474,59 +482,63 @@ export class MkbRemapper {
// Render action buttons
const $actionButtons = CE('div', {'class': 'bx-mkb-action-buttons'},
CE('div', {},
// Edit button
createButton({
label: t('edit'),
onClick: e => this.#toggleEditing(true),
}),
CE('div', {},
// Edit button
createButton({
label: t('edit'),
tabIndex: -1,
onClick: e => this.#toggleEditing(true),
}),
// Activate button
this.#$.activateButton = createButton({
label: t('activate'),
style: ButtonStyle.PRIMARY,
onClick: e => {
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId);
EmulatedMkbHandler.getInstance().refreshPresetData();
// Activate button
this.#$.activateButton = createButton({
label: t('activate'),
style: ButtonStyle.PRIMARY,
tabIndex: -1,
onClick: e => {
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId);
EmulatedMkbHandler.getInstance().refreshPresetData();
this.#refresh();
},
}),
),
this.#refresh();
},
}),
),
CE('div', {},
// Cancel button
createButton({
label: t('cancel'),
style: ButtonStyle.GHOST,
onClick: e => {
// Restore preset
this.#switchPreset(this.#STATE.currentPresetId);
this.#toggleEditing(false);
},
}),
CE('div', {},
// Cancel button
createButton({
label: t('cancel'),
style: ButtonStyle.GHOST,
tabIndex: -1,
onClick: e => {
// Restore preset
this.#switchPreset(this.#STATE.currentPresetId);
this.#toggleEditing(false);
},
}),
// Save button
createButton({
label: t('save'),
style: ButtonStyle.PRIMARY,
onClick: e => {
const updatedPreset = deepClone(this.#getCurrentPreset());
updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData;
// Save button
createButton({
label: t('save'),
style: ButtonStyle.PRIMARY,
tabIndex: -1,
onClick: e => {
const updatedPreset = deepClone(this.#getCurrentPreset());
updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData;
LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => {
// If this is the default preset => refresh preset data
if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) {
EmulatedMkbHandler.getInstance().refreshPresetData();
}
LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => {
// If this is the default preset => refresh preset data
if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) {
EmulatedMkbHandler.getInstance().refreshPresetData();
}
this.#toggleEditing(false);
this.#refresh();
});
},
}),
),
);
this.#toggleEditing(false);
this.#refresh();
});
},
}),
),
);
this.#$.wrapper!.appendChild($actionButtons);

View File

@@ -5,7 +5,8 @@ import { MkbHandler } from "./base-mkb-handler";
import { t } from "@/utils/translation";
import { BxEvent } from "@/utils/bx-event";
import { ButtonStyle, CE, createButton } from "@/utils/html";
import { PrefKey, getPref } from "@/utils/preferences";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
type NativeMouseData = {
X: number,

View File

@@ -1,6 +1,5 @@
import { AppInterface, SCRIPT_VERSION, STATES } from "@utils/global";
import { BX_FLAGS } from "@utils/bx-flags";
import { getPref, PrefKey } from "@utils/preferences";
import { VibrationManager } from "@modules/vibration-manager";
import { BxLogger } from "@utils/bx-logger";
import { hashCode, renderString } from "@utils/utils";
@@ -15,6 +14,9 @@ import codeRemotePlayKeepAlive from "./patches/remote-play-keep-alive.js" with {
import codeVibrationAdjust from "./patches/vibration-adjust.js" with { type: "text" };
import { FeatureGates } from "@/utils/feature-gates.js";
import { UiSection } from "@/enums/ui-sections.js";
import { PrefKey } from "@/enums/pref-keys.js";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { GamePassCloudGallery } from "@/enums/game-pass-gallery.js";
type PatchArray = (keyof typeof PATCHES)[];
@@ -27,7 +29,7 @@ const PATCHES = {
disableAiTrack(str: string) {
const text = '.track=function(';
const index = str.indexOf(text);
if (index === -1) {
if (index < 0) {
return false;
}
@@ -94,7 +96,7 @@ const PATCHES = {
// Replace "/direct-connect" with "/play"
remotePlayDirectConnectUrl(str: string) {
const index = str.indexOf('/direct-connect');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -160,7 +162,7 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
patchPollGamepads(str: string) {
const index = str.indexOf('},this.pollGamepads=()=>{');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -231,7 +233,7 @@ logFunc(logTag, '//', logMessage);
// Override website's settings
overrideSettings(str: string) {
const index = str.indexOf(',EnableStreamGate:');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -249,7 +251,7 @@ logFunc(logTag, '//', logMessage);
disableGamepadDisconnectedScreen(str: string) {
const index = str.indexOf('"GamepadDisconnected_Title",');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -286,7 +288,7 @@ logFunc(logTag, '//', logMessage);
// Disable StreamGate
disableStreamGate(str: string) {
const index = str.indexOf('case"partially-ready":');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -316,7 +318,7 @@ window.dispatchEvent(new Event("${BxEvent.TOUCH_LAYOUT_MANAGER_READY}"));
patchBabylonRendererClass(str: string) {
// ()=>{a.current.render(),h.current=window.requestAnimationFrame(l)
let index = str.indexOf('.current.render(),');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -454,7 +456,7 @@ BxEvent.dispatch(window, BxEvent.XCLOUD_POLLING_MODE_CHANGED, {mode: e});
patchGamepadPolling(str: string) {
let index = str.indexOf('.shouldHandleGamepadInput)())return void');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -466,7 +468,7 @@ BxEvent.dispatch(window, BxEvent.XCLOUD_POLLING_MODE_CHANGED, {mode: e});
patchXcloudTitleInfo(str: string) {
const text = 'async cloudConnect';
let index = str.indexOf(text);
if (index === -1) {
if (index < 0) {
return false;
}
@@ -488,7 +490,7 @@ BxLogger.info('patchXcloudTitleInfo', ${titleInfoVar});
patchRemotePlayMkb(str: string) {
const text = 'async homeConsoleConnect';
let index = str.indexOf(text);
if (index === -1) {
if (index < 0) {
return false;
}
@@ -709,7 +711,7 @@ true` + text;
index > -1 && (index = str.indexOf('return ', index));
index > -1 && (index = str.indexOf('?', index));
if (index === -1) {
if (index < 0) {
return false;
}
@@ -720,12 +722,12 @@ true` + text;
// Don't render "Play With Friends" sections
ignorePlayWithFriendsSection(str: string) {
let index = str.indexOf('location:"PlayWithFriendsRow",');
if (index === -1) {
if (index < 0) {
return false;
}
index = str.indexOf('return', index - 50);
if (index === -1) {
if (index < 0) {
return false;
}
@@ -736,14 +738,14 @@ true` + text;
// Don't render "All Games" sections
ignoreAllGamesSection(str: string) {
let index = str.indexOf('className:"AllGamesRow-module__allGamesRowContainer');
if (index === -1) {
if (index < 0) {
return false;
}
index = str.indexOf('grid:!0,', index);
index > -1 && (index = str.indexOf('(0,', index - 70));
if (index === -1) {
if (index < 0) {
return false;
}
@@ -751,6 +753,61 @@ true` + text;
return str;
},
// home-page.js
ignorePlayWithTouchSection(str: string) {
let index = str.indexOf('("Play_With_Touch"),');
if (index < 0) {
return false;
}
index = str.indexOf('const ', index - 100);
if (index < 0) {
return false;
}
str = str.substring(0, index) + 'return null;' + str.substring(index);
return str;
},
// home-page.js
ignoreSiglSections(str: string) {
let index = str.indexOf('SiglRow-module__heroCard___');
if (index < 0) {
return false;
}
index = str.indexOf('const[', index - 300);
if (index < 0) {
return false;
}
const PREF_HIDE_SECTIONS = getPref(PrefKey.UI_HIDE_SECTIONS) as UiSection[];
const siglIds: GamePassCloudGallery[] = [];
const sections: Partial<Record<UiSection, GamePassCloudGallery>> = {
[UiSection.NATIVE_MKB]: GamePassCloudGallery.NATIVE_MKB,
[UiSection.MOST_POPULAR]: GamePassCloudGallery.MOST_POPULAR,
};
PREF_HIDE_SECTIONS.forEach(section => {
const galleryId = sections[section];
galleryId && siglIds.push(galleryId);
});
const checkSyntax = siglIds.map(item => `siglId === "${item}"`).join(' || ');
const newCode = `
if (e && e.id) {
const siglId = e.id;
if (${checkSyntax}) {
return null;
}
}
`;
str = str.substring(0, index) + newCode + str.substring(index);
return str;
},
// Override Storage.getSettings()
overrideStorageGetSettings(str: string) {
const text = '}getSetting(e){';
@@ -774,12 +831,12 @@ if (this.baseStorageKey in window.BX_EXPOSED.overrideSettings) {
// game-stream.js 24.16.4
alwaysShowStreamHud(str: string) {
let index = str.indexOf(',{onShowStreamMenu:');
if (index === -1) {
if (index < 0) {
return false;
}
index = str.indexOf('&&(0,', index - 100);
if (index === -1) {
if (index < 0) {
return false;
}
@@ -791,7 +848,7 @@ if (this.baseStorageKey in window.BX_EXPOSED.overrideSettings) {
// 24225.js#4127, 24.17.11
patchSetCurrentlyFocusedInteractable(str: string) {
let index = str.indexOf('.setCurrentlyFocusedInteractable=(');
if (index === -1) {
if (index < 0) {
return false;
}
@@ -803,12 +860,12 @@ if (this.baseStorageKey in window.BX_EXPOSED.overrideSettings) {
// product-details-page.js#2388, 24.17.20
detectProductDetailsPage(str: string) {
let index = str.indexOf('{location:"ProductDetailPage",');
if (index === -1) {
if (index < 0) {
return false;
}
index = str.indexOf('return', index - 40);
if (index === -1) {
if (index < 0) {
return false;
}
@@ -847,6 +904,8 @@ let PATCH_ORDERS: PatchArray = [
getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.FRIENDS) && 'ignorePlayWithFriendsSection',
getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.ALL_GAMES) && 'ignoreAllGamesSection',
getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.TOUCH) && 'ignorePlayWithTouchSection',
(getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.NATIVE_MKB) || getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.MOST_POPULAR)) && 'ignoreSiglSections',
...(getPref(PrefKey.BLOCK_TRACKING) ? [
'disableAiTrack',
@@ -978,7 +1037,8 @@ export class Patcher {
}
const func = item[1][id];
let str = func.toString();
const funcStr = func.toString();
let patchedFuncStr = funcStr;
let modified = false;
@@ -993,15 +1053,15 @@ export class Patcher {
}
// Check function against patch
const patchedStr = PATCHES[patchName].call(null, str);
const tmpStr = PATCHES[patchName].call(null, patchedFuncStr);
// Not patched
if (!patchedStr) {
if (!tmpStr) {
continue;
}
modified = true;
str = patchedStr;
patchedFuncStr = tmpStr;
BxLogger.info(LOG_TAG, `${patchName}`);
appliedPatches.push(patchName);
@@ -1014,7 +1074,13 @@ export class Patcher {
// Apply patched functions
if (modified) {
item[1][id] = eval(str);
try {
item[1][id] = eval(patchedFuncStr);
} catch (e: unknown) {
if (e instanceof Error) {
BxLogger.error(LOG_TAG, 'Error', appliedPatches, e.message);
}
}
}
// Save to cache

View File

@@ -1,7 +1,8 @@
import vertClarityBoost from "./shaders/clarity_boost.vert" with { type: "text" };
import fsClarityBoost from "./shaders/clarity_boost.fs" with { type: "text" };
import { BxLogger } from "@/utils/bx-logger";
import { getPref, PrefKey } from "@/utils/preferences";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'WebGL2Player';

View File

@@ -3,15 +3,16 @@ import { CE, createButton, ButtonStyle } from "@utils/html";
import { BxIcon } from "@utils/bx-icon";
import { Toast } from "@utils/toast";
import { BxEvent } from "@utils/bx-event";
import { getPref, PrefKey, setPref } from "@utils/preferences";
import { t } from "@utils/translation";
import { localRedirect } from "@modules/ui/ui";
import { BxLogger } from "@utils/bx-logger";
import { HeaderSection } from "./ui/header";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'RemotePlay';
enum RemotePlayConsoleState {
const enum RemotePlayConsoleState {
ON = 'On',
OFF = 'Off',
STANDBY = 'ConnectedStandby',
@@ -53,8 +54,8 @@ export class RemotePlay {
env: {
clientAppId: window.location.host,
clientAppType: 'browser',
clientAppVersion: '21.1.98',
clientSdkVersion: '8.5.3',
clientAppVersion: '24.17.36',
clientSdkVersion: '10.1.14',
httpEnvironment: 'prod',
sdkInstallId: '',
},
@@ -82,7 +83,7 @@ export class RemotePlay {
},
browser: {
browserName: 'chrome',
browserVersion: '119.0',
browserVersion: '125.0',
},
},
};

View File

@@ -1,9 +1,9 @@
import { t } from "@utils/translation";
import { STATES } from "@utils/global";
import { PrefKey, getPref, setPref } from "@utils/preferences";
import { Toast } from "@utils/toast";
import { BxEvent } from "@/utils/bx-event";
import { ceilToNearest, floorToNearest } from "@/utils/utils";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
export class SoundShortcut {
static adjustGainNodeVolume(amount: number): number {
@@ -27,14 +27,11 @@ export class SoundShortcut {
newValue = currentValue + amount;
}
newValue = setPref(PrefKey.AUDIO_VOLUME, newValue);
newValue = setPref(PrefKey.AUDIO_VOLUME, newValue, true);
SoundShortcut.setGainNodeVolume(newValue);
// Show toast
Toast.show(`${t('stream')} ${t('volume')}`, newValue + '%', {instant: true});
BxEvent.dispatch(window, BxEvent.GAINNODE_VOLUME_CHANGED, {
volume: newValue,
});
return newValue;
}
@@ -51,10 +48,7 @@ export class SoundShortcut {
let targetValue: number;
if (settingValue === 0) { // settingValue is 0 => set to 100
targetValue = 100;
setPref(PrefKey.AUDIO_VOLUME, targetValue);
BxEvent.dispatch(window, BxEvent.GAINNODE_VOLUME_CHANGED, {
volume: targetValue,
});
setPref(PrefKey.AUDIO_VOLUME, targetValue, true);
} else if (gainValue === 0) { // is being muted => set to settingValue
targetValue = settingValue;
} else { // not being muted => mute

View File

@@ -1,9 +1,10 @@
import { CE } from "@/utils/html";
import { WebGL2Player } from "./player/webgl2-player";
import { getPref, PrefKey } from "@/utils/preferences";
import { Screenshot } from "@/utils/screenshot";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { STATES } from "@/utils/global";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
export type StreamPlayerOptions = Partial<{
processing: string,

View File

@@ -1,8 +1,9 @@
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { STATES } from "@utils/global";
import { getPref, PrefKey, setPref } from "@utils/preferences";
import { UserAgent } from "@utils/user-agent";
import type { StreamPlayerOptions } from "../stream-player";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
export function onChangeVideoPlayerType() {
const playerType = getPref(PrefKey.VIDEO_PLAYER_TYPE);
@@ -10,16 +11,22 @@ export function onChangeVideoPlayerType() {
const $videoSharpness = document.getElementById('bx_setting_video_sharpness') as HTMLElement;
const $videoPowerPreference = document.getElementById('bx_setting_video_power_preference') as HTMLElement;
if (!$videoProcessing) {
return;
}
let isDisabled = false;
const $optCas = $videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement;
if (playerType === StreamPlayerType.WEBGL2) {
($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = false;
$optCas && ($optCas.disabled = false);
} else {
// Only allow USM when player type is Video
$videoProcessing.value = StreamVideoProcessing.USM;
setPref(PrefKey.VIDEO_PROCESSING, StreamVideoProcessing.USM);
($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = true;
$optCas && ($optCas.disabled = true);
if (UserAgent.isSafari()) {
isDisabled = true;
@@ -30,7 +37,7 @@ export function onChangeVideoPlayerType() {
$videoSharpness.dataset.disabled = isDisabled.toString();
// Hide Power Preference setting if renderer isn't WebGL2
$videoPowerPreference.closest('.bx-stream-settings-row')!.classList.toggle('bx-gone', playerType !== StreamPlayerType.WEBGL2);
$videoPowerPreference.closest('.bx-settings-row')!.classList.toggle('bx-gone', playerType !== StreamPlayerType.WEBGL2);
updateVideoPlayer();
}

View File

@@ -1,795 +0,0 @@
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import { STATES, AppInterface } from "@utils/global";
import { ButtonStyle, CE, createButton, createSvgIcon } from "@utils/html";
import { PrefKey, Preferences, getPref, toPrefElement } from "@utils/preferences";
import { t } from "@utils/translation";
import { ControllerShortcut } from "../controller-shortcut";
import { MkbRemapper } from "../mkb/mkb-remapper";
import { NativeMkbHandler } from "../mkb/native-mkb-handler";
import { SoundShortcut } from "../shortcuts/shortcut-sound";
import { TouchController } from "../touch-controller";
import { VibrationManager } from "../vibration-manager";
import { StreamStats } from "./stream-stats";
import { BxSelectElement } from "@/web-components/bx-select";
import { onChangeVideoPlayerType, updateVideoPlayer } from "./stream-settings-utils";
import { GamepadKey } from "@/enums/mkb";
import { EmulatedMkbHandler } from "../mkb/mkb-handler";
enum NavigationDirection {
UP = 1,
RIGHT,
DOWN,
LEFT,
}
enum FocusContainer {
OUTSIDE,
TABS,
SETTINGS,
}
export class StreamSettings {
private static instance: StreamSettings;
public static getInstance(): StreamSettings {
if (!StreamSettings.instance) {
StreamSettings.instance = new StreamSettings();
}
return StreamSettings.instance;
}
static readonly MAIN_CLASS = 'bx-stream-settings-dialog';
private static readonly GAMEPAD_POLLING_INTERVAL = 50;
private static readonly GAMEPAD_KEYS = [
GamepadKey.UP,
GamepadKey.DOWN,
GamepadKey.LEFT,
GamepadKey.RIGHT,
GamepadKey.A,
GamepadKey.B,
GamepadKey.LB,
GamepadKey.RB,
];
private static readonly GAMEPAD_DIRECTION_MAP = {
[GamepadKey.UP]: NavigationDirection.UP,
[GamepadKey.DOWN]: NavigationDirection.DOWN,
[GamepadKey.LEFT]: NavigationDirection.LEFT,
[GamepadKey.RIGHT]: NavigationDirection.RIGHT,
[GamepadKey.LS_UP]: NavigationDirection.UP,
[GamepadKey.LS_DOWN]: NavigationDirection.DOWN,
[GamepadKey.LS_LEFT]: NavigationDirection.LEFT,
[GamepadKey.LS_RIGHT]: NavigationDirection.RIGHT,
};
private gamepadPollingIntervalId: number | null = null;
private gamepadLastButtons: Array<GamepadKey | null> = [];
private $container: HTMLElement | undefined;
private $tabs: HTMLElement | undefined;
private $settings: HTMLElement | undefined;
private $overlay: HTMLElement | undefined;
readonly SETTINGS_UI = [{
icon: BxIcon.DISPLAY,
group: 'stream',
items: [{
group: 'audio',
label: t('audio'),
help_url: 'https://better-xcloud.github.io/ingame-features/#audio',
items: [{
pref: PrefKey.AUDIO_VOLUME,
onChange: (e: any, value: number) => {
SoundShortcut.setGainNodeVolume(value);
},
params: {
disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL),
},
onMounted: ($elm: HTMLElement) => {
const $range = $elm.querySelector('input[type=range') as HTMLInputElement;
window.addEventListener(BxEvent.GAINNODE_VOLUME_CHANGED, e => {
$range.value = (e as any).volume;
BxEvent.dispatch($range, 'input', {
ignoreOnChange: true,
});
});
},
}],
}, {
group: 'video',
label: t('video'),
help_url: 'https://better-xcloud.github.io/ingame-features/#video',
items: [{
pref: PrefKey.VIDEO_PLAYER_TYPE,
onChange: onChangeVideoPlayerType,
}, {
pref: PrefKey.VIDEO_RATIO,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_PROCESSING,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_POWER_PREFERENCE,
onChange: () => {
const streamPlayer = STATES.currentStream.streamPlayer;
if (!streamPlayer) {
return;
}
streamPlayer.reloadPlayer();
updateVideoPlayer();
},
}, {
pref: PrefKey.VIDEO_SHARPNESS,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_SATURATION,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_CONTRAST,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_BRIGHTNESS,
onChange: updateVideoPlayer,
}],
}],
}, {
icon: BxIcon.CONTROLLER,
group: 'controller',
items: [{
group: 'controller',
label: t('controller'),
help_url: 'https://better-xcloud.github.io/ingame-features/#controller',
items: [{
pref: PrefKey.CONTROLLER_ENABLE_VIBRATION,
unsupported: !VibrationManager.supportControllerVibration(),
onChange: () => VibrationManager.updateGlobalVars(),
}, {
pref: PrefKey.CONTROLLER_DEVICE_VIBRATION,
unsupported: !VibrationManager.supportDeviceVibration(),
onChange: () => VibrationManager.updateGlobalVars(),
}, (VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && {
pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY,
unsupported: !VibrationManager.supportDeviceVibration(),
onChange: () => VibrationManager.updateGlobalVars(),
}],
},
STATES.userAgent.capabilities.touch && {
group: 'touch-controller',
label: t('touch-controller'),
items: [{
label: t('layout'),
content: CE('select', {disabled: true}, CE('option', {}, t('default'))),
onMounted: ($elm: HTMLSelectElement) => {
$elm.addEventListener('change', e => {
TouchController.loadCustomLayout(STATES.currentStream?.xboxTitleId!, $elm.value, 1000);
});
window.addEventListener(BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, e => {
const data = (e as any).data;
if (STATES.currentStream?.xboxTitleId && ($elm as any).xboxTitleId === STATES.currentStream?.xboxTitleId) {
$elm.dispatchEvent(new Event('change'));
return;
}
($elm as any).xboxTitleId = STATES.currentStream?.xboxTitleId;
// Clear options
while ($elm.firstChild) {
$elm.removeChild($elm.firstChild);
}
$elm.disabled = !data;
if (!data) {
$elm.appendChild(CE('option', {value: ''}, t('default')));
$elm.value = '';
$elm.dispatchEvent(new Event('change'));
return;
}
// Add options
const $fragment = document.createDocumentFragment();
for (const key in data.layouts) {
const layout = data.layouts[key];
let name;
if (layout.author) {
name = `${layout.name} (${layout.author})`;
} else {
name = layout.name;
}
const $option = CE('option', {value: key}, name);
$fragment.appendChild($option);
}
$elm.appendChild($fragment);
$elm.value = data.default_layout;
$elm.dispatchEvent(new Event('change'));
});
},
}],
}],
},
getPref(PrefKey.MKB_ENABLED) && {
icon: BxIcon.VIRTUAL_CONTROLLER,
group: 'mkb',
items: [{
group: 'mkb',
label: t('virtual-controller'),
help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/',
content: MkbRemapper.INSTANCE.render(),
}],
},
AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && {
icon: BxIcon.NATIVE_MKB,
group: 'native-mkb',
items: [{
group: 'native-mkb',
label: t('native-mkb'),
items: [{
pref: PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY,
onChange: (e: any, value: number) => {
NativeMkbHandler.getInstance().setVerticalScrollMultiplier(value / 100);
},
}, {
pref: PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY,
onChange: (e: any, value: number) => {
NativeMkbHandler.getInstance().setHorizontalScrollMultiplier(value / 100);
},
}],
}],
}, {
icon: BxIcon.COMMAND,
group: 'shortcuts',
items: [{
group: 'shortcuts_controller',
label: t('controller-shortcuts'),
content: ControllerShortcut.renderSettings(),
}],
}, {
icon: BxIcon.STREAM_STATS,
group: 'stats',
items: [{
group: 'stats',
label: t('stream-stats'),
help_url: 'https://better-xcloud.github.io/stream-stats/',
items: [{
pref: PrefKey.STATS_SHOW_WHEN_PLAYING,
}, {
pref: PrefKey.STATS_QUICK_GLANCE,
onChange: (e: InputEvent) => {
const streamStats = StreamStats.getInstance();
(e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop();
},
}, {
pref: PrefKey.STATS_ITEMS,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_POSITION,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_TEXT_SIZE,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_OPACITY,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_TRANSPARENT,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_CONDITIONAL_FORMATTING,
onChange: StreamStats.refreshStyles,
},
],
}],
},
];
constructor() {
this.#setupDialog();
// Hide dialog when the Guide menu is shown
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, e => this.hide());
}
isShowing() {
return this.$container && !this.$container.classList.contains('bx-gone');
}
show(tabId?: string) {
const $container = this.$container!;
// Select tab
if (tabId) {
const $tab = $container.querySelector(`.bx-stream-settings-tabs svg[data-tab-group=${tabId}]`);
$tab && $tab.dispatchEvent(new Event('click'));
}
// Show overlay
this.$overlay!.classList.remove('bx-gone');
this.$overlay!.dataset.isPlaying = STATES.isPlaying.toString();
// Show dialog
$container.classList.remove('bx-gone');
// Lock scroll bar
document.body.classList.add('bx-no-scroll');
// Focus the first visible setting
this.#focusDirection(NavigationDirection.DOWN);
// Add event listeners
$container.addEventListener('keydown', this);
// Start gamepad polling
this.#startGamepadPolling();
// Disable xCloud's navigation polling
(window as any).BX_EXPOSED.disableGamepadPolling = true;
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_SHOWN);
// Update video's settings
onChangeVideoPlayerType();
}
hide() {
// Hide overlay
this.$overlay!.classList.add('bx-gone');
// Hide dialog
this.$container!.classList.add('bx-gone');
// Show scroll bar
document.body.classList.remove('bx-no-scroll');
// Remove event listeners
this.$container!.removeEventListener('keydown', this);
// Stop gamepad polling();
this.#stopGamepadPolling();
// Enable xCloud's navigation polling
(window as any).BX_EXPOSED.disableGamepadPolling = false;
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_DISMISSED);
}
#focusCurrentTab() {
const $currentTab = this.$tabs!.querySelector('.bx-active') as HTMLElement;
$currentTab && $currentTab.focus();
}
#pollGamepad() {
const gamepads = window.navigator.getGamepads();
let direction: NavigationDirection | null = null;
for (const gamepad of gamepads) {
if (!gamepad || !gamepad.connected) {
continue;
}
// Ignore virtual controller
if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
continue;
}
const axes = gamepad.axes;
const buttons = gamepad.buttons;
let lastButton = this.gamepadLastButtons[gamepad.index];
let pressedButton: GamepadKey | null = null;
let holdingButton: GamepadKey | null = null;
for (const key of StreamSettings.GAMEPAD_KEYS) {
if (typeof lastButton === 'number') {
// Key released
if (lastButton === key && !buttons[key].pressed) {
pressedButton = key;
break;
}
} else if (buttons[key].pressed) {
// Key pressed
holdingButton = key;
break;
}
}
if (holdingButton === null && pressedButton === null && axes && axes.length >= 2) {
// Check sticks
// LEFT left-right, LEFT up-down
if (typeof lastButton === 'number') {
const releasedHorizontal = Math.abs(axes[0]) < 0.1 && (lastButton === GamepadKey.LS_LEFT || lastButton === GamepadKey.LS_RIGHT);
const releasedVertical = Math.abs(axes[1]) < 0.1 && (lastButton === GamepadKey.LS_UP || lastButton === GamepadKey.LS_DOWN);
if (releasedHorizontal || releasedVertical) {
pressedButton = lastButton;
}
} else {
if (axes[0] < -0.5) {
holdingButton = GamepadKey.LS_LEFT;
} else if (axes[0] > 0.5) {
holdingButton = GamepadKey.LS_RIGHT;
} else if (axes[1] < -0.5) {
holdingButton = GamepadKey.LS_UP;
} else if (axes[1] > 0.5) {
holdingButton = GamepadKey.LS_DOWN;
}
}
}
if (holdingButton !== null) {
this.gamepadLastButtons[gamepad.index] = holdingButton;
}
if (pressedButton === null) {
continue;
}
this.gamepadLastButtons[gamepad.index] = null;
if (pressedButton === GamepadKey.A) {
document.activeElement && document.activeElement.dispatchEvent(new MouseEvent('click'));
return;
} else if (pressedButton === GamepadKey.B) {
this.hide();
return;
} else if (pressedButton === GamepadKey.LB || pressedButton === GamepadKey.RB) {
// Focus setting tabs
this.#focusCurrentTab();
return;
}
direction = StreamSettings.GAMEPAD_DIRECTION_MAP[pressedButton as keyof typeof StreamSettings.GAMEPAD_DIRECTION_MAP];
if (direction) {
let handled = false;
if (document.activeElement instanceof HTMLInputElement && document.activeElement.type === 'range') {
const $range = document.activeElement;
if (direction === NavigationDirection.LEFT || direction === NavigationDirection.RIGHT) {
$range.value = (parseInt($range.value) + parseInt($range.step) * (direction === NavigationDirection.LEFT ? -1 : 1)).toString();
$range.dispatchEvent(new InputEvent('input'));
handled = true;
}
}
if (!handled) {
this.#focusDirection(direction);
}
}
return;
}
}
#startGamepadPolling() {
this.#stopGamepadPolling();
this.gamepadPollingIntervalId = window.setInterval(this.#pollGamepad.bind(this), StreamSettings.GAMEPAD_POLLING_INTERVAL);
}
#stopGamepadPolling() {
this.gamepadLastButtons = [];
this.gamepadPollingIntervalId && window.clearInterval(this.gamepadPollingIntervalId);
this.gamepadPollingIntervalId = null;
}
#handleTabsNavigation($focusing: HTMLElement, direction: NavigationDirection) {
if (direction === NavigationDirection.UP || direction === NavigationDirection.DOWN) {
let $sibling = $focusing;
const siblingProperty = direction === NavigationDirection.UP ? 'previousElementSibling' : 'nextElementSibling';
while ($sibling[siblingProperty]) {
$sibling = $sibling[siblingProperty] as HTMLElement;
$sibling && $sibling.focus();
return;
}
// If it's the first/last item -> loop around
const pseudo = direction === NavigationDirection.UP ? 'last-of-type' : 'first-of-type';
const $target = this.$tabs!.querySelector(`svg:not(.bx-gone):${pseudo}`);
$target && ($target as HTMLElement).focus();
} else if (direction === NavigationDirection.RIGHT) {
this.#focusFirstVisibleSetting();
}
}
#handleSettingsNavigation($focusing: HTMLElement, direction: NavigationDirection) {
// If current element's tabIndex property is not 0
if ($focusing.tabIndex !== 0) {
// Find first visible setting
const $childSetting = $focusing.querySelector('div[data-tab-group]:not(.bx-gone) [tabindex="0"]:not(a)') as HTMLElement;
if ($childSetting) {
$childSetting.focus();
return;
}
}
// Current element is setting -> Find the next one
// Find parent
let $parent = $focusing.closest('[data-focus-container]');
if (!$parent) {
return;
}
// Find sibling setting
let $sibling = $parent;
if (direction === NavigationDirection.UP || direction === NavigationDirection.DOWN) {
const siblingProperty = direction === NavigationDirection.UP ? 'previousElementSibling' : 'nextElementSibling';
while ($sibling[siblingProperty]) {
$sibling = $sibling[siblingProperty];
const $childSetting = $sibling.querySelector('[tabindex="0"]:last-of-type') as HTMLElement;
if ($childSetting) {
$childSetting.focus();
// Only stop when it was focused successfully
if (document.activeElement === $childSetting) {
return;
}
}
}
// If it's the first/last item -> loop around
// TODO: bugged if pseudo is "first-of-type" and the first setting is disabled
const pseudo = direction === NavigationDirection.UP ? ':last-of-type' : '';
const $target = this.$settings!.querySelector(`div[data-tab-group]:not(.bx-gone) div[data-focus-container]:not(.bx-gone)${pseudo} [tabindex="0"]:not(:disabled):last-of-type`);
$target && ($target as HTMLElement).focus();
} else if (direction === NavigationDirection.LEFT || direction === NavigationDirection.RIGHT) {
// Find all child elements with tabindex
const children = Array.from($parent.querySelectorAll('[tabindex="0"]'));
const index = children.indexOf($focusing);
let nextIndex;
if (direction === NavigationDirection.LEFT) {
nextIndex = index - 1;
} else {
nextIndex = index + 1;
}
nextIndex = Math.max(-1, Math.min(nextIndex, children.length - 1));
if (nextIndex === -1) {
// Focus setting tabs
const $tab = this.$tabs!.querySelector('svg.bx-active') as HTMLElement;
$tab && $tab.focus();
} else if (nextIndex !== index) {
(children[nextIndex] as HTMLElement).focus();
}
}
}
#focusFirstVisibleSetting() {
// Focus the first visible tab content
const $tab = this.$settings!.querySelector('div[data-tab-group]:not(.bx-gone)') as HTMLElement;
if ($tab) {
// Focus on the first focusable setting
const $control = $tab.querySelector('[tabindex="0"]:not(a)') as HTMLElement;
if ($control) {
$control.focus();
} else {
// Focus tab
$tab.focus();
}
}
}
#focusDirection(direction: NavigationDirection) {
const $tabs = this.$tabs!;
const $settings = this.$settings!;
// Get current focused element
let $focusing = document.activeElement as HTMLElement;
let focusContainer = FocusContainer.OUTSIDE;
if ($focusing) {
if ($settings.contains($focusing)) {
focusContainer = FocusContainer.SETTINGS;
} else if ($tabs.contains($focusing)) {
focusContainer = FocusContainer.TABS;
}
}
// If not focusing any element or the focused element is not inside the dialog
if (focusContainer === FocusContainer.OUTSIDE) {
this.#focusFirstVisibleSetting();
return;
} else if (focusContainer === FocusContainer.SETTINGS) {
this.#handleSettingsNavigation($focusing, direction);
} else if (focusContainer === FocusContainer.TABS) {
this.#handleTabsNavigation($focusing, direction);
}
}
handleEvent(event: Event) {
switch (event.type) {
case 'keydown':
const $target = event.target as HTMLElement;
const keyboardEvent = event as KeyboardEvent;
const keyCode = keyboardEvent.code || keyboardEvent.key;
let handled = false;
if (keyCode === 'ArrowUp' || keyCode === 'ArrowDown') {
handled = true;
this.#focusDirection(keyCode === 'ArrowUp' ? NavigationDirection.UP : NavigationDirection.DOWN);
} else if (keyCode === 'ArrowLeft' || keyCode === 'ArrowRight') {
if (($target as any).type !== 'range') {
handled = true;
this.#focusDirection(keyCode === 'ArrowLeft' ? NavigationDirection.LEFT : NavigationDirection.RIGHT);
}
} else if (keyCode === 'Enter' || keyCode === 'Space') {
if ($target instanceof SVGElement) {
handled = true;
$target.dispatchEvent(new Event('click'));
}
} else if (keyCode === 'Tab') {
handled = true;
this.#focusCurrentTab();
} else if (keyCode === 'Escape') {
handled = true;
this.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
break;
}
}
#setupDialog() {
let $tabs: HTMLElement;
let $settings: HTMLElement;
const $overlay = CE('div', {class: 'bx-stream-settings-overlay bx-gone'});
this.$overlay = $overlay;
const $container = CE('div', {class: StreamSettings.MAIN_CLASS + ' bx-gone'},
$tabs = CE('div', {class: 'bx-stream-settings-tabs'}),
$settings = CE('div', {
class: 'bx-stream-settings-tab-contents',
tabindex: 10,
}),
);
this.$container = $container;
this.$tabs = $tabs;
this.$settings = $settings;
// Close dialog when clicking on the overlay
$overlay.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.hide();
});
// Close dialog when not clicking on any child elements in the dialog
$container.addEventListener('click', e => {
if (e.target === $container) {
e.preventDefault();
e.stopPropagation();
this.hide();
}
});
for (const settingTab of this.SETTINGS_UI) {
if (!settingTab) {
continue;
}
const $svg = createSvgIcon(settingTab.icon);
$svg.tabIndex = 0;
$svg.addEventListener('click', e => {
// Switch tab
for (const $child of Array.from($settings.children)) {
if ($child.getAttribute('data-tab-group') === settingTab.group) {
$child.classList.remove('bx-gone');
} else {
$child.classList.add('bx-gone');
}
}
// Highlight current tab button
for (const $child of Array.from($tabs.children)) {
$child.classList.remove('bx-active');
}
$svg.classList.add('bx-active');
});
$tabs.appendChild($svg);
const $group = CE('div', {'data-tab-group': settingTab.group, 'class': 'bx-gone'});
for (const settingGroup of settingTab.items) {
if (!settingGroup) {
continue;
}
$group.appendChild(CE('h2', {'data-focus-container': 'true'},
CE('span', {}, settingGroup.label),
settingGroup.help_url && createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
url: settingGroup.help_url,
title: t('help'),
tabIndex: 0,
}),
));
if (settingGroup.note) {
if (typeof settingGroup.note === 'string') {
settingGroup.note = document.createTextNode(settingGroup.note);
}
$group.appendChild(settingGroup.note);
}
if (settingGroup.content) {
$group.appendChild(settingGroup.content);
continue;
}
if (!settingGroup.items) {
settingGroup.items = [];
}
for (const setting of settingGroup.items) {
if (!setting) {
continue;
}
const pref = setting.pref;
let $control;
if (setting.content) {
$control = setting.content;
} else if (!setting.unsupported) {
$control = toPrefElement(pref, setting.onChange, setting.params);
// Replace <select> with controller-friendly one
if ($control instanceof HTMLSelectElement && getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
$control = BxSelectElement.wrap($control);
}
}
const label = Preferences.SETTINGS[pref as PrefKey]?.label || setting.label;
const note = Preferences.SETTINGS[pref as PrefKey]?.note || setting.note;
const $content = CE('div', {
class: 'bx-stream-settings-row',
'data-type': settingGroup.group,
'data-focus-container': 'true',
},
CE('label', {for: `bx_setting_${pref}`},
label,
note && CE('div', {'class': 'bx-stream-settings-dialog-note'}, note),
setting.unsupported && CE('div', {'class': 'bx-stream-settings-dialog-note'}, t('browser-unsupported-feature')),
),
!setting.unsupported && $control,
);
$group.appendChild($content);
setting.onMounted && setting.onMounted($control);
}
}
$settings.appendChild($group);
}
// Select first tab
$tabs.firstElementChild!.dispatchEvent(new Event('click'));
document.documentElement.appendChild($overlay);
document.documentElement.appendChild($container);
}
}

View File

@@ -1,8 +1,9 @@
import { PrefKey, getPref } from "@utils/preferences"
import { BxEvent } from "@utils/bx-event"
import { CE } from "@utils/html"
import { t } from "@utils/translation"
import { STATES } from "@utils/global"
import { PrefKey } from "@/enums/pref-keys"
import { getPref } from "@/utils/settings-storages/global-settings-storage"
export enum StreamStat {
PING = 'ping',

View File

@@ -5,7 +5,7 @@ import { BxEvent } from "@utils/bx-event.ts";
import { t } from "@utils/translation.ts";
import { StreamBadges } from "./stream-badges.ts";
import { StreamStats } from "./stream-stats.ts";
import { StreamSettings } from "./stream-settings.ts";
import { SettingsNavigationDialog } from "../ui/dialog/settings-dialog.ts";
function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) {
@@ -123,11 +123,6 @@ export function injectStreamMenuButtons() {
return;
}
// Hide Stream Settings dialog when closing HUD
$btnCloseHud.addEventListener('click', e => {
StreamSettings.getInstance().hide();
});
// Create Refresh button from the Close button
const $btnRefresh = cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => {
confirm(t('confirm-reload-stream')) && window.location.reload();
@@ -178,13 +173,13 @@ export function injectStreamMenuButtons() {
// Create Stream Settings button
if (!$btnStreamSettings) {
$btnStreamSettings = cloneStreamHudButton($orgButton, t('stream-settings'), BxIcon.STREAM_SETTINGS);
$btnStreamSettings = cloneStreamHudButton($orgButton, t('better-xcloud'), BxIcon.BETTER_XCLOUD);
$btnStreamSettings.addEventListener('click', e => {
hideGripHandle();
e.preventDefault();
// Show Stream Settings dialog
StreamSettings.getInstance().show();
SettingsNavigationDialog.getInstance().show();
});
}

View File

@@ -1,14 +1,27 @@
import { STATES } from "@utils/global";
import { escapeHtml } from "@utils/html";
import { Toast } from "@utils/toast";
import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS, NATIVE_FETCH } from "@utils/bx-flags";
import { getPref, PrefKey } from "@utils/preferences";
import { NATIVE_FETCH } from "@utils/bx-flags";
import { t } from "@utils/translation";
import { BxLogger } from "@utils/bx-logger";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'TouchController';
type TouchControlLayout = {
name: string,
author: string,
content: any,
};
type TouchControlDefinition = {
name: string,
product_id: string,
default_layout: string,
layouts: Record<string, TouchControlLayout>,
};
export class TouchController {
static readonly #EVENT_SHOW_DEFAULT_CONTROLLER = new MessageEvent('message', {
data: JSON.stringify({
@@ -28,25 +41,40 @@ export class TouchController {
static #$style: HTMLStyleElement;
static #enable = false;
static #enabled = false;
static #dataChannel: RTCDataChannel | null;
static #customLayouts: {[index: string]: any} = {};
static #baseCustomLayouts: {[index: string]: any} = {};
static #customLayouts: Record<string, TouchControlDefinition | null> = {};
static #baseCustomLayouts: Record<string, Record<string, TouchControlLayout>> = {};
static #currentLayoutId: string;
static #customList: string[];
static #xboxTitleId: string | null = null;
static setXboxTitleId(xboxTitleId: string) {
TouchController.#xboxTitleId = xboxTitleId;
}
static getCustomLayouts() {
const xboxTitleId = TouchController.#xboxTitleId;
if (!xboxTitleId) {
return null;
}
return TouchController.#customLayouts[xboxTitleId];
}
static enable() {
TouchController.#enable = true;
TouchController.#enabled = true;
}
static disable() {
TouchController.#enable = false;
TouchController.#enabled = false;
}
static isEnabled() {
return TouchController.#enable;
return TouchController.#enabled;
}
static #showDefault() {
@@ -70,8 +98,9 @@ export class TouchController {
}
static reset() {
TouchController.#enable = false;
TouchController.#enabled = false;
TouchController.#dataChannel = null;
TouchController.#xboxTitleId = null;
TouchController.#$style && (TouchController.#$style.textContent = '');
}
@@ -83,12 +112,18 @@ export class TouchController {
}
static #dispatchLayouts(data: any) {
BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, {
data: data,
});
// Load default layout
TouchController.applyCustomLayout(null, 1000);
BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED);
};
static async getCustomLayouts(xboxTitleId: string, retries: number=1) {
static async requestCustomLayouts(retries: number=1) {
const xboxTitleId = TouchController.#xboxTitleId;
if (!xboxTitleId) {
return;
}
if (xboxTitleId in TouchController.#customLayouts) {
TouchController.#dispatchLayouts(TouchController.#customLayouts[xboxTitleId]);
return;
@@ -102,7 +137,7 @@ export class TouchController {
return;
}
const baseUrl = `https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts${BX_FLAGS.UseDevTouchLayout ? '/dev' : ''}`;
const baseUrl = 'https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts';
const url = `${baseUrl}/${xboxTitleId}.json`;
// Get layout info
@@ -137,17 +172,17 @@ export class TouchController {
window.setTimeout(() => TouchController.#dispatchLayouts(json), 1000);
} catch (e) {
// Retry
TouchController.getCustomLayouts(xboxTitleId, retries + 1);
TouchController.requestCustomLayouts(retries + 1);
}
}
static loadCustomLayout(xboxTitleId: string, layoutId: string, delay: number=0) {
static applyCustomLayout(layoutId: string | null, delay: number=0) {
// TODO: fix this
if (!window.BX_EXPOSED.touchLayoutManager) {
const listener = (e: Event) => {
window.removeEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener);
if (TouchController.#enable) {
TouchController.loadCustomLayout(xboxTitleId, layoutId, 0);
if (TouchController.#enabled) {
TouchController.applyCustomLayout(layoutId, 0);
}
};
window.addEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener);
@@ -155,13 +190,29 @@ export class TouchController {
return;
}
const xboxTitleId = TouchController.#xboxTitleId;
if (!xboxTitleId) {
BxLogger.error(LOG_TAG, 'Invalid xboxTitleId');
return;
}
if (!layoutId) {
// Get default layout ID from definition
layoutId = TouchController.#customLayouts[xboxTitleId]?.default_layout || null;
}
if (!layoutId) {
BxLogger.error(LOG_TAG, 'Invalid layoutId');
return;
}
const layoutChanged = TouchController.#currentLayoutId !== layoutId;
TouchController.#currentLayoutId = layoutId;
// Get layout data
const layoutData = TouchController.#customLayouts[xboxTitleId];
if (!xboxTitleId || !layoutId || !layoutData) {
TouchController.#enable && TouchController.#showDefault();
TouchController.#enabled && TouchController.#showDefault();
return;
}
@@ -223,7 +274,7 @@ export class TouchController {
touchLayoutManager && touchLayoutManager.changeLayoutForScope({
type: 'showLayout',
scope: '' + STATES.currentStream?.xboxTitleId,
scope: '' + TouchController.#xboxTitleId,
subscope: 'base',
layout: {
id: 'System.Standard',
@@ -249,7 +300,7 @@ export class TouchController {
// Apply touch controller's style
let filter = '';
if (TouchController.#enable) {
if (TouchController.#enabled) {
if (PREF_STYLE_STANDARD === 'white') {
filter = 'grayscale(1) brightness(2)';
} else if (PREF_STYLE_STANDARD === 'muted') {
@@ -280,9 +331,9 @@ export class TouchController {
// Dispatch a message to display generic touch controller
if (msg.data.includes('touchcontrols/showtitledefault')) {
if (TouchController.#enable) {
if (TouchController.#enabled) {
if (focused) {
TouchController.getCustomLayouts(STATES.currentStream?.xboxTitleId!);
TouchController.requestCustomLayouts();
} else {
TouchController.#showDefault();
}
@@ -300,7 +351,7 @@ export class TouchController {
TouchController.#show();
}
STATES.currentStream.xboxTitleId = parseInt(json.titleid, 16).toString();
TouchController.setXboxTitleId(parseInt(json.titleid, 16).toString());
}
} catch (e) {
BxLogger.error(LOG_TAG, 'Load custom layout', e);

View File

@@ -0,0 +1,608 @@
import { GamepadKey } from "@/enums/mkb";
import { EmulatedMkbHandler } from "@/modules/mkb/mkb-handler";
import { BxEvent } from "@/utils/bx-event";
import { STATES } from "@/utils/global";
import { CE } from "@/utils/html";
import { setNearby } from "@/utils/navigation-utils";
export enum NavigationDirection {
UP = 1,
RIGHT,
DOWN,
LEFT,
}
export type NavigationNearbyElements = Partial<{
orientation: 'horizontal' | 'vertical',
selfOrientation: 'horizontal' | 'vertical',
focus: NavigationElement | (() => boolean),
loop: ((direction: NavigationDirection) => boolean),
[NavigationDirection.UP]: NavigationElement | (() => void) | 'previous' | 'next',
[NavigationDirection.DOWN]: NavigationElement | (() => void) | 'previous' | 'next',
[NavigationDirection.LEFT]: NavigationElement | (() => void) | 'previous' | 'next',
[NavigationDirection.RIGHT]: NavigationElement | (() => void) | 'previous' | 'next',
}>;
export interface NavigationElement extends HTMLElement {
nearby?: NavigationNearbyElements;
}
export abstract class NavigationDialog {
abstract getDialog(): NavigationDialog;
abstract getContent(): HTMLElement;
abstract focusIfNeeded(): void;
abstract $container: HTMLElement;
dialogManager: NavigationDialogManager;
constructor() {
this.dialogManager = NavigationDialogManager.getInstance();
}
show() {
NavigationDialogManager.getInstance().show(this);
const $currentFocus = this.getFocusedElement();
// If not focusing on any element
if (!$currentFocus) {
this.focusIfNeeded();
}
}
hide() {
NavigationDialogManager.getInstance().hide();
}
getFocusedElement() {
const $activeElement = document.activeElement as HTMLElement;
if (!$activeElement) {
return null;
}
// Check if focused element is a child of dialog
if (this.$container.contains($activeElement)) {
return $activeElement;
}
return null;
}
onBeforeMount(): void {}
onMounted(): void {}
onBeforeUnmount(): void {}
onUnmounted(): void {}
handleKeyPress(key: string): boolean {
return false;
}
handleGamepad(button: GamepadKey): boolean {
return true;
}
}
export class NavigationDialogManager {
private static instance: NavigationDialogManager;
public static getInstance(): NavigationDialogManager {
if (!NavigationDialogManager.instance) {
NavigationDialogManager.instance = new NavigationDialogManager();
}
return NavigationDialogManager.instance;
}
private static readonly GAMEPAD_POLLING_INTERVAL = 50;
private static readonly GAMEPAD_KEYS = [
GamepadKey.UP,
GamepadKey.DOWN,
GamepadKey.LEFT,
GamepadKey.RIGHT,
GamepadKey.A,
GamepadKey.B,
GamepadKey.LB,
GamepadKey.RB,
GamepadKey.LT,
GamepadKey.RT,
];
private static readonly GAMEPAD_DIRECTION_MAP = {
[GamepadKey.UP]: NavigationDirection.UP,
[GamepadKey.DOWN]: NavigationDirection.DOWN,
[GamepadKey.LEFT]: NavigationDirection.LEFT,
[GamepadKey.RIGHT]: NavigationDirection.RIGHT,
[GamepadKey.LS_UP]: NavigationDirection.UP,
[GamepadKey.LS_DOWN]: NavigationDirection.DOWN,
[GamepadKey.LS_LEFT]: NavigationDirection.LEFT,
[GamepadKey.LS_RIGHT]: NavigationDirection.RIGHT,
};
private static readonly SIBLING_PROPERTY_MAP = {
'horizontal': {
[NavigationDirection.LEFT]: 'previousElementSibling',
[NavigationDirection.RIGHT]: 'nextElementSibling',
},
'vertical': {
[NavigationDirection.UP]: 'previousElementSibling',
[NavigationDirection.DOWN]: 'nextElementSibling',
},
};
private gamepadPollingIntervalId: number | null = null;
private gamepadLastStates: Array<[number, GamepadKey, boolean] | null> = [];
private gamepadHoldingIntervalId: number | null = null;
private $overlay: HTMLElement;
private $container: HTMLElement;
private dialog: NavigationDialog | null = null;
constructor() {
this.$overlay = CE('div', {class: 'bx-navigation-dialog-overlay bx-gone'});
this.$overlay.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.hide();
});
document.documentElement.appendChild(this.$overlay);
this.$container = CE('div', {class: 'bx-navigation-dialog bx-gone'});
document.documentElement.appendChild(this.$container);
// Hide dialog when the Guide menu is shown
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, e => this.hide());
}
handleEvent(event: Event) {
switch (event.type) {
case 'keydown':
const $target = event.target as HTMLElement;
const keyboardEvent = event as KeyboardEvent;
const keyCode = keyboardEvent.code || keyboardEvent.key;
let handled = this.dialog?.handleKeyPress(keyCode);
if (handled) {
event.preventDefault();
event.stopPropagation();
return;
}
if (keyCode === 'ArrowUp' || keyCode === 'ArrowDown') {
handled = true;
this.focusDirection(keyCode === 'ArrowUp' ? NavigationDirection.UP : NavigationDirection.DOWN);
} else if (keyCode === 'ArrowLeft' || keyCode === 'ArrowRight') {
if (!($target instanceof HTMLInputElement && ($target.type === 'text' || $target.type === 'range'))) {
handled = true;
this.focusDirection(keyCode === 'ArrowLeft' ? NavigationDirection.LEFT : NavigationDirection.RIGHT);
}
} else if (keyCode === 'Enter' || keyCode === 'NumpadEnter' || keyCode === 'Space') {
if (!($target instanceof HTMLInputElement && $target.type === 'text')) {
handled = true;
$target.dispatchEvent(new MouseEvent('click'));
}
} else if (keyCode === 'Escape') {
handled = true;
this.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
break;
}
}
isShowing() {
return this.$container && !this.$container.classList.contains('bx-gone');
}
private pollGamepad() {
const gamepads = window.navigator.getGamepads();
for (const gamepad of gamepads) {
if (!gamepad || !gamepad.connected) {
continue;
}
// Ignore virtual controller
if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
continue;
}
const axes = gamepad.axes;
const buttons = gamepad.buttons;
let releasedButton: GamepadKey | null = null;
let heldButton: GamepadKey | null = null;
let lastState = this.gamepadLastStates[gamepad.index];
let lastTimestamp;
let lastKey;
let lastKeyPressed;
if (lastState) {
[lastTimestamp, lastKey, lastKeyPressed] = lastState;
}
if (lastTimestamp && lastTimestamp === gamepad.timestamp) {
continue;
}
for (const key of NavigationDialogManager.GAMEPAD_KEYS) {
// Key released
if (lastKey === key && !buttons[key].pressed) {
releasedButton = key;
break;
} else if (buttons[key].pressed) {
// Key pressed
heldButton = key;
break;
}
}
// If not pressing any key => check analog sticks
if (heldButton === null && releasedButton === null && axes && axes.length >= 2) {
// [LEFT left-right, LEFT up-down]
if (lastKey) {
const releasedHorizontal = Math.abs(axes[0]) < 0.1 && (lastKey === GamepadKey.LS_LEFT || lastKey === GamepadKey.LS_RIGHT);
const releasedVertical = Math.abs(axes[1]) < 0.1 && (lastKey === GamepadKey.LS_UP || lastKey === GamepadKey.LS_DOWN);
if (releasedHorizontal || releasedVertical) {
releasedButton = lastKey;
} else {
heldButton = lastKey;
}
} else {
if (axes[0] < -0.5) {
heldButton = GamepadKey.LS_LEFT;
} else if (axes[0] > 0.5) {
heldButton = GamepadKey.LS_RIGHT;
} else if (axes[1] < -0.5) {
heldButton = GamepadKey.LS_UP;
} else if (axes[1] > 0.5) {
heldButton = GamepadKey.LS_DOWN;
}
}
}
// Save state if holding a button
if (heldButton !== null) {
this.gamepadLastStates[gamepad.index] = [gamepad.timestamp, heldButton, false];
this.clearGamepadHoldingInterval();
// Only set turbo for d-pad and stick
if (NavigationDialogManager.GAMEPAD_DIRECTION_MAP[heldButton as keyof typeof NavigationDialogManager.GAMEPAD_DIRECTION_MAP]) {
this.gamepadHoldingIntervalId = window.setInterval(() => {
const lastState = this.gamepadLastStates[gamepad.index];
// Avoid pressing the incorrect key
if (lastState) {
[lastTimestamp, lastKey, lastKeyPressed] = lastState;
if (lastKey === heldButton) {
this.handleGamepad(gamepad, heldButton);
return;
}
}
this.clearGamepadHoldingInterval();
}, 200);
}
continue;
}
// Continue if the button hasn't been released
if (releasedButton === null) {
this.clearGamepadHoldingInterval();
continue;
}
// Button released
this.gamepadLastStates[gamepad.index] = null;
if (lastKeyPressed) {
return;
}
if (releasedButton === GamepadKey.A) {
document.activeElement && document.activeElement.dispatchEvent(new MouseEvent('click'));
return;
} else if (releasedButton === GamepadKey.B) {
this.hide();
return;
}
if (this.handleGamepad(gamepad, releasedButton)) {
return;
}
}
}
private handleGamepad(gamepad: Gamepad, key: GamepadKey): boolean {
let handled = this.dialog?.handleGamepad(key);
if (handled) {
return true;
}
// Handle d-pad & sticks
let direction = NavigationDialogManager.GAMEPAD_DIRECTION_MAP[key as keyof typeof NavigationDialogManager.GAMEPAD_DIRECTION_MAP];
if (!direction) {
return false;
}
if (document.activeElement instanceof HTMLInputElement && document.activeElement.type === 'range') {
const $range = document.activeElement;
if (direction === NavigationDirection.LEFT || direction === NavigationDirection.RIGHT) {
$range.value = (parseInt($range.value) + parseInt($range.step) * (direction === NavigationDirection.LEFT ? -1 : 1)).toString();
$range.dispatchEvent(new InputEvent('input'));
handled = true;
}
}
if (!handled) {
this.focusDirection(direction);
}
this.gamepadLastStates[gamepad.index] && (this.gamepadLastStates[gamepad.index]![2] = true);
return true;
}
private clearGamepadHoldingInterval() {
this.gamepadHoldingIntervalId && window.clearInterval(this.gamepadHoldingIntervalId);
this.gamepadHoldingIntervalId = null;
}
show(dialog: NavigationDialog) {
this.clearGamepadHoldingInterval();
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_SHOWN);
// Stop xCloud's navigation polling
(window as any).BX_EXPOSED.disableGamepadPolling = true;
// Lock scroll bar
document.body.classList.add('bx-no-scroll');
// Show overlay
this.$overlay.classList.remove('bx-gone');
if (STATES.isPlaying) {
this.$overlay.classList.add('bx-invisible');
}
// Unmount current dialog
this.unmountCurrentDialog();
// Setup new dialog
this.dialog = dialog;
dialog.onBeforeMount();
this.$container.appendChild(dialog.getContent());
dialog.onMounted();
// Show content
this.$container.classList.remove('bx-gone');
// Add event listeners
this.$container.addEventListener('keydown', this);
// Start gamepad polling
this.startGamepadPolling();
}
hide() {
this.clearGamepadHoldingInterval();
// Unlock scroll bar
document.body.classList.remove('bx-no-scroll');
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_DISMISSED);
// Hide content
this.$overlay.classList.add('bx-gone');
this.$overlay.classList.remove('bx-invisible');
this.$container.classList.add('bx-gone');
// Remove event listeners
this.$container.removeEventListener('keydown', this);
// Stop gamepad polling
this.stopGamepadPolling();
// Unmount dialog
this.unmountCurrentDialog();
// Enable xCloud's navigation polling
(window as any).BX_EXPOSED.disableGamepadPolling = false;
}
focus($elm: NavigationElement | null): boolean {
if (!$elm) {
return false;
}
// console.log('focus', $elm);
if ($elm.nearby && $elm.nearby.focus) {
if ($elm.nearby.focus instanceof HTMLElement) {
return this.focus($elm.nearby.focus);
} else {
return $elm.nearby.focus();
}
}
$elm.focus();
return $elm === document.activeElement;
}
private getOrientation($elm: NavigationElement): NavigationNearbyElements['orientation'] {
const nearby = $elm.nearby || {};
if (nearby.selfOrientation) {
return nearby.selfOrientation;
}
let orientation;
let $current = $elm.parentElement! as NavigationElement;
while ($current !== this.$container) {
const tmp = $current.nearby?.orientation;
if ($current.nearby && tmp) {
orientation = tmp;
break;
}
$current = $current.parentElement!;
}
orientation = orientation || 'vertical';
setNearby($elm, {
selfOrientation: orientation,
});
return orientation;
}
findNextTarget($focusing: HTMLElement | null, direction: NavigationDirection, checkParent = false, checked: Array<HTMLElement> = []): HTMLElement | null {
if (!$focusing || $focusing === this.$container) {
return null;
}
if (checked.includes($focusing)) {
return null;
}
checked.push($focusing);
let $target: HTMLElement = $focusing;
const $parent = $target.parentElement;
const nearby = ($target as NavigationElement).nearby || {};
const orientation = this.getOrientation($target)!;
// @ts-ignore
let siblingProperty = (NavigationDialogManager.SIBLING_PROPERTY_MAP[orientation])[direction];
if (siblingProperty) {
let $sibling = $target as any;
while ($sibling[siblingProperty]) {
$sibling = $sibling[siblingProperty] as HTMLElement;
const $focusable = this.findFocusableElement($sibling, direction);
if ($focusable) {
return $focusable;
}
}
}
if (nearby.loop) {
// Loop
if (nearby.loop(direction)) {
return null;
}
}
if (checkParent) {
return this.findNextTarget($parent, direction, checkParent, checked);
}
return null;
}
findFocusableElement($elm: HTMLElement | null, direction?: NavigationDirection): HTMLElement | null {
if (!$elm) {
return null;
}
// Ignore disabled element
const isDisabled = !!($elm as any).disabled;
if (isDisabled) {
return null;
}
const rect = $elm.getBoundingClientRect();
const isVisible = !!rect.width && !!rect.height;
// Ignore hidden element
if (!isVisible) {
return null;
}
// Accept element with tabIndex
if ($elm.tabIndex > -1) {
return $elm;
}
const focus = ($elm as NavigationElement).nearby?.focus;
if (focus) {
if (focus instanceof HTMLElement) {
return this.findFocusableElement(focus, direction);
} else if (typeof focus === 'function') {
if (focus()) {
return document.activeElement as HTMLElement;
}
}
}
// Look for child focusable elemnet
const children = Array.from($elm.children);
// Search from right to left if the orientation is horizontal
const orientation = ($elm as NavigationElement).nearby?.orientation;
if (orientation === 'horizontal' || (orientation === 'vertical' && direction === NavigationDirection.UP)) {
children.reverse();
}
for (const $child of children) {
if (!$child || !($child instanceof HTMLElement)) {
return null;
}
const $target = this.findFocusableElement($child, direction);
if ($target) {
return $target;
}
}
return null;
}
private startGamepadPolling() {
this.stopGamepadPolling();
this.gamepadPollingIntervalId = window.setInterval(this.pollGamepad.bind(this), NavigationDialogManager.GAMEPAD_POLLING_INTERVAL);
}
private stopGamepadPolling() {
this.gamepadLastStates = [];
this.gamepadPollingIntervalId && window.clearInterval(this.gamepadPollingIntervalId);
this.gamepadPollingIntervalId = null;
}
private focusDirection(direction: NavigationDirection) {
const dialog = this.dialog;
if (!dialog) {
return;
}
// Get current focused element
const $focusing = dialog.getFocusedElement();
if (!$focusing || !this.findFocusableElement($focusing, direction)) {
dialog.focusIfNeeded();
return null;
}
const $target = this.findNextTarget($focusing, direction, true);
this.focus($target);
}
private unmountCurrentDialog() {
const dialog = this.dialog;
dialog && dialog.onBeforeUnmount();
this.$container.firstChild?.remove();
dialog && dialog.onUnmounted();
this.dialog = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,507 +0,0 @@
import { STATES, AppInterface, SCRIPT_VERSION, deepClone } from "@utils/global";
import { CE, createButton, ButtonStyle } from "@utils/html";
import { BxIcon } from "@utils/bx-icon";
import { getPreferredServerRegion } from "@utils/region";
import { UserAgent } from "@utils/user-agent";
import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences";
import { t, Translations } from "@utils/translation";
import { PatcherCache } from "../patcher";
import { UserAgentProfile } from "@enums/user-agent";
import { BxSelectElement } from "@/web-components/bx-select";
import { StreamSettings } from "../stream/stream-settings";
import { BX_FLAGS } from "@/utils/bx-flags";
import { Toast } from "@/utils/toast";
const SETTINGS_UI = {
'Better xCloud': {
items: [
PrefKey.BETTER_XCLOUD_LOCALE,
PrefKey.SERVER_BYPASS_RESTRICTION,
PrefKey.UI_CONTROLLER_FRIENDLY,
PrefKey.REMOTE_PLAY_ENABLED,
],
},
[t('server')]: {
items: [
PrefKey.SERVER_REGION,
PrefKey.STREAM_PREFERRED_LOCALE,
PrefKey.PREFER_IPV6_SERVER,
],
},
[t('stream')]: {
items: [
PrefKey.STREAM_TARGET_RESOLUTION,
PrefKey.STREAM_CODEC_PROFILE,
PrefKey.BITRATE_VIDEO_MAX,
PrefKey.AUDIO_ENABLE_VOLUME_CONTROL,
PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG,
PrefKey.SCREENSHOT_APPLY_FILTERS,
PrefKey.AUDIO_MIC_ON_PLAYING,
PrefKey.GAME_FORTNITE_FORCE_CONSOLE,
PrefKey.STREAM_COMBINE_SOURCES,
],
},
[t('game-bar')]: {
items: [
PrefKey.GAME_BAR_POSITION,
],
},
[t('local-co-op')]: {
items: [
PrefKey.LOCAL_CO_OP_ENABLED,
],
},
[t('mouse-and-keyboard')]: {
items: [
PrefKey.NATIVE_MKB_ENABLED,
PrefKey.MKB_ENABLED,
PrefKey.MKB_HIDE_IDLE_CURSOR,
],
},
[t('touch-controller')]: {
note: !STATES.userAgent.capabilities.touch ? '⚠️ ' + t('device-unsupported-touch') : null,
unsupported: !STATES.userAgent.capabilities.touch,
items: [
PrefKey.STREAM_TOUCH_CONTROLLER,
PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF,
PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY,
PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD,
PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM,
],
},
[t('loading-screen')]: {
items: [
PrefKey.UI_LOADING_SCREEN_GAME_ART,
PrefKey.UI_LOADING_SCREEN_WAIT_TIME,
PrefKey.UI_LOADING_SCREEN_ROCKET,
],
},
[t('ui')]: {
items: [
PrefKey.UI_LAYOUT,
PrefKey.UI_HOME_CONTEXT_MENU_DISABLED,
PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME,
PrefKey.CONTROLLER_SHOW_CONNECTION_STATUS,
PrefKey.STREAM_SIMPLIFY_MENU,
PrefKey.SKIP_SPLASH_VIDEO,
!AppInterface && PrefKey.UI_SCROLLBAR_HIDE,
PrefKey.HIDE_DOTS_ICON,
PrefKey.REDUCE_ANIMATIONS,
PrefKey.BLOCK_SOCIAL_FEATURES,
PrefKey.UI_HIDE_SECTIONS,
],
},
[t('other')]: {
items: [
PrefKey.BLOCK_TRACKING,
],
},
[t('advanced')]: {
items: [
PrefKey.USER_AGENT_PROFILE,
],
},
};
export function setupSettingsUi() {
// Avoid rendering the Settings multiple times
if (document.querySelector('.bx-settings-container')) {
return;
}
const PREF_PREFERRED_REGION = getPreferredServerRegion();
const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION);
let $btnReload: HTMLButtonElement;
// Setup Settings UI
const $container = CE('div', {
'class': 'bx-settings-container bx-gone',
});
const $wrapper = CE('div', {'class': 'bx-settings-wrapper'},
CE('div', {'class': 'bx-settings-title-wrapper'},
createButton({
classes: ['bx-settings-title'],
style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST,
label: 'Better xCloud ' + SCRIPT_VERSION,
url: 'https://github.com/redphx/better-xcloud/releases',
}),
createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.FOCUSABLE,
label: t('help'),
url: 'https://better-xcloud.github.io/features/',
}),
)
);
const topButtons = [];
// "New version available" button
if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) {
// Show new version indicator
topButtons.push(createButton({
label: `🌟 Version ${PREF_LATEST_VERSION} available`,
style: ButtonStyle.PRIMARY | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH,
url: 'https://github.com/redphx/better-xcloud/releases/latest',
}));
}
// "Stream settings" button
(STATES.supportedRegion && STATES.isSignedIn) && topButtons.push(createButton({
label: t('stream-settings'),
icon: BxIcon.STREAM_SETTINGS,
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
StreamSettings.getInstance().show();
},
}));
// Buttons for Android app
if (AppInterface) {
// Show Android app settings button
topButtons.push(createButton({
label: t('app-settings'),
icon: BxIcon.STREAM_SETTINGS,
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
AppInterface.openAppSettings && AppInterface.openAppSettings();
},
}));
} else {
// Show link to Android app
const userAgent = UserAgent.getDefault().toLowerCase();
if (userAgent.includes('android')) {
topButtons.push(createButton({
label: '🔥 ' + t('install-android'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
url: 'https://better-xcloud.github.io/android',
}));
}
}
if (topButtons.length) {
const $div = CE('div', {class: 'bx-top-buttons'});
for (const $button of topButtons) {
$div.appendChild($button);
}
$wrapper.appendChild($div);
}
let localeSwitchingTimeout: number | null;
const onChange = async (e: Event) => {
// Clear PatcherCache;
PatcherCache.clear();
$btnReload.classList.add('bx-danger');
// Highlight the Settings button in the Header to remind user to reload the page
const $btnHeaderSettings = document.querySelector('.bx-header-settings-button');
$btnHeaderSettings && $btnHeaderSettings.classList.add('bx-danger');
if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) {
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
localeSwitchingTimeout && window.clearTimeout(localeSwitchingTimeout);
localeSwitchingTimeout = window.setTimeout(() => {
Translations.refreshCurrentLocale();
Translations.updateTranslations();
}, 1000);
} else {
// Update locale
Translations.refreshCurrentLocale();
await Translations.updateTranslations();
$btnReload.textContent = t('settings-reloading');
$btnReload.click();
}
}
};
// Render settings
for (let groupLabel in SETTINGS_UI) {
// Don't render other settings when not signed in
if (groupLabel !== 'Better xCloud' && (!STATES.supportedRegion || !STATES.isSignedIn)) {
continue;
}
const $group = CE('span', {'class': 'bx-settings-group-label'}, groupLabel);
// Render note
if (SETTINGS_UI[groupLabel].note) {
const $note = CE('b', {}, SETTINGS_UI[groupLabel].note);
$group.appendChild($note);
}
$wrapper.appendChild($group);
// Don't render settings if this is an unsupported feature
if (SETTINGS_UI[groupLabel].unsupported) {
continue;
}
const settingItems = SETTINGS_UI[groupLabel].items;
for (let settingId of settingItems) {
// Don't render custom settings
if (!settingId) {
continue;
}
const setting = Preferences.SETTINGS[settingId];
if (!setting) {
continue;
}
let settingLabel = setting.label;
let settingNote = setting.note || '';
// Add Experimental text
if (setting.experimental) {
settingLabel = '🧪 ' + settingLabel;
if (!settingNote) {
settingNote = t('experimental');
} else {
settingNote = `${t('experimental')}: ${settingNote}`;
}
}
let $control: any;
let $inpCustomUserAgent: HTMLInputElement;
let labelAttrs: any = {
tabindex: '-1',
};
if (settingId === PrefKey.USER_AGENT_PROFILE) {
let defaultUserAgent = (window.navigator as any).orgUserAgent || window.navigator.userAgent;
$inpCustomUserAgent = CE('input', {
id: `bx_setting_inp_${settingId}`,
type: 'text',
placeholder: defaultUserAgent,
'class': 'bx-settings-custom-user-agent',
});
$inpCustomUserAgent.addEventListener('input', e => {
const profile = $control.value;
const custom = (e.target as HTMLInputElement).value.trim();
UserAgent.updateStorage(profile, custom);
onChange(e);
});
$control = toPrefElement(PrefKey.USER_AGENT_PROFILE, (e: Event) => {
const value = (e.target as HTMLInputElement).value as UserAgentProfile;
let isCustom = value === UserAgentProfile.CUSTOM;
let userAgent = UserAgent.get(value as UserAgentProfile);
UserAgent.updateStorage(value);
$inpCustomUserAgent.value = userAgent;
$inpCustomUserAgent.readOnly = !isCustom;
$inpCustomUserAgent.disabled = !isCustom;
!(e.target as HTMLInputElement).disabled && onChange(e);
});
} else if (settingId === PrefKey.SERVER_REGION) {
let selectedValue;
$control = CE<HTMLSelectElement>('select', {
id: `bx_setting_${settingId}`,
title: settingLabel,
tabindex: 0,
});
$control.name = $control.id;
$control.addEventListener('input', (e: Event) => {
setPref(settingId, (e.target as HTMLSelectElement).value);
onChange(e);
});
selectedValue = PREF_PREFERRED_REGION;
setting.options = {};
for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName];
let value = regionName;
let label = `${region.shortName} - ${regionName}`;
if (region.isDefault) {
label += ` (${t('default')})`;
value = 'default';
if (selectedValue === regionName) {
selectedValue = 'default';
}
}
setting.options[value] = label;
}
for (let value in setting.options) {
const label = setting.options[value];
const $option = CE('option', {value: value}, label);
$control.appendChild($option);
}
$control.disabled = Object.keys(STATES.serverRegions).length === 0;
// Select preferred region
$control.value = selectedValue;
} else {
if (settingId === PrefKey.BETTER_XCLOUD_LOCALE) {
$control = toPrefElement(settingId, (e: Event) => {
localStorage.setItem('better_xcloud_locale', (e.target as HTMLSelectElement).value);
onChange(e);
});
} else {
$control = toPrefElement(settingId, onChange);
}
}
if (!!$control.id) {
labelAttrs['for'] = $control.id;
} else {
labelAttrs['for'] = `bx_setting_${settingId}`;
}
// Disable unsupported settings
if (setting.unsupported) {
($control as HTMLInputElement).disabled = true;
}
// Make disabled control elements un-focusable
if ($control.disabled && !!$control.getAttribute('tabindex')) {
$control.setAttribute('tabindex', -1);
}
const $label = CE<HTMLLabelElement>('label', labelAttrs, settingLabel);
if (settingNote) {
$label.appendChild(CE('b', {}, settingNote));
}
let $elm: HTMLElement;
if ($control instanceof HTMLSelectElement && getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
// Controller-friendly <select>
$elm = CE('div', {'class': 'bx-settings-row', 'data-group': 0},
$label,
CE('div', {class: 'bx-setting-control'}, BxSelectElement.wrap($control)),
);
} else {
$elm = CE('div', {'class': 'bx-settings-row', 'data-group': 0},
$label,
$control instanceof HTMLInputElement ? CE('label', {
class: 'bx-setting-control',
for: $label.getAttribute('for'),
}, $control) : CE('div', {class: 'bx-setting-control'}, $control),
);
}
$wrapper.appendChild($elm);
// Add User-Agent input
if (settingId === PrefKey.USER_AGENT_PROFILE) {
$wrapper.appendChild($inpCustomUserAgent!);
// Trigger 'change' event
$control.disabled = true;
$control.dispatchEvent(new Event('input'));
$control.disabled = false;
}
}
}
// Setup Reload button
$btnReload = createButton({
label: t('settings-reload'),
classes: ['bx-settings-reload-button'],
style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH | ButtonStyle.TALL,
onClick: e => {
window.location.reload();
$btnReload.disabled = true;
$btnReload.textContent = t('settings-reloading');
},
});
$btnReload.setAttribute('tabindex', '0');
$wrapper.appendChild($btnReload);
// Donation link
const $donationLink = CE('a', {
'class': 'bx-donation-link',
href: 'https://ko-fi.com/redphx',
target: '_blank',
tabindex: 0,
}, `❤️ ${t('support-better-xcloud')}`);
$wrapper.appendChild($donationLink);
// Show Game Pass app version
try {
const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content;
const appDate = new Date((document.querySelector('meta[name=gamepass-app-date]') as HTMLMetaElement).content).toISOString().substring(0, 10);
$wrapper.appendChild(CE('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`));
} catch (e) {}
// Show Debug info
const debugInfo = deepClone(BX_FLAGS.DeviceInfo);
const debugSettings = [
PrefKey.STREAM_TARGET_RESOLUTION,
PrefKey.STREAM_CODEC_PROFILE,
PrefKey.VIDEO_PLAYER_TYPE,
PrefKey.VIDEO_PROCESSING,
PrefKey.VIDEO_POWER_PREFERENCE,
PrefKey.VIDEO_SHARPNESS,
];
debugInfo['settings'] = {};
for (const key of debugSettings) {
debugInfo['settings'][key] = getPref(key);
}
const $debugInfo = CE('div', {class: 'bx-debug-info'},
createButton({
label: 'Debug info',
style: ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
console.log(e);
(e.target as HTMLElement).closest('button')?.nextElementSibling?.classList.toggle('bx-gone');
},
}),
CE('pre', {
class: 'bx-gone',
on: {
click: async (e: Event) => {
try {
await navigator.clipboard.writeText((e.target as HTMLElement).innerText);
Toast.show('Copied to clipboard', '', {instant: true});
} catch (err) {
console.error('Failed to copy: ', err);
}
},
},
}, '```\n' + JSON.stringify(debugInfo, null, ' ') + '\n```'),
);
$wrapper.appendChild($debugInfo);
$container.appendChild($wrapper);
// Add Settings UI to the web page
const $pageContent = document.getElementById('PageContent');
$pageContent?.parentNode?.insertBefore($container, $pageContent);
}

View File

@@ -2,21 +2,21 @@ import { BxEvent } from "@/utils/bx-event";
import { AppInterface, STATES } from "@/utils/global";
import { createButton, ButtonStyle, CE } from "@/utils/html";
import { t } from "@/utils/translation";
import { StreamSettings } from "../stream/stream-settings";
import { SettingsNavigationDialog } from "./dialog/settings-dialog";
export enum GuideMenuTab {
HOME,
HOME = 'home',
}
export class GuideMenu {
static #BUTTONS = {
streamSetting: createButton({
label: t('stream-settings'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
scriptSettings: createButton({
label: t('better-xcloud'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.PRIMARY,
onClick: e => {
// Wait until the Guide dialog is closed
window.addEventListener(BxEvent.XCLOUD_DIALOG_DISMISSED, e => {
setTimeout(() => StreamSettings.getInstance().show(), 50);
setTimeout(() => SettingsNavigationDialog.getInstance().show(), 50);
}, {once: true});
// Close all xCloud's dialogs
@@ -86,8 +86,8 @@ export class GuideMenu {
const buttons: HTMLElement[] = [];
// "Stream settings" button
buttons.push(GuideMenu.#BUTTONS.streamSetting);
// "Better xCloud" button
buttons.push(GuideMenu.#BUTTONS.scriptSettings);
// "App settings" button
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
@@ -112,7 +112,7 @@ export class GuideMenu {
const buttons: HTMLElement[] = [];
buttons.push(GuideMenu.#BUTTONS.streamSetting);
buttons.push(GuideMenu.#BUTTONS.scriptSettings);
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
// Reload page

View File

@@ -2,17 +2,18 @@ import { SCRIPT_VERSION } from "@utils/global";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { BxIcon } from "@utils/bx-icon";
import { getPreferredServerRegion } from "@utils/region";
import { PrefKey, getPref } from "@utils/preferences";
import { RemotePlay } from "@modules/remote-play";
import { t } from "@utils/translation";
import { setupSettingsUi } from "./global-settings";
import { SettingsNavigationDialog } from "./dialog/settings-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
export class HeaderSection {
static #$remotePlayBtn = createButton({
classes: ['bx-header-remote-play-button', 'bx-gone'],
icon: BxIcon.REMOTE_PLAY,
title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.CIRCULAR,
onClick: e => {
RemotePlay.togglePopup();
},
@@ -21,14 +22,9 @@ export class HeaderSection {
static #$settingsBtn = createButton({
classes: ['bx-header-settings-button'],
label: '???',
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
style: ButtonStyle.FROSTED | ButtonStyle.DROP_SHADOW | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
onClick: e => {
setupSettingsUi();
const $settings = document.querySelector('.bx-settings-container')!;
$settings.classList.toggle('bx-gone');
window.scrollTo(0, 0);
document.activeElement && (document.activeElement as HTMLElement).blur();
SettingsNavigationDialog.getInstance().show();
},
});
@@ -49,7 +45,7 @@ export class HeaderSection {
// Setup Settings button
const $settingsBtn = HeaderSection.#$settingsBtn;
$settingsBtn.querySelector('span')!.textContent = getPreferredServerRegion(true);
$settingsBtn.querySelector('span')!.textContent = getPreferredServerRegion(true) || t('better-xcloud');
// Show new update status
if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) {

View File

@@ -19,7 +19,7 @@ export class ProductDetailsPage {
private static shortcutTimeoutId: number | null = null;
static injectShortcutButton() {
if (!AppInterface || BX_FLAGS.DeviceInfo?.deviceType !== 'android') {
if (!AppInterface || BX_FLAGS.DeviceInfo!.deviceType !== 'android') {
return;
}

View File

@@ -1,6 +1,4 @@
import { CE } from "@utils/html";
import { onChangeVideoPlayerType } from "../stream/stream-settings-utils";
import { StreamSettings } from "../stream/stream-settings";
export function localRedirect(path: string) {
@@ -26,10 +24,4 @@ export function localRedirect(path: string) {
$anchor.click();
}
export function setupStreamUi() {
StreamSettings.getInstance();
onChangeVideoPlayerType();
}
(window as any).localRedirect = localRedirect;

View File

@@ -1,6 +1,7 @@
import { AppInterface } from "@utils/global";
import { BxEvent } from "@utils/bx-event";
import { PrefKey, getPref } from "@utils/preferences";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
const VIBRATION_DATA_MAP = {
'gamepadIndex': 8,