This commit is contained in:
redphx
2024-12-05 17:10:39 +07:00
parent c836e33f7b
commit 9199351af1
207 changed files with 9833 additions and 6953 deletions

7
src/utils/bx-event.ts Normal file → Executable file
View File

@@ -21,6 +21,9 @@ export namespace BxEvent {
export const STREAM_WEBRTC_CONNECTED = 'bx-stream-webrtc-connected';
export const STREAM_WEBRTC_DISCONNECTED = 'bx-stream-webrtc-disconnected';
export const MKB_UPDATED = 'bx-mkb-updated';
export const KEYBOARD_SHORTCUTS_UPDATED = 'bx-keyboard-shortcuts-updated';
// export const STREAM_EVENT_TARGET_READY = 'bx-stream-event-target-ready';
export const STREAM_SESSION_READY = 'bx-stream-session-ready';
@@ -34,10 +37,12 @@ export namespace BxEvent {
export const XCLOUD_SERVERS_UNAVAILABLE = 'bx-servers-unavailable';
export const DATA_CHANNEL_CREATED = 'bx-data-channel-created';
export const DEVICE_VIBRATION_CHANGED = 'bx-device-vibration-changed';
export const GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated';
export const MICROPHONE_STATE_CHANGED = 'bx-microphone-state-changed';
export const SPEAKER_STATE_CHANGED = 'bx-speaker-state-changed';
export const VIDEO_VISIBILITY_CHANGED = 'bx-video-visibility-changed';
export const CAPTURE_SCREENSHOT = 'bx-capture-screenshot';
@@ -46,6 +51,8 @@ export namespace BxEvent {
export const NAVIGATION_FOCUS_CHANGED = 'bx-nav-focus-changed';
export const GH_PAGES_FORCE_NATIVE_MKB_UPDATED = 'bx-gh-pages-force-native-mkb-updated';
// xCloud Dialog events
export const XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown';
export const XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed';

19
src/utils/bx-exposed.ts Normal file → Executable file
View File

@@ -7,9 +7,10 @@ import { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags";
import { NavigationDialogManager } from "@/modules/ui/dialog/navigation-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, StreamTouchController } from "./settings-storages/global-settings-storage";
import { getPref } from "./settings-storages/global-settings-storage";
import { GamePassCloudGallery } from "@/enums/game-pass-gallery";
import { TouchController } from "@/modules/touch-controller";
import { NativeMkbMode, TouchControllerMode } from "@/enums/pref-values";
export enum SupportedInputType {
CONTROLLER = 'Controller',
@@ -89,17 +90,17 @@ export const BxExposed = {
}
// Remove native MKB support on mobile browsers or by user's choice
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
if (getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.OFF) {
supportedInputTypes = supportedInputTypes.filter(i => i !== SupportedInputType.MKB);
}
titleInfo.details.hasMkbSupport = supportedInputTypes.includes(SupportedInputType.MKB);
if (STATES.userAgent.capabilities.touch) {
let touchControllerAvailability = getPref(PrefKey.STREAM_TOUCH_CONTROLLER);
let touchControllerAvailability = getPref<TouchControllerMode>(PrefKey.TOUCH_CONTROLLER_MODE);
// Disable touch control when gamepad found
if (touchControllerAvailability !== StreamTouchController.OFF && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) {
if (touchControllerAvailability !== TouchControllerMode.OFF && getPref(PrefKey.TOUCH_CONTROLLER_AUTO_OFF)) {
const gamepads = window.navigator.getGamepads();
let gamepadFound = false;
@@ -110,10 +111,10 @@ export const BxExposed = {
}
}
gamepadFound && (touchControllerAvailability = StreamTouchController.OFF);
gamepadFound && (touchControllerAvailability = TouchControllerMode.OFF);
}
if (touchControllerAvailability === StreamTouchController.OFF) {
if (touchControllerAvailability === TouchControllerMode.OFF) {
// Disable touch on all games (not native touch)
supportedInputTypes = supportedInputTypes.filter(i => i !== SupportedInputType.CUSTOM_TOUCH_OVERLAY && i !== SupportedInputType.GENERIC_TOUCH);
// Empty TABs
@@ -126,7 +127,7 @@ export const BxExposed = {
supportedInputTypes.includes(SupportedInputType.CUSTOM_TOUCH_OVERLAY) ||
supportedInputTypes.includes(SupportedInputType.GENERIC_TOUCH);
if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === StreamTouchController.ALL) {
if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === TouchControllerMode.ALL) {
// Add generic touch support for non touch-supported games
titleInfo.details.hasFakeTouchSupport = true;
supportedInputTypes.push(SupportedInputType.GENERIC_TOUCH);
@@ -172,7 +173,7 @@ export const BxExposed = {
resetControllerShortcut: isFullVersion() && ControllerShortcut.reset,
overrideSettings: {
'Tv_settings': {
Tv_settings: {
hasCompletedOnboarding: true,
},
},
@@ -206,4 +207,6 @@ export const BxExposed = {
/ {2,}/g,
/ /g,
],
toggleLocalCoOp: (enable: boolean) => {},
};

0
src/utils/bx-flags.ts Normal file → Executable file
View File

4
src/utils/bx-icon.ts Normal file → Executable file
View File

@@ -2,7 +2,6 @@
import iconBetterXcloud from "@assets/svg/better-xcloud.svg" with { type: "text" };
import iconTrueAchievements from "@assets/svg/true-achievements.svg" with { type: "text" };
import iconClose from "@assets/svg/close.svg" with { type: "text" };
import iconCommand from "@assets/svg/command.svg" with { type: "text" };
import iconController from "@assets/svg/controller.svg" with { type: "text" };
import iconCopy from "@assets/svg/copy.svg" with { type: "text" };
import iconCreateShortcut from "@assets/svg/create-shortcut.svg" with { type: "text" };
@@ -23,7 +22,6 @@ import iconStreamStats from "@assets/svg/stream-stats.svg" with { type: "text" }
import iconTouchControlDisable from "@assets/svg/touch-control-disable.svg" with { type: "text" };
import iconTouchControlEnable from "@assets/svg/touch-control-enable.svg" with { type: "text" };
import iconTrash from "@assets/svg/trash.svg" with { type: "text" };
import iconVirtualController from "@assets/svg/virtual-controller.svg" with { type: "text" };
// Game Bar
import iconCaretLeft from "@assets/svg/caret-left.svg" with { type: "text" };
@@ -47,7 +45,6 @@ export const BxIcon = {
STREAM_SETTINGS: iconStreamSettings,
STREAM_STATS: iconStreamStats,
CLOSE: iconClose,
COMMAND: iconCommand,
CONTROLLER: iconController,
CREATE_SHORTCUT: iconCreateShortcut,
DISPLAY: iconDisplay,
@@ -62,7 +59,6 @@ export const BxIcon = {
POWER: iconPower,
QUESTION: iconQuestion,
REFRESH: iconRefresh,
VIRTUAL_CONTROLLER: iconVirtualController,
REMOTE_PLAY: iconRemotePlay,

0
src/utils/bx-logger.ts Normal file → Executable file
View File

15
src/utils/css.ts Normal file → Executable file
View File

@@ -1,6 +1,6 @@
import { CE } from "@utils/html";
import { compressCss, renderStylus } from "@macros/build" with {type: "macro"};
import { UiSection } from "@/enums/ui-sections";
import { UiSection } from "@/enums/pref-values";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
@@ -9,7 +9,7 @@ export function addCss() {
const STYLUS_CSS = renderStylus() as unknown as string;
let css = STYLUS_CSS;
const PREF_HIDE_SECTIONS = getPref(PrefKey.UI_HIDE_SECTIONS);
const PREF_HIDE_SECTIONS = getPref<UiSection[]>(PrefKey.UI_HIDE_SECTIONS);
const selectorToHide = [];
// Hide "News" section
@@ -17,6 +17,11 @@ export function addCss() {
selectorToHide.push('#BodyContent > div[class*=CarouselRow-module]');
}
// Hide BYOG section
if (getPref(PrefKey.BYOG_DISABLED)) {
selectorToHide.push('#BodyContent > div[class*=ByogRow-module__container___]');
}
// Hide "All games" section
if (PREF_HIDE_SECTIONS.includes(UiSection.ALL_GAMES)) {
selectorToHide.push('#BodyContent div[class*=AllGamesRow-module__gridContainer]');
@@ -43,7 +48,7 @@ export function addCss() {
}
// Reduce animations
if (getPref(PrefKey.REDUCE_ANIMATIONS)) {
if (getPref(PrefKey.UI_REDUCE_ANIMATIONS)) {
css += compressCss(`
div[class*=GameCard-module__gameTitleInnerWrapper],
div[class*=GameCard-module__card],
@@ -54,7 +59,7 @@ div[class*=ScrollArrows-module] {
}
// Hide the top-left dots icon while playing
if (getPref(PrefKey.HIDE_DOTS_ICON)) {
if (getPref(PrefKey.UI_HIDE_SYSTEM_MENU_ICON)) {
css += compressCss(`
div[class*=Grip-module__container] {
visibility: hidden;
@@ -87,7 +92,7 @@ div[class*=StreamMenu-module__menu] {
`);
// Simplify Stream's menu
if (getPref(PrefKey.STREAM_SIMPLIFY_MENU)) {
if (getPref(PrefKey.UI_SIMPLIFY_STREAM_MENU)) {
css += compressCss(`
div[class*=Menu-module__scrollable] {
--bxStreamMenuItemSize: 80px;

23
src/utils/feature-gates.ts Normal file → Executable file
View File

@@ -1,17 +1,30 @@
import { PrefKey } from "@/enums/pref-keys";
import { BX_FLAGS } from "./bx-flags";
import { getPref } from "./settings-storages/global-settings-storage";
import { NativeMkbMode } from "@/enums/pref-values";
export let FeatureGates: {[key: string]: boolean} = {
'PwaPrompt': false,
'EnableWifiWarnings': false,
'EnableUpdateRequiredPage': false,
'ShowForcedUpdateScreen': false,
PwaPrompt: false,
EnableWifiWarnings: false,
EnableUpdateRequiredPage: false,
ShowForcedUpdateScreen: false,
};
// Enable Native Mouse & Keyboard
const nativeMkbMode = getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE);
if (nativeMkbMode !== NativeMkbMode.DEFAULT) {
FeatureGates.EnableMouseAndKeyboard = nativeMkbMode === NativeMkbMode.ON;
}
// Disable chat feature
if (getPref(PrefKey.BLOCK_SOCIAL_FEATURES)) {
FeatureGates['EnableGuideChatTab'] = false;
FeatureGates.EnableGuideChatTab = false;
}
// Disable BYOG feature
if (getPref(PrefKey.BYOG_DISABLED)) {
FeatureGates.EnableBYOG = false;
FeatureGates.EnableBYOGPurchase = false;
}
if (BX_FLAGS.FeatureGates) {

24
src/utils/gamepad.ts Normal file → Executable file
View File

@@ -34,6 +34,26 @@ export function showGamepadToast(gamepad: Gamepad) {
Toast.show(text, status, {instant: false});
}
export function updatePollingRate() {
window.BX_CONTROLLER_POLLING_RATE = getPref(PrefKey.CONTROLLER_POLLING_RATE);
export function getUniqueGamepadNames() {
const gamepads = window.navigator.getGamepads();
const names: string[] = [];
for (const gamepad of gamepads) {
if (gamepad?.connected && gamepad.id !== VIRTUAL_GAMEPAD_ID) {
!names.includes(gamepad.id) && names.push(gamepad.id);
}
}
return names;
}
export function hasGamepad() {
const gamepads = window.navigator.getGamepads();
for (const gamepad of gamepads) {
if (gamepad?.connected) {
return true;
}
}
return false;
}

84
src/utils/gh-pages.ts Executable file
View File

@@ -0,0 +1,84 @@
import { StorageKey } from "@/enums/pref-keys";
import { NATIVE_FETCH } from "./bx-flags";
import { BxLogger } from "./bx-logger";
import { BxEvent } from "./bx-event";
export type ForceNativeMkbResponse = {
$schemaVersion: number;
data: { [key: string]: string };
}
export class GhPagesUtils {
static fetchLatestCommit() {
const url = 'https://api.github.com/repos/redphx/better-xcloud/branches/gh-pages';
NATIVE_FETCH(url, {
method: 'GET',
headers: {
'Accept': 'application/vnd.github.v3+json',
},
})
.then(response => response.json())
.then(data => {
const latestCommitHash = data.commit.sha;
window.localStorage.setItem(StorageKey.GH_PAGES_COMMIT_HASH, latestCommitHash);
}).catch(error => {
BxLogger.error('GhPagesUtils', 'Error fetching the latest commit:', error);
});
}
static getUrl(path: string): string {
if (path[0] === '/') {
alert('`path` must not starts with "/"');
}
const prefix = 'https://raw.githubusercontent.com/redphx/better-xcloud';
const latestCommitHash = window.localStorage.getItem(StorageKey.GH_PAGES_COMMIT_HASH);
if (latestCommitHash) {
return `${prefix}/${latestCommitHash}/${path}`;
} else {
return `${prefix}/refs/heads/gh-pages/${path}`;
}
}
static getNativeMkbCustomList(update=false): ForceNativeMkbResponse['data'] {
const supportedSchema = 1;
const key = StorageKey.LIST_FORCE_NATIVE_MKB;
// Update IDs in the background
update && NATIVE_FETCH(GhPagesUtils.getUrl('native-mkb/ids.json'))
.then(response => response.json())
.then((json: ForceNativeMkbResponse) => {
if (json.$schemaVersion === supportedSchema) {
// Save to storage
window.localStorage.setItem(key, JSON.stringify(json));
BxEvent.dispatch(window, BxEvent.GH_PAGES_FORCE_NATIVE_MKB_UPDATED);
}
});
const info = JSON.parse(window.localStorage.getItem(key) || '{}');
if (info.$schemaVersion !== supportedSchema) {
// Delete storage;
window.localStorage.removeItem(key);
return {};
}
return info.data;
}
static getTouchControlCustomList() {
const key = StorageKey.LIST_CUSTOM_TOUCH_LAYOUTS;
NATIVE_FETCH(GhPagesUtils.getUrl('touch-layouts/ids.json'))
.then(response => response.json())
.then(json => {
if (Array.isArray(json)) {
window.localStorage.setItem(key, JSON.stringify(json));
}
});
const customList = JSON.parse(window.localStorage.getItem(key) || '[]');
return customList;
}
}

16
src/utils/global.ts Normal file → Executable file
View File

@@ -13,7 +13,6 @@ const isTv = userAgent.includes('smart-tv') || userAgent.includes('smarttv') ||
const isVr = window.navigator.userAgent.includes('VR') && window.navigator.userAgent.includes('OculusBrowser');
const browserHasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport;
const supportMkb = AppInterface || !userAgent.match(/(android|iphone|ipad)/);
export const STATES: BxStates = {
supportedRegion: true,
@@ -28,6 +27,9 @@ export const STATES: BxStates = {
capabilities: {
touch: browserHasTouchSupport,
batteryApi: 'getBattery' in window.navigator,
deviceVibration: !!window.navigator.vibrate,
mkb: AppInterface || !UserAgent.getDefault().toLowerCase().match(/(android|iphone|ipad)/),
emulatedNativeMkb: !!AppInterface,
},
},
@@ -35,7 +37,7 @@ export const STATES: BxStates = {
isTv: isTv,
capabilities: {
touch: userAgentHasTouchSupport,
mkb: supportMkb,
mkb: AppInterface || !userAgent.match(/(android|iphone|ipad)/),
}
},
@@ -47,14 +49,14 @@ export const STATES: BxStates = {
export const STORAGE: {[key: string]: BaseSettingsStore} = {};
export function deepClone(obj: any): any {
if ('structuredClone' in window) {
return structuredClone(obj);
}
export function deepClone(obj: any): typeof obj | {} {
if (!obj) {
return {};
}
if ('structuredClone' in window) {
return structuredClone(obj);
}
return JSON.parse(JSON.stringify(obj));
}

0
src/utils/history.ts Normal file → Executable file
View File

207
src/utils/html.ts Normal file → Executable file
View File

@@ -1,24 +1,28 @@
import type { BxIcon } from "@utils/bx-icon";
import { setNearby } from "./navigation-utils";
import type { NavigationNearbyElements } from "@/modules/ui/dialog/navigation-dialog";
import type { PresetRecord, AllPresets } from "@/types/presets";
import { t } from "./translation";
export enum ButtonStyle {
PRIMARY = 1,
DANGER = 2,
GHOST = 4,
FROSTED = 8,
DROP_SHADOW = 16,
FOCUSABLE = 32,
FULL_WIDTH = 64,
FULL_HEIGHT = 128,
TALL = 256,
CIRCULAR = 512,
NORMAL_CASE = 1024,
NORMAL_LINK = 2048,
WARNING = 1 << 1,
DANGER = 1 << 2,
GHOST = 1 << 3,
FROSTED = 1 << 4,
DROP_SHADOW = 1 << 5,
FOCUSABLE = 1 << 6,
FULL_WIDTH = 1 << 7,
FULL_HEIGHT = 1 << 8,
TALL = 1 << 9,
CIRCULAR = 1 << 10,
NORMAL_CASE = 1 << 11,
NORMAL_LINK = 1 << 12,
}
const ButtonStyleClass = {
[ButtonStyle.PRIMARY]: 'bx-primary',
[ButtonStyle.WARNING]: 'bx-warning',
[ButtonStyle.DANGER]: 'bx-danger',
[ButtonStyle.GHOST]: 'bx-ghost',
[ButtonStyle.FROSTED]: 'bx-frosted',
@@ -32,22 +36,34 @@ const ButtonStyleClass = {
[ButtonStyle.NORMAL_LINK]: 'bx-normal-link',
}
export type BxButton = {
style?: ButtonStyle;
url?: string;
classes?: string[];
icon?: typeof BxIcon;
label?: string;
title?: string;
disabled?: boolean;
onClick?: EventListener;
tabIndex?: number;
attributes?: {[key: string]: any},
}
export type BxButtonOptions = Partial<{
style: ButtonStyle;
url: string;
classes: string[];
icon: typeof BxIcon;
label: string;
secondaryText: HTMLElement | string;
title: string;
disabled: boolean;
onClick: EventListener;
tabIndex: number;
attributes: {[key: string]: any},
}>;
export type SettingsRowOptions = Partial<{
multiLines: boolean;
$note: HTMLElement;
}>;
// Quickly create a tree of elements without having to use innerHTML
type CreateElementOptions = {
[index: string]: any;
_on?: {
[ key: string ]: (e: Event) => void;
};
_dataset?: {
[ key: string ]: string | number;
};
_nearby?: NavigationNearbyElements;
};
@@ -65,9 +81,23 @@ function createElement<T=HTMLElement>(elmName: string, props: CreateElementOptio
$elm = document.createElement(elmName);
}
if (props['_nearby']) {
setNearby($elm, props['_nearby']);
delete props['_nearby'];
if (props._nearby) {
setNearby($elm, props._nearby);
delete props._nearby;
}
if (props._on) {
for (const name in props._on) {
$elm.addEventListener(name, props._on[name]);
}
delete props._on;
}
if (props._dataset) {
for (const name in props._dataset) {
$elm.dataset[name] = props._dataset[name] as string;
}
delete props._dataset;
}
for (const key in props) {
@@ -75,33 +105,25 @@ function createElement<T=HTMLElement>(elmName: string, props: CreateElementOptio
continue;
}
const value = props[key];
if (hasNs) {
$elm.setAttributeNS(null, key, props[key]);
$elm.setAttributeNS(null, key, value);
} else {
if (key === 'on') {
for (const eventName in props[key]) {
$elm.addEventListener(eventName, props[key][eventName]);
}
} else {
$elm.setAttribute(key, props[key]);
}
$elm.setAttribute(key, value);
}
}
for (let i = 2, size = arguments.length; i < size; i++) {
const arg = arguments[i];
if (arg instanceof Node) {
$elm.appendChild(arg);
} else if (arg !== null && arg !== false && typeof arg !== 'undefined') {
$elm.appendChild(document.createTextNode(arg));
if (arg !== null && arg !== false && typeof arg !== 'undefined') {
$elm.append(arg);
}
}
return $elm as T;
}
export const CE = createElement;
const domParser = new DOMParser();
export function createSvgIcon(icon: typeof BxIcon) {
@@ -110,16 +132,26 @@ export function createSvgIcon(icon: typeof BxIcon) {
const ButtonStyleIndices = Object.keys(ButtonStyleClass).map(i => parseInt(i));
export function createButton<T=HTMLButtonElement>(options: BxButton): T {
export function createButton<T=HTMLButtonElement>(options: BxButtonOptions): T {
let $btn;
// Create base button element
if (options.url) {
$btn = CE<HTMLAnchorElement>('a', {'class': 'bx-button'});
$btn.href = options.url;
$btn.target = '_blank';
$btn = CE<HTMLAnchorElement>('a', {
class: 'bx-button',
href: options.url,
target: '_blank',
});
} else {
$btn = CE<HTMLButtonElement>('button', {'class': 'bx-button', type: 'button'});
$btn = CE<HTMLButtonElement>('button', {
class: 'bx-button',
type: 'button',
});
options.disabled && ($btn.disabled = true);
}
// Add button styles
const style = (options.style || 0) as number;
if (style) {
let index: keyof typeof ButtonStyleClass;
@@ -133,10 +165,14 @@ export function createButton<T=HTMLButtonElement>(options: BxButton): T {
options.icon && $btn.appendChild(createSvgIcon(options.icon));
options.label && $btn.appendChild(CE('span', {}, options.label));
options.title && $btn.setAttribute('title', options.title);
options.disabled && (($btn as HTMLButtonElement).disabled = true);
options.onClick && $btn.addEventListener('click', options.onClick);
$btn.tabIndex = typeof options.tabIndex === 'number' ? options.tabIndex : 0;
if (options.secondaryText) {
$btn.classList.add('bx-button-multi-lines');
$btn.appendChild(CE('span', {}, options.secondaryText));
}
for (const key in options.attributes) {
if (!$btn.hasOwnProperty(key)) {
$btn.setAttribute(key, options.attributes[key]);
@@ -146,6 +182,41 @@ export function createButton<T=HTMLButtonElement>(options: BxButton): T {
return $btn as T;
}
export function createSettingRow(label: string, $control: HTMLElement | false | undefined, options: SettingsRowOptions={}) {
let $label: HTMLElement;
const $row = CE<HTMLLabelElement>('label', { class: 'bx-settings-row' },
$label = CE('span', {class: 'bx-settings-label'},
label,
options.$note,
),
$control,
);
// Make link inside <label> focusable
const $link = $label.querySelector('a');
if ($link) {
$link.classList.add('bx-focusable');
setNearby($label, {
focus: $link,
});
}
setNearby($row, {
orientation: options.multiLines ? 'vertical' : 'horizontal',
});
if (options.multiLines) {
$row.dataset.multiLines = 'true';
}
if ($control instanceof HTMLElement && $control.id) {
$row.htmlFor = $control.id;
}
return $row;
}
export function getReactProps($elm: HTMLElement): any | null {
for (const key in $elm) {
if (key.startsWith('__reactProps')) {
@@ -169,10 +240,12 @@ export function isElementVisible($elm: HTMLElement): boolean {
return (rect.x >= 0 || rect.y >= 0) && !!rect.width && !!rect.height;
}
export const CTN = document.createTextNode.bind(document);
window.BX_CE = createElement;
export function removeChildElements($parent: HTMLElement) {
if ($parent instanceof HTMLDivElement && $parent.classList.contains('bx-select')) {
$parent = $parent.querySelector('select')!;
}
while ($parent.firstElementChild) {
$parent.firstElementChild.remove();
}
@@ -190,6 +263,39 @@ export function clearDataSet($elm: HTMLElement) {
});
}
export function renderPresetsList<T extends PresetRecord>($select: HTMLSelectElement, allPresets: AllPresets<T>, selectedValue: number | null, addOffValue=false) {
removeChildElements($select);
if (addOffValue) {
const $option = CE<HTMLOptionElement>('option', { value: 0 }, t('off'));
$option.selected = selectedValue === 0;
$select.appendChild($option);
}
// Render options
const groups = {
default: t('default'),
custom: t('custom'),
};
let key: keyof typeof groups;
for (key in groups) {
const $optGroup = CE('optgroup', { label: groups[key] });
for (const id of allPresets[key]) {
const record = allPresets.data[id];
const $option = CE<HTMLOptionElement>('option', { value: record.id }, record.name);
$option.selected = selectedValue === record.id;
$optGroup.appendChild($option);
}
if ($optGroup.hasChildNodes()) {
$select.appendChild($optGroup);
}
}
}
// https://stackoverflow.com/a/20732091
const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
export function humanFileSize(size: number) {
@@ -228,3 +334,10 @@ export function secondsToHms(seconds: number) {
return output.join(' ');
}
export function escapeCssSelector(name: string) {
return name.replaceAll('.', '-');
}
export const CE = createElement;
window.BX_CE = createElement;

View File

@@ -0,0 +1,84 @@
import type { AllPresets, AllPresetsData, PresetRecord, PresetRecords } from "@/types/presets";
import { deepClone } from "../global";
import { BaseLocalTable } from "./base-table";
export abstract class BasePresetsTable<T extends PresetRecord> extends BaseLocalTable<T> {
protected abstract TABLE_PRESETS: string;
protected abstract DEFAULT_PRESETS: PresetRecords<T>;
protected abstract readonly DEFAULT_PRESET_ID: number;
async newPreset(name: string, data: T['data']) {
const newRecord = { name, data } as T;
return await this.add(newRecord);
}
async updatePreset(preset: T) {
return await this.put(preset);
}
async deletePreset(id: number) {
return this.delete(id);
}
async getPreset(id: number): Promise<T | null> {
if (id === 0) {
return null;
}
if (id < 0) {
return this.DEFAULT_PRESETS[id];
}
let preset = await this.get(id);
if (!preset) {
preset = this.DEFAULT_PRESETS[this.DEFAULT_PRESET_ID];
}
return preset;
}
async getPresets(): Promise<AllPresets<T>> {
let all = deepClone(this.DEFAULT_PRESETS) as Record<string, T>;
const presets: AllPresets<T> = {
default: Object.keys(this.DEFAULT_PRESETS).map(key => parseInt(key)),
custom: [],
data: {},
}
const count = await this.count();
if (count > 0) {
const items = await this.getAll();
let id: keyof typeof items;
for (id in items) {
const item = items[id]!;
presets.custom.push(item.id!);
all[item.id] = item;
}
}
presets.data = all;
return presets;
}
async getPresetsData(): Promise<AllPresetsData<T>> {
const presetsData: AllPresetsData<T> = {}
for (const id in this.DEFAULT_PRESETS) {
const preset = this.DEFAULT_PRESETS[id];
presetsData[id] = deepClone(preset.data);
}
const count = await this.count();
if (count > 0) {
const items = await this.getAll();
let id: keyof typeof items;
for (id in items) {
const item = items[id]!;
presetsData[item.id] = item.data;
}
}
return presetsData;
}
}

View File

@@ -0,0 +1,70 @@
import { LocalDb } from "./local-db";
export class BaseLocalTable<T extends BaseRecord> {
private tableName: string;
constructor(tableName: string) {
this.tableName = tableName;
}
protected async prepareTable(type: IDBTransactionMode = 'readonly'): Promise<IDBObjectStore> {
const db = await LocalDb.getInstance().open();
const transaction = db.transaction(this.tableName, type);
const table = transaction.objectStore(this.tableName);
return table;
}
// Convert IndexDB method to Promise
protected call(method: any) {
return new Promise(resolve => {
const request = method.call(null, ...Array.from(arguments).slice(1));
request.onsuccess = (e: Event) => {
resolve((e.target as any).result);
};
});
}
async count(): Promise<number> {
const table = await this.prepareTable();
// @ts-ignore
return this.call(table.count.bind(table));
}
async add(data: T): Promise<number> {
const table = await this.prepareTable('readwrite');
// @ts-ignore
return this.call(table.add.bind(table), ...arguments);
}
async put(data: T): Promise<number> {
const table = await this.prepareTable('readwrite');
// @ts-ignore
return this.call(table.put.bind(table), ...arguments);
}
async delete(id: T['id']): Promise<number> {
const table = await this.prepareTable('readwrite');
// @ts-ignore
return this.call(table.delete.bind(table), ...arguments);
}
async get(id: T['id']): Promise<T> {
const table = await this.prepareTable();
// @ts-ignore
return this.call(table.get.bind(table), ...arguments);
}
async getAll(): Promise<{[key: string]: T}> {
const table = await this.prepareTable();
// @ts-ignore
const all = await (this.call(table.getAll.bind(table), ...arguments) as Promise<T[]>);
const results: {[key: string]: T} = {};
all.forEach(item => {
results[item.id as T['id']] = item;
});
return results;
}
}

View File

@@ -0,0 +1,38 @@
import { BaseLocalTable } from "./base-table";
import { LocalDb } from "./local-db";
import { ControllerShortcutDefaultId } from "./controller-shortcuts-table";
import { deepClone } from "../global";
export class ControllerSettingsTable extends BaseLocalTable<ControllerSettingsRecord> {
private static instance: ControllerSettingsTable;
public static getInstance = () => ControllerSettingsTable.instance ?? (ControllerSettingsTable.instance = new ControllerSettingsTable(LocalDb.TABLE_CONTROLLER_SETTINGS));
static readonly DEFAULT_DATA: ControllerSettingsRecord['data'] = {
shortcutPresetId: ControllerShortcutDefaultId.DEFAULT,
vibrationIntensity: 50,
};
async getControllerData(id: string): Promise<ControllerSettingsRecord['data']> {
const setting = await this.get(id);
if (!setting) {
return deepClone(ControllerSettingsTable.DEFAULT_DATA);
}
return setting.data;
}
async getControllersData() {
const all = await this.getAll();
const results: {[key: string]: ControllerSettingsRecord['data']} = {};
for (const key in all) {
const settings = all[key].data;
// Pre-calculate virabtionIntensity
settings.vibrationIntensity /= 100;
results[key] = settings;
}
return results;
}
}

View File

@@ -0,0 +1,61 @@
import type { ControllerShortcutPresetRecord, PresetRecords } from "@/types/presets";
import { BxLogger } from "../bx-logger";
import { BasePresetsTable } from "./base-presets-table";
import { LocalDb } from "./local-db";
import { GamepadKey } from "@/enums/gamepad";
import { ShortcutAction } from "@/enums/shortcut-actions";
import { AppInterface } from "../global";
export enum ControllerShortcutDefaultId {
TYPE_A = -1,
TYPE_B = -2,
DEFAULT = TYPE_A,
};
export class ControllerShortcutsTable extends BasePresetsTable<ControllerShortcutPresetRecord> {
private static instance: ControllerShortcutsTable;
public static getInstance = () => ControllerShortcutsTable.instance ?? (ControllerShortcutsTable.instance = new ControllerShortcutsTable());
private readonly LOG_TAG = 'ControllerShortcutsTable';
protected readonly TABLE_PRESETS: string = LocalDb.TABLE_CONTROLLER_SHORTCUTS;
protected readonly DEFAULT_PRESETS: PresetRecords<ControllerShortcutPresetRecord> = {
[ControllerShortcutDefaultId.TYPE_A]: {
id: ControllerShortcutDefaultId.TYPE_A,
name: 'Type A',
data: {
mapping: {
[GamepadKey.Y]: AppInterface ? ShortcutAction.DEVICE_VOLUME_INC : ShortcutAction.STREAM_VOLUME_INC,
[GamepadKey.A]: AppInterface ? ShortcutAction.DEVICE_VOLUME_DEC : ShortcutAction.STREAM_VOLUME_DEC,
[GamepadKey.X]: ShortcutAction.STREAM_STATS_TOGGLE,
[GamepadKey.B]: AppInterface ? ShortcutAction.DEVICE_SOUND_TOGGLE : ShortcutAction.STREAM_SOUND_TOGGLE,
[GamepadKey.RB]: ShortcutAction.STREAM_SCREENSHOT_CAPTURE,
[GamepadKey.START]: ShortcutAction.STREAM_MENU_SHOW,
},
},
},
[ControllerShortcutDefaultId.TYPE_B]: {
id: ControllerShortcutDefaultId.TYPE_B,
name: 'Type B',
data: {
mapping: {
[GamepadKey.UP]: AppInterface ? ShortcutAction.DEVICE_VOLUME_INC : ShortcutAction.STREAM_VOLUME_INC,
[GamepadKey.DOWN]: AppInterface ? ShortcutAction.DEVICE_VOLUME_DEC : ShortcutAction.STREAM_VOLUME_DEC,
[GamepadKey.RIGHT]: ShortcutAction.STREAM_STATS_TOGGLE,
[GamepadKey.LEFT]: AppInterface ? ShortcutAction.DEVICE_SOUND_TOGGLE : ShortcutAction.STREAM_SOUND_TOGGLE,
[GamepadKey.LB]: ShortcutAction.STREAM_SCREENSHOT_CAPTURE,
[GamepadKey.SELECT]: ShortcutAction.STREAM_MENU_SHOW,
},
},
},
};
protected readonly DEFAULT_PRESET_ID = ControllerShortcutDefaultId.DEFAULT;
private constructor() {
super(LocalDb.TABLE_CONTROLLER_SHORTCUTS);
BxLogger.info(this.LOG_TAG, 'constructor()');
}
}

View File

@@ -0,0 +1,45 @@
import type { KeyboardShortcutPresetRecord, PresetRecords } from "@/types/presets";
import { BxLogger } from "../bx-logger";
import { BasePresetsTable } from "./base-presets-table";
import { LocalDb } from "./local-db";
import { ShortcutAction } from "@/enums/shortcut-actions";
import { t } from "../translation";
export enum KeyboardShortcutDefaultId {
OFF = 0,
STANDARD = -1,
DEFAULT = STANDARD,
};
export class KeyboardShortcutsTable extends BasePresetsTable<KeyboardShortcutPresetRecord> {
private static instance: KeyboardShortcutsTable;
public static getInstance = () => KeyboardShortcutsTable.instance ?? (KeyboardShortcutsTable.instance = new KeyboardShortcutsTable());
private readonly LOG_TAG = 'KeyboardShortcutsTable';
protected readonly TABLE_PRESETS: string = LocalDb.TABLE_KEYBOARD_SHORTCUTS;
protected readonly DEFAULT_PRESETS: PresetRecords<KeyboardShortcutPresetRecord> = {
[KeyboardShortcutDefaultId.DEFAULT]: {
id: KeyboardShortcutDefaultId.DEFAULT,
name: t('standard'),
data: {
mapping: {
[ShortcutAction.MKB_TOGGLE]: {
code: 'F8',
},
[ShortcutAction.STREAM_SCREENSHOT_CAPTURE]: {
code: 'Slash',
},
},
},
},
};
protected readonly DEFAULT_PRESET_ID = KeyboardShortcutDefaultId.DEFAULT;
private constructor() {
super(LocalDb.TABLE_KEYBOARD_SHORTCUTS);
BxLogger.info(this.LOG_TAG, 'constructor()');
}
}

113
src/utils/local-db/local-db.ts Normal file → Executable file
View File

@@ -1,18 +1,65 @@
export abstract class LocalDb {
export class LocalDb {
private static instance: LocalDb;
public static getInstance = () => LocalDb.instance ?? (LocalDb.instance = new LocalDb());
// private readonly LOG_TAG = 'LocalDb';
static readonly DB_NAME = 'BetterXcloud';
static readonly DB_VERSION = 2;
static readonly DB_VERSION = 3;
protected db!: IDBDatabase;
static readonly TABLE_VIRTUAL_CONTROLLERS = 'virtual_controllers';
static readonly TABLE_CONTROLLER_SHORTCUTS = 'controller_shortcuts';
static readonly TABLE_CONTROLLER_SETTINGS = 'controller_settings';
static readonly TABLE_KEYBOARD_SHORTCUTS = 'keyboard_shortcuts';
protected open() {
return new Promise<void>((resolve, reject) => {
private db!: IDBDatabase;
open() {
return new Promise<IDBDatabase>((resolve, reject) => {
if (this.db) {
resolve();
resolve(this.db);
return;
}
const request = window.indexedDB.open(LocalDb.DB_NAME, LocalDb.DB_VERSION);
request.onupgradeneeded = this.onUpgradeNeeded.bind(this);
request.onupgradeneeded = (e: IDBVersionChangeEvent) => {
const db = (e.target! as any).result as IDBDatabase;
// Delete "undefined" table
if (db.objectStoreNames.contains('undefined')) {
db.deleteObjectStore('undefined');
}
// Virtual controller
if (!db.objectStoreNames.contains(LocalDb.TABLE_VIRTUAL_CONTROLLERS)) {
db.createObjectStore(LocalDb.TABLE_VIRTUAL_CONTROLLERS, {
keyPath: 'id',
autoIncrement: true,
});
}
// Controller shortcuts
if (!db.objectStoreNames.contains(LocalDb.TABLE_CONTROLLER_SHORTCUTS)) {
db.createObjectStore(LocalDb.TABLE_CONTROLLER_SHORTCUTS, {
keyPath: 'id',
autoIncrement: true,
});
}
// Controller settings
if (!db.objectStoreNames.contains(LocalDb.TABLE_CONTROLLER_SETTINGS)) {
db.createObjectStore(LocalDb.TABLE_CONTROLLER_SETTINGS, {
keyPath: 'id',
});
}
// Keyboard shortcuts
if (!db.objectStoreNames.contains(LocalDb.TABLE_KEYBOARD_SHORTCUTS)) {
db.createObjectStore(LocalDb.TABLE_KEYBOARD_SHORTCUTS, {
keyPath: 'id',
autoIncrement: true,
});
}
};
request.onerror = e => {
console.log(e);
@@ -22,58 +69,8 @@ export abstract class LocalDb {
request.onsuccess = e => {
this.db = (e.target as any).result;
resolve();
resolve(this.db);
};
});
}
protected abstract onUpgradeNeeded(e: IDBVersionChangeEvent): void;
protected table(name: string, type: IDBTransactionMode): Promise<IDBObjectStore> {
const transaction = this.db.transaction(name, type || 'readonly');
const table = transaction.objectStore(name);
return new Promise(resolve => resolve(table));
}
// Convert IndexDB method to Promise
protected call(method: any) {
const table = arguments[1];
return new Promise(resolve => {
const request = method.call(table, ...Array.from(arguments).slice(2));
request.onsuccess = (e: Event) => {
resolve([table, (e.target as any).result]);
};
});
}
protected count(table: IDBObjectStore): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.call(table.count, ...arguments);
}
protected add(table: IDBObjectStore, data: any): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.call(table.add, ...arguments);
}
protected put(table: IDBObjectStore, data: any): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.call(table.put, ...arguments);
}
protected delete(table: IDBObjectStore, data: any): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.call(table.delete, ...arguments);
}
protected get(table: IDBObjectStore, id: number): Promise<any> {
// @ts-ignore
return this.call(table.get, ...arguments);
}
protected getAll(table: IDBObjectStore): Promise<[IDBObjectStore, any]> {
// @ts-ignore
return this.call(table.getAll, ...arguments);
}
}

View File

@@ -0,0 +1,126 @@
import { LocalDb } from "./local-db";
import { BxLogger } from "../bx-logger";
import { BasePresetsTable } from "./base-presets-table";
import type { MkbPresetRecord, PresetRecords } from "@/types/presets";
import { MkbPresetKey, MouseMapTo, MouseButtonCode } from "@/enums/mkb";
import { GamepadKey } from "@/enums/gamepad";
import { t } from "../translation";
export const enum MkbMappingDefaultPresetId {
OFF = 0,
STANDARD = -1,
SHOOTER = -2,
DEFAULT = STANDARD,
};
export class MkbMappingPresetsTable extends BasePresetsTable<MkbPresetRecord> {
private static instance: MkbMappingPresetsTable;
public static getInstance = () => MkbMappingPresetsTable.instance ?? (MkbMappingPresetsTable.instance = new MkbMappingPresetsTable());
private readonly LOG_TAG = 'MkbMappingPresetsTable';
protected readonly TABLE_PRESETS = LocalDb.TABLE_VIRTUAL_CONTROLLERS;
protected readonly DEFAULT_PRESETS: PresetRecords<MkbPresetRecord> = {
[MkbMappingDefaultPresetId.STANDARD]: {
id: MkbMappingDefaultPresetId.STANDARD,
name: t('standard'),
data: {
mapping: {
[GamepadKey.HOME]: ['Backquote'],
[GamepadKey.UP]: ['ArrowUp', 'Digit1'],
[GamepadKey.DOWN]: ['ArrowDown', 'Digit2'],
[GamepadKey.LEFT]: ['ArrowLeft', 'Digit3'],
[GamepadKey.RIGHT]: ['ArrowRight', 'Digit4'],
[GamepadKey.LS_UP]: ['KeyW'],
[GamepadKey.LS_DOWN]: ['KeyS'],
[GamepadKey.LS_LEFT]: ['KeyA'],
[GamepadKey.LS_RIGHT]: ['KeyD'],
[GamepadKey.RS_UP]: ['KeyU'],
[GamepadKey.RS_DOWN]: ['KeyJ'],
[GamepadKey.RS_LEFT]: ['KeyH'],
[GamepadKey.RS_RIGHT]: ['KeyK'],
[GamepadKey.A]: ['Space', 'KeyE'],
[GamepadKey.X]: ['KeyR'],
[GamepadKey.B]: ['KeyC', 'Backspace'],
[GamepadKey.Y]: ['KeyE'],
[GamepadKey.START]: ['Enter'],
[GamepadKey.SELECT]: ['Tab'],
[GamepadKey.LB]: ['KeyQ'],
[GamepadKey.RB]: ['KeyF'],
[GamepadKey.RT]: [MouseButtonCode.LEFT_CLICK],
[GamepadKey.LT]: [MouseButtonCode.RIGHT_CLICK],
[GamepadKey.L3]: ['KeyX'],
[GamepadKey.R3]: ['KeyZ'],
},
mouse: {
[MkbPresetKey.MOUSE_MAP_TO]: MouseMapTo.RS,
[MkbPresetKey.MOUSE_SENSITIVITY_X]: 100,
[MkbPresetKey.MOUSE_SENSITIVITY_Y]: 100,
[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT]: 20,
},
},
},
[MkbMappingDefaultPresetId.SHOOTER]: {
id: MkbMappingDefaultPresetId.SHOOTER,
name: 'Shooter',
data: {
mapping: {
[GamepadKey.HOME]: ['Backquote'],
[GamepadKey.UP]: ['ArrowUp'],
[GamepadKey.DOWN]: ['ArrowDown'],
[GamepadKey.LEFT]: ['ArrowLeft'],
[GamepadKey.RIGHT]: ['ArrowRight'],
[GamepadKey.LS_UP]: ['KeyW'],
[GamepadKey.LS_DOWN]: ['KeyS'],
[GamepadKey.LS_LEFT]: ['KeyA'],
[GamepadKey.LS_RIGHT]: ['KeyD'],
[GamepadKey.RS_UP]: ['KeyI'],
[GamepadKey.RS_DOWN]: ['KeyK'],
[GamepadKey.RS_LEFT]: ['KeyJ'],
[GamepadKey.RS_RIGHT]: ['KeyL'],
[GamepadKey.A]: ['Space', 'KeyE'],
[GamepadKey.X]: ['KeyR'],
[GamepadKey.B]: ['ControlLeft', 'Backspace'],
[GamepadKey.Y]: ['KeyV'],
[GamepadKey.START]: ['Enter'],
[GamepadKey.SELECT]: ['Tab'],
[GamepadKey.LB]: ['KeyC', 'KeyG'],
[GamepadKey.RB]: ['KeyQ'],
[GamepadKey.RT]: [MouseButtonCode.LEFT_CLICK],
[GamepadKey.LT]: [MouseButtonCode.RIGHT_CLICK],
[GamepadKey.L3]: ['ShiftLeft'],
[GamepadKey.R3]: ['KeyF'],
},
mouse: {
[MkbPresetKey.MOUSE_MAP_TO]: MouseMapTo.RS,
[MkbPresetKey.MOUSE_SENSITIVITY_X]: 100,
[MkbPresetKey.MOUSE_SENSITIVITY_Y]: 100,
[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT]: 20,
},
},
},
};
protected readonly DEFAULT_PRESET_ID = MkbMappingDefaultPresetId.DEFAULT;
private constructor() {
super(LocalDb.TABLE_VIRTUAL_CONTROLLERS);
BxLogger.info(this.LOG_TAG, 'constructor()');
}
}

View File

@@ -1,102 +0,0 @@
import { PrefKey } from "@/enums/pref-keys";
import { MkbPreset } from "@/modules/mkb/mkb-preset";
import type { MkbStoredPreset, MkbStoredPresets } from "@/types/mkb";
import { setPref } from "../settings-storages/global-settings-storage";
import { t } from "../translation";
import { LocalDb } from "./local-db";
import { BxLogger } from "../bx-logger";
export class MkbPresetsDb extends LocalDb {
private static instance: MkbPresetsDb;
public static getInstance = () => MkbPresetsDb.instance ?? (MkbPresetsDb.instance = new MkbPresetsDb());
private readonly LOG_TAG = 'MkbPresetsDb';
private readonly TABLE_PRESETS = 'mkb_presets';
private constructor() {
super();
BxLogger.info(this.LOG_TAG, 'constructor()');
}
private createTable(db: IDBDatabase) {
const presets = db.createObjectStore(this.TABLE_PRESETS, {
keyPath: 'id',
autoIncrement: true,
});
presets.createIndex('name_idx', 'name');
}
protected onUpgradeNeeded(e: IDBVersionChangeEvent): void {
const db = (e.target! as any).result as IDBDatabase;
if (db.objectStoreNames.contains('undefined')) {
db.deleteObjectStore('undefined');
}
if (!db.objectStoreNames.contains(this.TABLE_PRESETS)) {
this.createTable(db);
}
}
private async presetsTable() {
await this.open();
return await this.table(this.TABLE_PRESETS, 'readwrite');
}
async newPreset(name: string, data: any) {
const table = await this.presetsTable();
const [, id] = await this.add(table, { name, data });
return id;
}
async updatePreset(preset: MkbStoredPreset) {
const table = await this.presetsTable();
const [, id] = await this.put(table, preset);
return id;
}
async deletePreset(id: number) {
const table = await this.presetsTable();
await this.delete(table, id);
return id;
}
async getPreset(id: number): Promise<MkbStoredPreset> {
const table = await this.presetsTable();
const [, preset] = await this.get(table, id);
return preset;
}
async getPresets(): Promise<MkbStoredPresets> {
const table = await this.presetsTable();
const [, count] = await this.count(table);
// Return stored presets
if (count > 0) {
const [, items] = await this.getAll(table);
const presets: MkbStoredPresets = {};
items.forEach((item: MkbStoredPreset) => (presets[item.id!] = item));
return presets;
}
// Create "Default" preset when the table is empty
const preset: MkbStoredPreset = {
name: t('default'),
data: MkbPreset.DEFAULT_PRESET,
};
const [, id] = await this.add(table, preset);
preset.id = id;
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, id);
return {
[id]: preset,
};
}
}

23
src/utils/monkey-patches.ts Normal file → Executable file
View File

@@ -4,10 +4,12 @@ import { BxLogger } from "@utils/bx-logger";
import { patchSdpBitrate, setCodecPreferences } from "./sdp";
import { StreamPlayer, type StreamPlayerOptions } from "@/modules/stream-player";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
import { getPref, getPrefDefinition } from "./settings-storages/global-settings-storage";
import { CodecProfile } from "@/enums/pref-values";
import type { SettingDefinition } from "@/types/setting-definition";
export function patchVideoApi() {
const PREF_SKIP_SPLASH_VIDEO = getPref(PrefKey.SKIP_SPLASH_VIDEO);
const PREF_SKIP_SPLASH_VIDEO = getPref(PrefKey.UI_SKIP_SPLASH_VIDEO);
// Show video player when it's ready
const showFunc = function(this: HTMLVideoElement) {
@@ -57,7 +59,7 @@ export function patchVideoApi() {
export function patchRtcCodecs() {
const codecProfile = getPref(PrefKey.STREAM_CODEC_PROFILE);
const codecProfile = getPref<CodecProfile>(PrefKey.STREAM_CODEC_PROFILE);
if (codecProfile === 'default') {
return;
}
@@ -80,20 +82,21 @@ export function patchRtcPeerConnection() {
return dataChannel;
}
const maxVideoBitrate = getPref(PrefKey.BITRATE_VIDEO_MAX);
const codec = getPref(PrefKey.STREAM_CODEC_PROFILE);
const maxVideoBitrateDef = getPrefDefinition(PrefKey.STREAM_MAX_VIDEO_BITRATE) as Extract<SettingDefinition, { min: number }>;
const maxVideoBitrate = getPref<VideoMaxBitrate>(PrefKey.STREAM_MAX_VIDEO_BITRATE);
const codec = getPref<CodecProfile>(PrefKey.STREAM_CODEC_PROFILE);
if (codec !== 'default' || maxVideoBitrate > 0) {
if (codec !== CodecProfile.DEFAULT || maxVideoBitrate < maxVideoBitrateDef.max) {
const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription;
RTCPeerConnection.prototype.setLocalDescription = function(description?: RTCLocalSessionDescriptionInit): Promise<void> {
// Set preferred codec profile
if (codec !== 'default') {
if (codec !== CodecProfile.DEFAULT) {
arguments[0].sdp = setCodecPreferences(arguments[0].sdp, codec);
}
// set maximum bitrate
// Set maximum bitrate
try {
if (maxVideoBitrate > 0 && description) {
if (maxVideoBitrate < maxVideoBitrateDef.max && description) {
arguments[0].sdp = patchSdpBitrate(arguments[0].sdp, Math.round(maxVideoBitrate / 1000));
}
} catch (e) {
@@ -133,7 +136,7 @@ export function patchAudioContext() {
ctx.createGain = function() {
const gainNode = nativeCreateGain.apply(this);
gainNode.gain.value = getPref(PrefKey.AUDIO_VOLUME) / 100;
gainNode.gain.value = getPref<AudioVolume>(PrefKey.AUDIO_VOLUME) / 100;
STATES.currentStream.audioGainNode = gainNode;
return gainNode;

0
src/utils/navigation-utils.ts Normal file → Executable file
View File

10
src/utils/network.ts Normal file → Executable file
View File

@@ -66,10 +66,10 @@ function updateIceCandidates(candidates: any, options: {preferIpv6Server: boolea
const newCandidate = (candidate: string) => {
return {
'candidate': candidate,
'messageType': 'iceCandidate',
'sdpMLineIndex': '0',
'sdpMid': '0',
candidate: candidate,
messageType: 'iceCandidate',
sdpMLineIndex: '0',
sdpMid: '0',
};
};
@@ -105,7 +105,7 @@ export async function patchIceCandidates(request: Request, consoleAddrs?: Remote
}
const options = {
preferIpv6Server: getPref(PrefKey.PREFER_IPV6_SERVER),
preferIpv6Server: getPref(PrefKey.SERVER_PREFER_IPV6),
consoleAddrs: consoleAddrs,
};

16
src/utils/region.ts Normal file → Executable file
View File

@@ -4,17 +4,21 @@ import { getPref } from "./settings-storages/global-settings-storage";
export function getPreferredServerRegion(shortName = false): string | null {
let preferredRegion = getPref(PrefKey.SERVER_REGION);
if (preferredRegion in STATES.serverRegions) {
if (shortName && STATES.serverRegions[preferredRegion].shortName) {
return STATES.serverRegions[preferredRegion].shortName;
let preferredRegion = getPref<ServerRegionName>(PrefKey.SERVER_REGION);
const serverRegions = STATES.serverRegions;
// Return preferred region
if (preferredRegion in serverRegions) {
if (shortName && serverRegions[preferredRegion].shortName) {
return serverRegions[preferredRegion].shortName;
} else {
return preferredRegion;
}
}
for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName];
// Get default region
for (let regionName in serverRegions) {
const region = serverRegions[regionName];
if (!region.isDefault) {
continue;
}

2
src/utils/root-dialog-observer.ts Normal file → Executable file
View File

@@ -14,7 +14,6 @@ export class RootDialogObserver {
icon: BxIcon.CREATE_SHORTCUT,
label: t('create-shortcut'),
style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.NORMAL_CASE | ButtonStyle.NORMAL_LINK,
tabIndex: 0,
onClick: e => {
window.BX_EXPOSED.dialogRoutes?.closeAll();
@@ -27,7 +26,6 @@ export class RootDialogObserver {
icon: BxIcon.DOWNLOAD,
label: t('wallpaper'),
style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.NORMAL_CASE | ButtonStyle.NORMAL_LINK,
tabIndex: 0,
onClick: e => {
window.BX_EXPOSED.dialogRoutes?.closeAll();

2
src/utils/screenshot-manager.ts Normal file → Executable file
View File

@@ -1,9 +1,9 @@
import { StreamPlayerType } from "@enums/stream-player";
import { AppInterface, STATES } from "./global";
import { CE } from "./html";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
import { BxLogger } from "./bx-logger";
import { StreamPlayerType } from "@/enums/pref-values";
export class ScreenshotManager {

0
src/utils/sdp.ts Normal file → Executable file
View File

277
src/utils/setting-element.ts Normal file → Executable file
View File

@@ -1,15 +1,14 @@
import type { PreferenceSetting } from "@/types/preferences";
import { CE } from "@utils/html";
import { setNearby } from "./navigation-utils";
import { CE, escapeCssSelector } from "@utils/html";
import type { PrefKey } from "@/enums/pref-keys";
import type { BaseSettingsStore } from "./settings-storages/base-settings-storage";
import { type MultipleOptionsParams, type NumberStepperParams } from "@/types/setting-definition";
import { type BaseSettingDefinition, type MultipleOptionsParams, type NumberStepperParams } from "@/types/setting-definition";
import { BxEvent } from "./bx-event";
import { BxNumberStepper } from "@/web-components/bx-number-stepper";
export enum SettingElementType {
OPTIONS = 'options',
MULTIPLE_OPTIONS = 'multiple-options',
NUMBER = 'number',
NUMBER_STEPPER = 'number-stepper',
CHECKBOX = 'checkbox',
}
@@ -23,7 +22,7 @@ export interface BxHtmlSettingElement extends HTMLElement, BxBaseSettingElement
export interface BxSelectSettingElement extends HTMLSelectElement, BxBaseSettingElement {}
export class SettingElement {
static #renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any): BxSelectSettingElement {
private static renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any): BxSelectSettingElement {
const $control = CE<BxSelectSettingElement>('select', {
// title: setting.label,
tabindex: 0,
@@ -62,16 +61,15 @@ export class SettingElement {
return $control;
}
static #renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}): BxSelectSettingElement {
private static renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}): BxSelectSettingElement {
const $control = CE<BxSelectSettingElement>('select', {
// title: setting.label,
multiple: true,
tabindex: 0,
});
if (params && params.size) {
$control.setAttribute('size', params.size.toString());
}
const size = params.size ? params.size : Object.keys(setting.multipleOptions!).length;
$control.setAttribute('size', size.toString());
for (let value in setting.multipleOptions) {
const label = setting.multipleOptions[value];
@@ -111,29 +109,8 @@ export class SettingElement {
return $control;
}
static #renderNumber(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) {
const $control = CE<HTMLInputElement>('input', {
tabindex: 0,
type: 'number',
min: setting.min,
max: setting.max,
});
$control.value = currentValue;
onChange && $control.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
const value = Math.max(setting.min!, Math.min(setting.max!, parseInt(target.value)));
target.value = value.toString();
!(e as any).ignoreOnChange && onChange(e, value);
});
return $control;
}
static #renderCheckbox(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) {
const $control = CE('input', {'type': 'checkbox', 'tabindex': 0}) as HTMLInputElement;
private static renderCheckbox(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) {
const $control = CE('input', {type: 'checkbox', tabindex: 0}) as HTMLInputElement;
$control.checked = currentValue;
onChange && $control.addEventListener('input', e => {
@@ -147,233 +124,25 @@ export class SettingElement {
return $control;
}
static #renderNumberStepper(key: string, setting: PreferenceSetting, value: any, onChange: any, options: NumberStepperParams={}) {
options = options || {};
options.suffix = options.suffix || '';
options.disabled = !!options.disabled;
options.hideSlider = !!options.hideSlider;
let $text: HTMLSpanElement;
let $btnDec: HTMLButtonElement;
let $btnInc: HTMLButtonElement;
let $range: HTMLInputElement | null = null;
let controlValue = value;
const MIN = options.reverse ? -setting.max! : setting.min!;
const MAX = options.reverse ? -setting.min! : setting.max!;
const STEPS = Math.max(setting.steps || 1, 1);
let intervalId: number | null;
let isHolding = false;
const clearIntervalId = () => {
intervalId && clearInterval(intervalId);
intervalId = null;
}
const renderTextValue = (value: any) => {
value = parseInt(value as string);
let textContent = null;
if (options.customTextValue) {
textContent = options.customTextValue(value);
}
if (textContent === null) {
textContent = value.toString() + options.suffix;
}
return textContent;
};
const updateButtonsVisibility = () => {
$btnDec.classList.toggle('bx-inactive', controlValue === MIN);
$btnInc.classList.toggle('bx-inactive', controlValue === MAX);
if (controlValue === MIN || controlValue === MAX) {
clearIntervalId();
}
}
const $wrapper = CE<BxHtmlSettingElement>('div', {'class': 'bx-number-stepper', id: `bx_setting_${key}`},
$btnDec = CE('button', {
'data-type': 'dec',
type: 'button',
class: options.hideSlider ? 'bx-focusable' : '',
tabindex: options.hideSlider ? 0 : -1,
}, '-') as HTMLButtonElement,
$text = CE('span', {}, renderTextValue(value)) as HTMLSpanElement,
$btnInc = CE('button', {
'data-type': 'inc',
type: 'button',
class: options.hideSlider ? 'bx-focusable' : '',
tabindex: options.hideSlider ? 0 : -1,
}, '+') as HTMLButtonElement,
);
if (options.disabled) {
$btnInc.disabled = true;
$btnInc.classList.add('bx-inactive');
$btnDec.disabled = true;
$btnDec.classList.add('bx-inactive');
($wrapper as any).disabled = true;
return $wrapper;
}
$range = CE<HTMLInputElement>('input', {
id: `bx_inp_setting_${key}`,
type: 'range',
min: MIN,
max: MAX,
value: options.reverse ? -value : value,
step: STEPS,
tabindex: 0,
});
options.hideSlider && $range.classList.add('bx-gone');
$range.addEventListener('input', e => {
value = parseInt((e.target as HTMLInputElement).value);
if (options.reverse) {
value *= -1;
}
const valueChanged = controlValue !== value;
if (!valueChanged) {
return;
}
controlValue = options.reverse ? -value : value;
updateButtonsVisibility();
$text.textContent = renderTextValue(value);
!(e as any).ignoreOnChange && onChange && onChange(e, value);
});
$wrapper.addEventListener('input', e => {
BxEvent.dispatch($range, 'input');
});
$wrapper.appendChild($range);
if (options.ticks || options.exactTicks) {
const markersId = `markers-${key}`;
const $markers = CE('datalist', {id: markersId});
$range.setAttribute('list', markersId);
if (options.exactTicks) {
let start = Math.max(Math.floor(setting.min! / options.exactTicks), 1) * options.exactTicks;
if (start === setting.min!) {
start += options.exactTicks;
}
for (let i = start; i < setting.max!; i += options.exactTicks) {
$markers.appendChild(CE<HTMLOptionElement>('option', {
value: options.reverse ? -i : i,
}));
}
} else {
for (let i = MIN + options.ticks!; i < MAX; i += options.ticks!) {
$markers.appendChild(CE<HTMLOptionElement>('option', {value: i}));
}
}
$wrapper.appendChild($markers);
}
updateButtonsVisibility();
const buttonPressed = (e: Event, $btn: HTMLElement) => {
let value = parseInt(controlValue);
const btnType = $btn.dataset.type;
if (btnType === 'dec') {
value = Math.max(MIN, value - STEPS);
} else {
value = Math.min(MAX, value + STEPS);
}
controlValue = value;
updateButtonsVisibility();
$text.textContent = renderTextValue(value);
$range && ($range.value = value.toString());
onChange && onChange(e, value);
};
const onClick = (e: Event) => {
e.preventDefault();
if (isHolding) {
return;
}
const $btn = (e.target as HTMLElement).closest('button') as HTMLElement;
$btn && buttonPressed(e, $btn);
clearIntervalId();
isHolding = false;
};
const onPointerDown = (e: PointerEvent) => {
clearIntervalId();
const $btn = (e.target as HTMLElement).closest('button') as HTMLElement;
if (!$btn) {
return;
}
isHolding = true;
e.preventDefault();
intervalId = window.setInterval((e: Event) => {
buttonPressed(e, $btn);
}, 200);
window.addEventListener('pointerup', onPointerUp, {once: true});
window.addEventListener('pointercancel', onPointerUp, {once: true});
};
const onPointerUp = (e: PointerEvent) => {
clearIntervalId();
isHolding = false;
};
const onContextMenu = (e: Event) => e.preventDefault();
// Custom method
$wrapper.setValue = (value: any) => {
$text.textContent = renderTextValue(value);
$range.value = options.reverse ? -value : value;
};
$wrapper.addEventListener('click', onClick);
$wrapper.addEventListener('pointerdown', onPointerDown);
$wrapper.addEventListener('contextmenu', onContextMenu);
setNearby($wrapper, {
focus: options.hideSlider ? $btnInc : $range,
})
return $wrapper;
private static renderNumberStepper(key: string, setting: PreferenceSetting, value: any, onChange: any, options: NumberStepperParams={}) {
const $control = BxNumberStepper.create(key, value, setting.min!, setting.max!, options, onChange);
return $control;
}
static #METHOD_MAP = {
[SettingElementType.OPTIONS]: SettingElement.#renderOptions,
[SettingElementType.MULTIPLE_OPTIONS]: SettingElement.#renderMultipleOptions,
[SettingElementType.NUMBER]: SettingElement.#renderNumber,
[SettingElementType.NUMBER_STEPPER]: SettingElement.#renderNumberStepper,
[SettingElementType.CHECKBOX]: SettingElement.#renderCheckbox,
private static readonly METHOD_MAP = {
[SettingElementType.OPTIONS]: SettingElement.renderOptions,
[SettingElementType.MULTIPLE_OPTIONS]: SettingElement.renderMultipleOptions,
[SettingElementType.NUMBER_STEPPER]: SettingElement.renderNumberStepper,
[SettingElementType.CHECKBOX]: SettingElement.renderCheckbox,
};
static render(type: SettingElementType, key: string, setting: PreferenceSetting, currentValue: any, onChange: any, options: any) {
const method = SettingElement.#METHOD_MAP[type];
static render(type: SettingElementType, key: string, setting: BaseSettingDefinition, currentValue: any, onChange: any, options: any) {
const method = SettingElement.METHOD_MAP[type];
// @ts-ignore
const $control = method(...Array.from(arguments).slice(1)) as HTMLElement;
if (type !== SettingElementType.NUMBER_STEPPER) {
$control.id = `bx_setting_${key}`;
$control.id = `bx_setting_${escapeCssSelector(key)}`;
}
// Add "name" property to "select" elements
@@ -389,14 +158,12 @@ export class SettingElement {
let currentValue = storage.getSetting(key);
let type;
if ('type' in definition) {
type = definition.type;
} else if ('options' in definition) {
if ('options' in definition) {
type = SettingElementType.OPTIONS;
} else if ('multipleOptions' in definition) {
type = SettingElementType.MULTIPLE_OPTIONS;
} else if (typeof definition.default === 'number') {
type = SettingElementType.NUMBER;
type = SettingElementType.NUMBER_STEPPER;
} else {
type = SettingElementType.CHECKBOX;
}

59
src/utils/settings-storages/base-settings-storage.ts Normal file → Executable file
View File

@@ -1,7 +1,6 @@
import type { PrefKey } from "@/enums/pref-keys";
import type { NumberStepperParams, SettingDefinitions } from "@/types/setting-definition";
import type { NumberStepperParams, SettingAction, SettingDefinitions } from "@/types/setting-definition";
import { BxEvent } from "../bx-event";
import { SettingElementType } from "../setting-element";
import { t } from "../translation";
import { SCRIPT_VARIANT } from "../global";
@@ -43,6 +42,12 @@ export class BaseSettingsStore {
}
const settings = JSON.parse(this.storage.getItem(this.storageKey) || '{}');
// Validate setting values
for (const key in settings) {
settings[key] = this.validateValue('get', key as PrefKey, settings[key]);
}
this._settings = settings;
return settings;
@@ -58,35 +63,34 @@ export class BaseSettingsStore {
return this.definitions[key];
}
getSetting(key: PrefKey, checkUnsupported = true) {
if (typeof key === 'undefined') {
debugger;
return;
}
getSetting<T=boolean>(key: PrefKey, checkUnsupported = true): T {
const definition = this.definitions[key];
// Return default value if build variant is different
if (definition.requiredVariants && !definition.requiredVariants.includes(SCRIPT_VARIANT)) {
return definition.default;
return definition.default as T;
}
// Return default value if the feature is not supported
if (checkUnsupported && definition.unsupported) {
return definition.default;
if ('unsupportedValue' in definition) {
return definition.unsupportedValue as T;
} else {
return definition.default as T;
}
}
if (!(key in this.settings)) {
this.settings[key] = this.validateValue(key, null);
this.settings[key] = this.validateValue('get', key, null);
}
return this.settings[key];
return this.settings[key] as T;
}
setSetting(key: PrefKey, value: any, emitEvent = false) {
value = this.validateValue(key, value);
setSetting<T=any>(key: PrefKey, value: T, emitEvent = false) {
value = this.validateValue('set', key, value);
this.settings[key] = value;
this.settings[key] = this.validateValue('get', key, value);
this.saveSettings();
emitEvent && BxEvent.dispatch(window, BxEvent.SETTINGS_CHANGED, {
@@ -102,7 +106,7 @@ export class BaseSettingsStore {
this.storage.setItem(this.storageKey, JSON.stringify(this.settings));
}
private validateValue(key: PrefKey, value: any) {
private validateValue(action: SettingAction, key: PrefKey, value: any) {
const def = this.definitions[key];
if (!def) {
return value;
@@ -112,6 +116,11 @@ export class BaseSettingsStore {
value = def.default;
}
// Transform value before validating
if (def.transformValue && action === 'get') {
value = def.transformValue.get.call(def, value);
}
if ('min' in def) {
value = Math.max(def.min!, value);
}
@@ -120,8 +129,10 @@ export class BaseSettingsStore {
value = Math.min(def.max!, value);
}
if ('options' in def && !(value in def.options!)) {
value = def.default;
if ('options' in def) {
if (!(value in def.options)) {
value = def.default;
}
} else if ('multipleOptions' in def) {
if (value.length) {
const validOptions = Object.keys(def.multipleOptions!);
@@ -135,6 +146,11 @@ export class BaseSettingsStore {
}
}
// Transform value before setting
if (def.transformValue && action === 'set') {
value = def.transformValue.set.call(def, value);
}
return value;
}
@@ -144,10 +160,13 @@ export class BaseSettingsStore {
getValueText(key: PrefKey, value: any): string {
const definition = this.definitions[key];
if (definition.type === SettingElementType.NUMBER_STEPPER) {
if ('min' in definition) {
const params = (definition as any).params as NumberStepperParams;
if (params.customTextValue) {
const text = params.customTextValue(value);
if (definition.transformValue) {
value = definition.transformValue.get.call(definition, value);
}
const text = params.customTextValue(value, definition.min, definition.max);
if (text) {
return text;
}

363
src/utils/settings-storages/global-settings-storage.ts Normal file → Executable file
View File

@@ -1,7 +1,5 @@
import { BypassServers } from "@/enums/bypass-servers";
import { PrefKey, StorageKey } from "@/enums/pref-keys";
import { StreamPlayerType, StreamVideoProcessing } from "@/enums/stream-player";
import { UiSection } from "@/enums/ui-sections";
import { UserAgentProfile } from "@/enums/user-agent";
import { type SettingDefinition, type SettingDefinitions } from "@/types/setting-definition";
import { BX_FLAGS } from "../bx-flags";
@@ -10,37 +8,11 @@ import { CE } from "../html";
import { t, SUPPORTED_LANGUAGES } from "../translation";
import { UserAgent } from "../user-agent";
import { BaseSettingsStore as BaseSettingsStorage } from "./base-settings-storage";
import { SettingElementType } from "../setting-element";
import { StreamStat } from "../stream-stats-collector";
export const enum StreamResolution {
DIM_720P = '720p',
DIM_1080P = '1080p',
}
export const enum CodecProfile {
DEFAULT = 'default',
LOW = 'low',
NORMAL = 'normal',
HIGH = 'high',
};
export const enum StreamTouchController {
DEFAULT = 'default',
ALL = 'all',
OFF = 'off',
}
export const enum ControllerDeviceVibration {
ON = 'on',
AUTO = 'auto',
OFF = 'off',
}
export type GameBarPosition = 'bottom-left' | 'bottom-right' | 'off';
export type GameBarPositionOptions = Record<GameBarPosition, string>;
import { CodecProfile, StreamResolution, TouchControllerMode, TouchControllerStyleStandard, TouchControllerStyleCustom, GameBarPosition, DeviceVibrationMode, NativeMkbMode, UiLayout, UiSection, StreamPlayerType, StreamVideoProcessing, VideoRatio, StreamStat } from "@/enums/pref-values";
import { MkbMappingDefaultPresetId } from "../local-db/mkb-mapping-presets-table";
import { KeyboardShortcutDefaultId } from "../local-db/keyboard-shortcuts-table";
import { GhPagesUtils } from "../gh-pages";
import { BxEvent } from "../bx-event";
function getSupportedCodecProfiles() {
@@ -101,18 +73,18 @@ function getSupportedCodecProfiles() {
export class GlobalSettingsStorage extends BaseSettingsStorage {
private static readonly DEFINITIONS = {
[PrefKey.LAST_UPDATE_CHECK]: {
[PrefKey.VERSION_LAST_CHECK]: {
default: 0,
},
[PrefKey.LATEST_VERSION]: {
[PrefKey.VERSION_LATEST]: {
default: '',
},
[PrefKey.CURRENT_VERSION]: {
[PrefKey.VERSION_CURRENT]: {
default: '',
},
[PrefKey.BETTER_XCLOUD_LOCALE]: {
[PrefKey.SCRIPT_LOCALE]: {
label: t('language'),
default: localStorage.getItem('better_xcloud_locale') || 'en-US',
default: localStorage.getItem(StorageKey.LOCALE) || 'en-US',
options: SUPPORTED_LANGUAGES,
},
[PrefKey.SERVER_REGION]: {
@@ -167,22 +139,24 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
'zh-TW': '中文 (繁體)',
},
},
[PrefKey.STREAM_TARGET_RESOLUTION]: {
[PrefKey.STREAM_RESOLUTION]: {
label: t('target-resolution'),
default: 'auto',
options: {
auto: t('default'),
[StreamResolution.DIM_720P]: '720p',
[StreamResolution.DIM_1080P]: '1080p',
[StreamResolution.DIM_1080P_HQ]: '1080p (HQ)',
},
suggest: {
lowest: StreamResolution.DIM_720P,
highest: StreamResolution.DIM_1080P,
highest: StreamResolution.DIM_1080P_HQ,
},
},
[PrefKey.STREAM_CODEC_PROFILE]: {
label: t('visual-quality'),
default: 'default',
default: CodecProfile.DEFAULT,
options: getSupportedCodecProfiles(),
ready: (setting: SettingDefinition) => {
const options = (setting as any).options;
@@ -199,7 +173,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
};
},
},
[PrefKey.PREFER_IPV6_SERVER]: {
[PrefKey.SERVER_PREFER_IPV6]: {
label: t('prefer-ipv6-server'),
default: false,
},
@@ -210,11 +184,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
default: false,
},
[PrefKey.SKIP_SPLASH_VIDEO]: {
[PrefKey.UI_SKIP_SPLASH_VIDEO]: {
label: t('skip-splash-video'),
default: false,
},
[PrefKey.HIDE_DOTS_ICON]: {
[PrefKey.UI_HIDE_SYSTEM_MENU_ICON]: {
label: t('hide-system-menu-icon'),
default: false,
},
@@ -228,66 +202,62 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
note: t('combine-audio-video-streams-summary'),
},
[PrefKey.STREAM_TOUCH_CONTROLLER]: {
[PrefKey.TOUCH_CONTROLLER_MODE]: {
requiredVariants: 'full',
label: t('tc-availability'),
default: StreamTouchController.ALL,
default: TouchControllerMode.ALL,
options: {
[StreamTouchController.DEFAULT]: t('default'),
[StreamTouchController.ALL]: t('tc-all-games'),
[StreamTouchController.OFF]: t('off'),
[TouchControllerMode.DEFAULT]: t('default'),
[TouchControllerMode.OFF]: t('off'),
[TouchControllerMode.ALL]: t('tc-all-games'),
},
unsupported: !STATES.userAgent.capabilities.touch,
ready: (setting: SettingDefinition) => {
if (setting.unsupported) {
setting.default = StreamTouchController.DEFAULT;
}
},
unsupportedValue: TouchControllerMode.DEFAULT,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: {
[PrefKey.TOUCH_CONTROLLER_AUTO_OFF]: {
requiredVariants: 'full',
label: t('tc-auto-off'),
default: false,
unsupported: !STATES.userAgent.capabilities.touch,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: {
[PrefKey.TOUCH_CONTROLLER_DEFAULT_OPACITY]: {
requiredVariants: 'full',
type: SettingElementType.NUMBER_STEPPER,
label: t('tc-default-opacity'),
default: 100,
min: 10,
max: 100,
steps: 10,
params: {
steps: 10,
suffix: '%',
ticks: 10,
hideSlider: true,
},
unsupported: !STATES.userAgent.capabilities.touch,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: {
[PrefKey.TOUCH_CONTROLLER_STYLE_STANDARD]: {
requiredVariants: 'full',
label: t('tc-standard-layout-style'),
default: 'default',
default: TouchControllerStyleStandard.DEFAULT,
options: {
default: t('default'),
white: t('tc-all-white'),
muted: t('tc-muted-colors'),
[TouchControllerStyleStandard.DEFAULT]: t('default'),
[TouchControllerStyleStandard.WHITE]: t('tc-all-white'),
[TouchControllerStyleStandard.MUTED]: t('tc-muted-colors'),
},
unsupported: !STATES.userAgent.capabilities.touch,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: {
[PrefKey.TOUCH_CONTROLLER_STYLE_CUSTOM]: {
requiredVariants: 'full',
label: t('tc-custom-layout-style'),
default: 'default',
default: TouchControllerStyleCustom.DEFAULT,
options: {
default: t('default'),
muted: t('tc-muted-colors'),
[TouchControllerStyleCustom.DEFAULT]: t('default'),
[TouchControllerStyleCustom.MUTED]: t('tc-muted-colors'),
},
unsupported: !STATES.userAgent.capabilities.touch,
},
[PrefKey.STREAM_SIMPLIFY_MENU]: {
[PrefKey.UI_SIMPLIFY_STREAM_MENU]: {
label: t('simplify-stream-menu'),
default: false,
},
@@ -296,27 +266,35 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
label: t('hide-idle-cursor'),
default: false,
},
[PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG]: {
[PrefKey.UI_DISABLE_FEEDBACK_DIALOG]: {
requiredVariants: 'full',
label: t('disable-post-stream-feedback-dialog'),
default: false,
},
[PrefKey.BITRATE_VIDEO_MAX]: {
[PrefKey.STREAM_MAX_VIDEO_BITRATE]: {
requiredVariants: 'full',
type: SettingElementType.NUMBER_STEPPER,
label: t('bitrate-video-maximum'),
note: '⚠️ ' + t('unexpected-behavior'),
default: 0,
min: 0,
max: 14 * 1024 * 1000,
steps: 100 * 1024,
min: 1024 * 100,
max: 15 * 1024 * 1000,
transformValue: {
get(value) {
return value === 0 ? this.max : value;
},
set(value) {
return value === this.max ? 0 : value;
},
},
params: {
exactTicks: 5 * 1024 * 1000,
customTextValue: (value: any) => {
steps: 100 * 1024,
exactTicks: 5 * 1024 * 1000,
customTextValue: (value: any, min, max) => {
value = parseInt(value);
if (value === 0) {
if (value === max) {
return t('unlimited');
} else {
return (value / (1024 * 1000)).toFixed(1) + ' Mb/s';
@@ -331,12 +309,12 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
[PrefKey.GAME_BAR_POSITION]: {
requiredVariants: 'full',
label: t('position'),
default: 'bottom-left' satisfies GameBarPosition,
default: GameBarPosition.BOTTOM_LEFT,
options: {
'bottom-left': t('bottom-left'),
'bottom-right': t('bottom-right'),
'off': t('off'),
} satisfies GameBarPositionOptions,
[GameBarPosition.OFF]: t('off'),
[GameBarPosition.BOTTOM_LEFT]: t('bottom-left'),
[GameBarPosition.BOTTOM_RIGHT]: t('bottom-right'),
},
},
[PrefKey.LOCAL_CO_OP_ENABLED]: {
@@ -349,58 +327,43 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
}, t('enable-local-co-op-support-note')),
},
/*
[Preferences.LOCAL_CO_OP_SEPARATE_TOUCH_CONTROLLER]: {
default: false,
'note': t('separate-touch-controller-note'),
},
*/
[PrefKey.CONTROLLER_SHOW_CONNECTION_STATUS]: {
[PrefKey.UI_CONTROLLER_SHOW_STATUS]: {
label: t('show-controller-connection-status'),
default: true,
},
[PrefKey.CONTROLLER_ENABLE_VIBRATION]: {
requiredVariants: 'full',
label: t('controller-vibration'),
default: true,
},
[PrefKey.CONTROLLER_DEVICE_VIBRATION]: {
[PrefKey.DEVICE_VIBRATION_MODE]: {
requiredVariants: 'full',
label: t('device-vibration'),
default: ControllerDeviceVibration.OFF,
default: DeviceVibrationMode.OFF,
options: {
[ControllerDeviceVibration.ON]: t('on'),
[ControllerDeviceVibration.AUTO]: t('device-vibration-not-using-gamepad'),
[ControllerDeviceVibration.OFF]: t('off'),
[DeviceVibrationMode.OFF]: t('off'),
[DeviceVibrationMode.ON]: t('on'),
[DeviceVibrationMode.AUTO]: t('device-vibration-not-using-gamepad'),
},
},
[PrefKey.CONTROLLER_VIBRATION_INTENSITY]: {
[PrefKey.DEVICE_VIBRATION_INTENSITY]: {
requiredVariants: 'full',
label: t('vibration-intensity'),
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 0,
default: 50,
min: 10,
max: 100,
steps: 10,
params: {
steps: 10,
suffix: '%',
ticks: 10,
exactTicks: 20,
},
},
[PrefKey.CONTROLLER_POLLING_RATE]: {
requiredVariants: 'full',
label: t('polling-rate'),
type: SettingElementType.NUMBER_STEPPER,
default: 4,
min: 4,
max: 60,
steps: 4,
params: {
steps: 4,
exactTicks: 20,
reverse: true,
customTextValue(value: any) {
@@ -420,7 +383,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
requiredVariants: 'full',
label: t('enable-mkb'),
default: false,
unsupported: !STATES.userAgent.capabilities.mkb,
unsupported: !STATES.userAgent.capabilities.mkb || !STATES.browser.capabilities.mkb,
ready: (setting: SettingDefinition) => {
let note;
let url;
@@ -439,24 +402,39 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
},
[PrefKey.NATIVE_MKB_ENABLED]: {
[PrefKey.NATIVE_MKB_MODE]: {
requiredVariants: 'full',
label: t('native-mkb'),
default: 'default',
default: NativeMkbMode.DEFAULT,
options: {
default: t('default'),
on: t('on'),
off: t('off'),
[NativeMkbMode.DEFAULT]: t('default'),
[NativeMkbMode.OFF]: t('off'),
[NativeMkbMode.ON]: t('on'),
},
ready: (setting: SettingDefinition) => {
if (AppInterface) {
if (STATES.browser.capabilities.emulatedNativeMkb) {
} else if (UserAgent.isMobile()) {
setting.unsupported = true;
setting.default = 'off';
delete (setting as any).options['default'];
delete (setting as any).options['on'];
setting.unsupportedValue = NativeMkbMode.OFF;
delete (setting as any).options[NativeMkbMode.DEFAULT];
delete (setting as any).options[NativeMkbMode.ON];
} else {
delete (setting as any).options['on'];
delete (setting as any).options[NativeMkbMode.ON];
}
},
},
[PrefKey.FORCE_NATIVE_MKB_GAMES]: {
label: t('force-native-mkb-games'),
default: [],
unsupported: !AppInterface && UserAgent.isMobile(),
ready: (setting: SettingDefinition) => {
if (!setting.unsupported) {
(setting as any).multipleOptions = GhPagesUtils.getNativeMkbCustomList(true);
window.addEventListener(BxEvent.GH_PAGES_FORCE_NATIVE_MKB_UPDATED, e => {
(setting as any).multipleOptions = GhPagesUtils.getNativeMkbCustomList();
});
}
},
},
@@ -464,12 +442,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
[PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY]: {
requiredVariants: 'full',
label: t('horizontal-scroll-sensitivity'),
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 100 * 100,
steps: 10,
params: {
steps: 10,
exactTicks: 20 * 100,
customTextValue: (value: any) => {
if (!value) {
@@ -484,12 +461,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
[PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY]: {
requiredVariants: 'full',
label: t('vertical-scroll-sensitivity'),
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 100 * 100,
steps: 10,
params: {
steps: 10,
exactTicks: 20 * 100,
customTextValue: (value: any) => {
if (!value) {
@@ -501,31 +477,60 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
},
[PrefKey.MKB_DEFAULT_PRESET_ID]: {
[PrefKey.MKB_P1_MAPPING_PRESET_ID]: {
requiredVariants: 'full',
default: MkbMappingDefaultPresetId.DEFAULT,
},
[PrefKey.MKB_P1_SLOT]: {
requiredVariants: 'full',
default: 1,
min: 1,
max: 4,
params: {
hideSlider: true,
},
},
[PrefKey.MKB_P2_MAPPING_PRESET_ID]: {
requiredVariants: 'full',
default: MkbMappingDefaultPresetId.OFF,
},
[PrefKey.MKB_P2_SLOT]: {
requiredVariants: 'full',
default: 0,
min: 0,
max: 4,
params: {
hideSlider: true,
customTextValue(value) {
value = parseInt(value);
return (value === 0) ? t('off') : value.toString();
},
},
},
[PrefKey.MKB_ABSOLUTE_MOUSE]: {
[PrefKey.KEYBOARD_SHORTCUTS_IN_GAME_PRESET_ID]: {
requiredVariants: 'full',
default: false,
default: KeyboardShortcutDefaultId.DEFAULT,
},
[PrefKey.REDUCE_ANIMATIONS]: {
[PrefKey.UI_REDUCE_ANIMATIONS]: {
label: t('reduce-animations'),
default: false,
},
[PrefKey.UI_LOADING_SCREEN_GAME_ART]: {
[PrefKey.LOADING_SCREEN_GAME_ART]: {
requiredVariants: 'full',
label: t('show-game-art'),
default: true,
},
[PrefKey.UI_LOADING_SCREEN_WAIT_TIME]: {
[PrefKey.LOADING_SCREEN_SHOW_WAIT_TIME]: {
label: t('show-wait-time'),
default: true,
},
[PrefKey.UI_LOADING_SCREEN_ROCKET]: {
[PrefKey.LOADING_SCREEN_ROCKET]: {
label: t('rocket-animation'),
default: 'show',
options: {
@@ -543,11 +548,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
[PrefKey.UI_LAYOUT]: {
requiredVariants: 'full',
label: t('layout'),
default: 'default',
default: UiLayout.DEFAULT,
options: {
default: t('default'),
normal: t('normal'),
tv: t('smart-tv'),
[UiLayout.DEFAULT]: t('default'),
[UiLayout.NORMAL]: t('normal'),
[UiLayout.TV]: t('smart-tv'),
},
},
@@ -565,18 +570,24 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
[UiSection.FRIENDS]: t('section-play-with-friends'),
[UiSection.NATIVE_MKB]: t('section-native-mkb'),
[UiSection.TOUCH]: t('section-touch'),
// [UiSection.BOYG]: t('section-byog'),
[UiSection.MOST_POPULAR]: t('section-most-popular'),
[UiSection.ALL_GAMES]: t('section-all-games'),
},
params: {
size: 6,
size: 0,
},
},
[PrefKey.BYOG_DISABLED]: {
label: t('disable-byog-feature'),
default: false,
},
[PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME]: {
requiredVariants: 'full',
label: t('show-wait-time-in-game-card'),
default: false,
default: true,
},
[PrefKey.BLOCK_SOCIAL_FEATURES]: {
@@ -603,7 +614,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.VIDEO_PLAYER_TYPE]: {
label: t('renderer'),
default: 'default',
default: StreamPlayerType.VIDEO,
options: {
[StreamPlayerType.VIDEO]: t('default'),
[StreamPlayerType.WEBGL2]: t('webgl2'),
@@ -629,7 +640,7 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
label: t('renderer-configuration'),
default: 'default',
options: {
'default': t('default'),
default: t('default'),
'low-power': t('battery-saving'),
'high-performance': t('high-performance'),
},
@@ -639,12 +650,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.VIDEO_MAX_FPS]: {
label: t('max-fps'),
type: SettingElementType.NUMBER_STEPPER,
default: 60,
min: 10,
max: 60,
steps: 10,
params: {
steps: 10,
exactTicks: 10,
customTextValue: (value: any) => {
value = parseInt(value);
@@ -654,7 +664,6 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.VIDEO_SHARPNESS]: {
label: t('sharpness'),
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 10,
@@ -673,21 +682,20 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
[PrefKey.VIDEO_RATIO]: {
label: t('aspect-ratio'),
note: t('aspect-ratio-note'),
default: '16:9',
default: VideoRatio['16:9'],
options: {
'16:9': '16:9',
'18:9': '18:9',
'21:9': '21:9',
'16:10': '16:10',
'4:3': '4:3',
[VideoRatio['16:9']]: '16:9',
[VideoRatio['18:9']]: '18:9',
[VideoRatio['21:9']]: '21:9',
[VideoRatio['16:10']]: '16:10',
[VideoRatio['4:3']]: '4:3',
fill: t('stretch'),
[VideoRatio.FILL]: t('stretch'),
//'cover': 'Cover',
},
},
[PrefKey.VIDEO_SATURATION]: {
label: t('saturation'),
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 50,
max: 150,
@@ -698,7 +706,6 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.VIDEO_CONTRAST]: {
label: t('contrast'),
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 50,
max: 150,
@@ -709,7 +716,6 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.VIDEO_BRIGHTNESS]: {
label: t('brightness'),
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 50,
max: 150,
@@ -723,19 +729,18 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
label: t('enable-mic-on-startup'),
default: false,
},
[PrefKey.AUDIO_ENABLE_VOLUME_CONTROL]: {
[PrefKey.AUDIO_VOLUME_CONTROL_ENABLED]: {
requiredVariants: 'full',
label: t('enable-volume-control'),
default: false,
},
[PrefKey.AUDIO_VOLUME]: {
label: t('volume'),
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 0,
max: 600,
steps: 10,
params: {
steps: 10,
suffix: '%',
ticks: 100,
},
@@ -746,21 +751,21 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
label: t('stats'),
default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST],
multipleOptions: {
[StreamStat.CLOCK]: `${StreamStat.CLOCK.toUpperCase()}: ${t('clock')}`,
[StreamStat.PLAYTIME]: `${StreamStat.PLAYTIME.toUpperCase()}: ${t('playtime')}`,
[StreamStat.BATTERY]: `${StreamStat.BATTERY.toUpperCase()}: ${t('battery')}`,
[StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`,
[StreamStat.JITTER]: `${StreamStat.JITTER.toUpperCase()}: ${t('jitter')}`,
[StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`,
[StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`,
[StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`,
[StreamStat.PACKETS_LOST]: `${StreamStat.PACKETS_LOST.toUpperCase()}: ${t('stat-packets-lost')}`,
[StreamStat.FRAMES_LOST]: `${StreamStat.FRAMES_LOST.toUpperCase()}: ${t('stat-frames-lost')}`,
[StreamStat.DOWNLOAD]: `${StreamStat.DOWNLOAD.toUpperCase()}: ${t('downloaded')}`,
[StreamStat.UPLOAD]: `${StreamStat.UPLOAD.toUpperCase()}: ${t('uploaded')}`,
[StreamStat.CLOCK]: t('clock'),
[StreamStat.PLAYTIME]: t('playtime'),
[StreamStat.BATTERY]: t('battery'),
[StreamStat.PING]: t('stat-ping'),
[StreamStat.JITTER]: t('jitter'),
[StreamStat.FPS]: t('stat-fps'),
[StreamStat.BITRATE]: t('stat-bitrate'),
[StreamStat.DECODE_TIME]: t('stat-decode-time'),
[StreamStat.PACKETS_LOST]: t('stat-packets-lost'),
[StreamStat.FRAMES_LOST]: t('stat-frames-lost'),
[StreamStat.DOWNLOAD]: t('downloaded'),
[StreamStat.UPLOAD]: t('uploaded'),
},
params: {
size: 6,
size: 0,
},
ready: setting => {
// Remove Battery option in unsupported browser
@@ -768,13 +773,18 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
if (!STATES.browser.capabilities.batteryApi) {
delete multipleOptions[StreamStat.BATTERY];
}
// Update texts
for (const key in multipleOptions) {
multipleOptions[key] = (key as string).toUpperCase() + ': ' + multipleOptions[key];
}
},
},
[PrefKey.STATS_SHOW_WHEN_PLAYING]: {
label: t('show-stats-on-startup'),
default: false,
},
[PrefKey.STATS_QUICK_GLANCE]: {
[PrefKey.STATS_QUICK_GLANCE_ENABLED]: {
label: '👀 ' + t('enable-quick-glance-mode'),
default: true,
},
@@ -802,12 +812,11 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
},
[PrefKey.STATS_OPACITY]: {
label: t('opacity'),
type: SettingElementType.NUMBER_STEPPER,
default: 80,
min: 50,
max: 100,
steps: 10,
params: {
steps: 10,
suffix: '%',
ticks: 10,
},
@@ -823,12 +832,13 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
default: false,
},
[PrefKey.REMOTE_PLAY_RESOLUTION]: {
[PrefKey.REMOTE_PLAY_STREAM_RESOLUTION]: {
requiredVariants: 'full',
default: StreamResolution.DIM_1080P,
options: {
[StreamResolution.DIM_1080P]: '1080p',
[StreamResolution.DIM_720P]: '720p',
[StreamResolution.DIM_1080P]: '1080p',
[StreamResolution.DIM_1080P_HQ]: '1080p (HQ)',
},
},
@@ -838,13 +848,6 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
default: false,
note: t('fortnite-allow-stw-mode'),
},
[PrefKey.GAME_MSFS2020_FORCE_NATIVE_MKB]: {
requiredVariants: 'full',
label: '✈️ ' + t('msfs2020-force-native-mkb'),
default: false,
note: t('may-not-work-properly'),
},
} satisfies SettingDefinitions;
constructor() {

74
src/utils/shortcut-handler.ts Executable file
View File

@@ -0,0 +1,74 @@
import { ShortcutAction } from "@/enums/shortcut-actions";
import { MicrophoneShortcut } from "@/modules/shortcuts/microphone-shortcut";
import { SoundShortcut } from "@/modules/shortcuts/sound-shortcut";
import { StreamUiShortcut } from "@/modules/shortcuts/stream-ui-shortcut";
import { StreamStats } from "@/modules/stream/stream-stats";
import { SettingsDialog } from "@/modules/ui/dialog/settings-dialog";
import { AppInterface, STATES } from "./global";
import { ScreenshotManager } from "./screenshot-manager";
import { EmulatedMkbHandler } from "@/modules/mkb/mkb-handler";
import { RendererShortcut } from "@/modules/shortcuts/renderer-shortcut";
import { TrueAchievements } from "./true-achievements";
import { NativeMkbHandler } from "@/modules/mkb/native-mkb-handler";
export class ShortcutHandler {
static runAction(action: ShortcutAction) {
switch (action) {
case ShortcutAction.BETTER_XCLOUD_SETTINGS_SHOW:
SettingsDialog.getInstance().show();
break;
case ShortcutAction.STREAM_SCREENSHOT_CAPTURE:
ScreenshotManager.getInstance().takeScreenshot();
break;
case ShortcutAction.STREAM_VIDEO_TOGGLE:
RendererShortcut.toggleVisibility();
break;
case ShortcutAction.STREAM_STATS_TOGGLE:
StreamStats.getInstance().toggle();
break;
case ShortcutAction.STREAM_MICROPHONE_TOGGLE:
MicrophoneShortcut.toggle();
break;
case ShortcutAction.STREAM_MENU_SHOW:
StreamUiShortcut.showHideStreamMenu();
break;
case ShortcutAction.STREAM_SOUND_TOGGLE:
SoundShortcut.muteUnmute();
break;
case ShortcutAction.STREAM_VOLUME_INC:
SoundShortcut.adjustGainNodeVolume(10);
break;
case ShortcutAction.STREAM_VOLUME_DEC:
SoundShortcut.adjustGainNodeVolume(-10);
break;
case ShortcutAction.DEVICE_BRIGHTNESS_INC:
case ShortcutAction.DEVICE_BRIGHTNESS_DEC:
case ShortcutAction.DEVICE_SOUND_TOGGLE:
case ShortcutAction.DEVICE_VOLUME_INC:
case ShortcutAction.DEVICE_VOLUME_DEC:
AppInterface && AppInterface.runShortcut && AppInterface.runShortcut(action);
break;
case ShortcutAction.MKB_TOGGLE:
if (STATES.currentStream.titleInfo?.details.hasMkbSupport) {
NativeMkbHandler.getInstance()?.toggle();
} else {
EmulatedMkbHandler.getInstance()?.toggle();
}
break;
case ShortcutAction.TRUE_ACHIEVEMENTS_OPEN:
TrueAchievements.getInstance().open(false);
break;
}
}
}

215
src/utils/stream-settings.ts Executable file
View File

@@ -0,0 +1,215 @@
import { PrefKey } from "@/enums/pref-keys";
import { ControllerSettingsTable } from "./local-db/controller-settings-table";
import { ControllerShortcutsTable } from "./local-db/controller-shortcuts-table";
import { getPref, setPref } from "./settings-storages/global-settings-storage";
import type { ControllerShortcutPresetRecord, KeyboardShortcutConvertedPresetData, MkbConvertedPresetData } from "@/types/presets";
import { STATES } from "./global";
import { DeviceVibrationMode } from "@/enums/pref-values";
import { VIRTUAL_GAMEPAD_ID } from "@/modules/mkb/mkb-handler";
import { hasGamepad } from "./gamepad";
import { MkbMappingPresetsTable } from "./local-db/mkb-mapping-presets-table";
import type { GamepadKey } from "@/enums/gamepad";
import { MkbPresetKey, MouseConstant } from "@/enums/mkb";
import { BxEvent } from "./bx-event";
import { KeyboardShortcutDefaultId, KeyboardShortcutsTable } from "./local-db/keyboard-shortcuts-table";
import { ShortcutAction } from "@/enums/shortcut-actions";
import { KeyHelper } from "@/modules/mkb/key-helper";
export type StreamSettingsData = {
settings: Partial<Record<PrefKey, any>>;
xCloudPollingMode: 'none' | 'callbacks' | 'navigation' | 'all';
deviceVibrationIntensity: DeviceVibrationIntensity;
controllerPollingRate: ControllerPollingRate;
controllers: {
[gamepadId: string]: {
vibrationIntensity: number;
shortcuts: ControllerShortcutPresetRecord['data']['mapping'] | null;
};
};
mkbPreset: MkbConvertedPresetData | null;
keyboardShortcuts: KeyboardShortcutConvertedPresetData['mapping'] | null;
}
export class StreamSettings {
static settings: StreamSettingsData = {
settings: {},
xCloudPollingMode: 'all',
deviceVibrationIntensity: 0,
controllerPollingRate: 4,
controllers: {},
mkbPreset: null,
keyboardShortcuts: {},
};
static getPref<T=boolean>(key: PrefKey) {
return getPref<T>(key);
}
static async refreshControllerSettings() {
const settings = StreamSettings.settings;
const controllers: StreamSettingsData['controllers'] = {};
const settingsTable = ControllerSettingsTable.getInstance();
const shortcutsTable = ControllerShortcutsTable.getInstance();
const gamepads = window.navigator.getGamepads();
for (const gamepad of gamepads) {
if (!gamepad?.connected) {
continue;
}
// Ignore virtual controller
if (gamepad.id === VIRTUAL_GAMEPAD_ID) {
continue;
}
const settingsData = await settingsTable.getControllerData(gamepad.id);
let shortcutsMapping;
const preset = await shortcutsTable.getPreset(settingsData.shortcutPresetId);
if (!preset) {
shortcutsMapping = null;
} else {
shortcutsMapping = preset.data.mapping;
}
controllers[gamepad.id] = {
vibrationIntensity: settingsData.vibrationIntensity,
shortcuts: shortcutsMapping,
}
}
settings.controllers = controllers;
// Controller polling rate
settings.controllerPollingRate = StreamSettings.getPref(PrefKey.CONTROLLER_POLLING_RATE);
// Device vibration
await StreamSettings.refreshDeviceVibration();
}
private static async refreshDeviceVibration() {
if (!STATES.browser.capabilities.deviceVibration) {
return;
}
const mode = StreamSettings.getPref<DeviceVibrationMode>(PrefKey.DEVICE_VIBRATION_MODE);
let intensity = 0; // Disable
// Enable when no controllers are detected in Auto mode
if (mode === DeviceVibrationMode.ON || (mode === DeviceVibrationMode.AUTO && !hasGamepad())) {
// Set intensity
intensity = StreamSettings.getPref<DeviceVibrationIntensity>(PrefKey.DEVICE_VIBRATION_INTENSITY) / 100;
}
StreamSettings.settings.deviceVibrationIntensity = intensity;
BxEvent.dispatch(window, BxEvent.DEVICE_VIBRATION_CHANGED);
}
static async refreshMkbSettings() {
const settings = StreamSettings.settings;
let presetId = StreamSettings.getPref<MkbPresetId>(PrefKey.MKB_P1_MAPPING_PRESET_ID);
const orgPreset = (await MkbMappingPresetsTable.getInstance().getPreset(presetId))!;
const orgPresetData = orgPreset.data;
const converted: MkbConvertedPresetData = {
mapping: {},
mouse: Object.assign({}, orgPresetData.mouse),
};
let key: string;
for (key in orgPresetData.mapping) {
const buttonIndex = parseInt(key) as GamepadKey;
if (!orgPresetData.mapping[buttonIndex]) {
continue;
}
for (const keyName of orgPresetData.mapping[buttonIndex]) {
if (typeof keyName === 'string') {
converted.mapping[keyName!] = buttonIndex;
}
}
}
// Pre-calculate mouse's sensitivities
const mouse = converted.mouse;
mouse[MkbPresetKey.MOUSE_SENSITIVITY_X] *= MouseConstant.DEFAULT_PANNING_SENSITIVITY;
mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y] *= MouseConstant.DEFAULT_PANNING_SENSITIVITY;
mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT] *= MouseConstant.DEFAULT_DEADZONE_COUNTERWEIGHT;
settings.mkbPreset = converted;
setPref(PrefKey.MKB_P1_MAPPING_PRESET_ID, orgPreset.id);
BxEvent.dispatch(window, BxEvent.MKB_UPDATED);
}
static async refreshKeyboardShortcuts() {
const settings = StreamSettings.settings;
let presetId = StreamSettings.getPref<KeyboardShortcutsPresetId>(PrefKey.KEYBOARD_SHORTCUTS_IN_GAME_PRESET_ID);
if (presetId === KeyboardShortcutDefaultId.OFF) {
settings.keyboardShortcuts = null;
setPref(PrefKey.KEYBOARD_SHORTCUTS_IN_GAME_PRESET_ID, presetId);
BxEvent.dispatch(window, BxEvent.KEYBOARD_SHORTCUTS_UPDATED);
return;
}
const orgPreset = (await KeyboardShortcutsTable.getInstance().getPreset(presetId))!;
const orgPresetData = orgPreset.data.mapping;
const converted: KeyboardShortcutConvertedPresetData['mapping'] = {};
let action: keyof typeof orgPresetData;
for (action in orgPresetData) {
const info = orgPresetData[action]!;
const key = `${info.code}:${info.modifiers || 0}`;
converted[key] = action;
}
settings.keyboardShortcuts = converted;
setPref(PrefKey.KEYBOARD_SHORTCUTS_IN_GAME_PRESET_ID, orgPreset.id);
BxEvent.dispatch(window, BxEvent.KEYBOARD_SHORTCUTS_UPDATED);
}
static async refreshAllSettings() {
window.BX_STREAM_SETTINGS = StreamSettings.settings;
await StreamSettings.refreshControllerSettings();
await StreamSettings.refreshMkbSettings();
await StreamSettings.refreshKeyboardShortcuts();
}
static findKeyboardShortcut(targetAction: ShortcutAction) {
const shortcuts = StreamSettings.settings.keyboardShortcuts
for (const codeStr in shortcuts) {
const action = shortcuts[codeStr];
if (action === targetAction) {
return KeyHelper.parseFullKeyCode(codeStr);
}
}
return null;
}
static setup() {
const listener = () => {
StreamSettings.refreshControllerSettings();
}
window.addEventListener('gamepadconnected', listener);
window.addEventListener('gamepaddisconnected', listener);
StreamSettings.refreshAllSettings();
}
}

18
src/utils/stream-stats-collector.ts Normal file → Executable file
View File

@@ -4,21 +4,7 @@ import { STATES } from "./global";
import { humanFileSize, secondsToHm } from "./html";
import { getPref } from "./settings-storages/global-settings-storage";
import { BxLogger } from "./bx-logger";
export enum StreamStat {
PING = 'ping',
JITTER = 'jit',
FPS = 'fps',
BITRATE = 'btr',
DECODE_TIME = 'dt',
PACKETS_LOST = 'pl',
FRAMES_LOST = 'fl',
DOWNLOAD = 'dl',
UPLOAD = 'ul',
PLAYTIME = 'play',
BATTERY = 'batt',
CLOCK = 'time',
};
import { StreamStat } from "@/enums/pref-values";
export type StreamStatGrade = '' | 'bad' | 'ok' | 'good';
@@ -125,7 +111,7 @@ export class StreamStatsCollector {
[StreamStat.FPS]: {
current: 0,
toString() {
const maxFps = getPref(PrefKey.VIDEO_MAX_FPS);
const maxFps = getPref<VideoMaxFps>(PrefKey.VIDEO_MAX_FPS);
return maxFps < 60 ? `${maxFps}/${this.current}` : this.current.toString();
},
},

4
src/utils/toast.ts Normal file → Executable file
View File

@@ -65,7 +65,7 @@ export class Toast {
this.isShowing = true;
this.timeoutId && clearTimeout(this.timeoutId);
this.timeoutId = window.setTimeout(this.hide.bind(this), this.DURATION);
this.timeoutId = window.setTimeout(this.hide, this.DURATION);
// Get values from item
const [msg, status, options] = this.stack.shift()!;
@@ -88,7 +88,7 @@ export class Toast {
classList.add('bx-show');
}
private hide() {
private hide = () => {
this.timeoutId = null;
const classList = this.$wrapper.classList;

103
src/utils/translation.ts Normal file → Executable file
View File

@@ -1,5 +1,7 @@
import { StorageKey } from "@/enums/pref-keys";
import { NATIVE_FETCH } from "./bx-flags";
import { BxLogger } from "./bx-logger";
import { GhPagesUtils } from "./gh-pages";
export const SUPPORTED_LANGUAGES = {
'en-US': 'English (US)',
@@ -57,6 +59,9 @@ const Texts = {
"clarity-boost": "Clarity boost",
"clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON",
"clear": "Clear",
"clear-data": "Clear data",
"clear-data-confirm": "Do you want to clear all Better xCloud settings and data?",
"clear-data-success": "Data cleared! Refresh the page to apply the changes.",
"clock": "Clock",
"close": "Close",
"close-app": "Close app",
@@ -77,6 +82,7 @@ const Texts = {
"controller-friendly-ui": "Controller-friendly UI",
"controller-shortcuts": "Controller shortcuts",
"controller-shortcuts-connect-note": "Connect a controller to use this feature",
"controller-shortcuts-in-game": "In-game controller shortcuts",
"controller-shortcuts-xbox-note": "Button to open the Guide menu",
"controller-vibration": "Controller vibration",
"copy": "Copy",
@@ -91,6 +97,7 @@ const Texts = {
"device-vibration": "Device vibration",
"device-vibration-not-using-gamepad": "On when not using gamepad",
"disable": "Disable",
"disable-byog-feature": "Disable \"Stream your own game\" feature",
"disable-home-context-menu": "Disable context menu in Home page",
"disable-post-stream-feedback-dialog": "Disable post-stream feedback dialog",
"disable-social-features": "Disable social features",
@@ -112,6 +119,7 @@ const Texts = {
"experimental": "Experimental",
"export": "Export",
"fast": "Fast",
"force-native-mkb-games": "Force native Mouse & Keyboard for these games",
"fortnite-allow-stw-mode": "Allows playing \"Save the World\" mode on mobile",
"fortnite-force-console-version": "Fortnite: force console version",
"game-bar": "Game Bar",
@@ -137,7 +145,9 @@ const Texts = {
"install-android": "Better xCloud app for Android",
"japan": "Japan",
"jitter": "Jitter",
"keyboard-key": "Keyboard key",
"keyboard-shortcuts": "Keyboard shortcuts",
"keyboard-shortcuts-in-game": "In-game keyboard shortcuts",
"korea": "Korea",
"language": "Language",
"large": "Large",
@@ -147,6 +157,7 @@ const Texts = {
"loading-screen": "Loading screen",
"local-co-op": "Local co-op",
"lowest-quality": "Lowest quality",
"manage": "Manage",
"map-mouse-to": "Map mouse to",
"max-fps": "Max FPS",
"may-not-work-properly": "May not work properly!",
@@ -154,17 +165,18 @@ const Texts = {
"microphone": "Microphone",
"mkb-adjust-ingame-settings": "You may also need to adjust the in-game sensitivity & deadzone settings",
"mkb-click-to-activate": "Click to activate",
"mkb-disclaimer": "Using this feature when playing online could be viewed as cheating",
"mkb-disclaimer": "This could be viewed as cheating when playing online",
"modifiers-note": "To use more than one key, include Ctrl, Alt or Shift in your shortcut. Command key is not allowed.",
"mouse-and-keyboard": "Mouse & Keyboard",
"mouse-click": "Mouse click",
"mouse-wheel": "Mouse wheel",
"msfs2020-force-native-mkb": "MSFS2020: force native M&KB support",
"muted": "Muted",
"name": "Name",
"native-mkb": "Native Mouse & Keyboard",
"new": "New",
"new-version-available": [
(e: any) => `Version ${e.version} available`,
,
(e: any) => `Versió ${e.version} disponible`,
,
(e: any) => `Version ${e.version} verfügbar`,
(e: any) => `Versi ${e.version} tersedia`,
@@ -184,8 +196,10 @@ const Texts = {
(e: any) => `已可更新為 ${e.version}`,
],
"no-consoles-found": "No consoles found",
"no-controllers-connected": "No controllers connected",
"normal": "Normal",
"off": "Off",
"official": "Official",
"on": "On",
"only-supports-some-games": "Only supports some games",
"opacity": "Opacity",
@@ -264,6 +278,7 @@ const Texts = {
"screen": "Screen",
"screenshot-apply-filters": "Apply video filters to screenshots",
"section-all-games": "All games",
"section-byog": "Stream your own game",
"section-most-popular": "Most popular",
"section-native-mkb": "Play with mouse & keyboard",
"section-news": "News",
@@ -293,6 +308,7 @@ const Texts = {
"small": "Small",
"smart-tv": "Smart TV",
"sound": "Sound",
"standard": "Standard",
"standby": "Standby",
"stat-bitrate": "Bitrate",
"stat-decode-time": "Decode time",
@@ -357,6 +373,8 @@ const Texts = {
"unknown": "Unknown",
"unlimited": "Unlimited",
"unmuted": "Unmuted",
"unofficial": "Unofficial",
"unofficial-game-list": "Unofficial game list",
"unsharp-masking": "Unsharp masking",
"upload": "Upload",
"uploaded": "Uploaded",
@@ -369,6 +387,7 @@ const Texts = {
"vibration-status": "Vibration",
"video": "Video",
"virtual-controller": "Virtual controller",
"virtual-controller-slot": "Virtual controller slot",
"visual-quality": "Visual quality",
"visual-quality-high": "High",
"visual-quality-low": "Low",
@@ -376,57 +395,57 @@ const Texts = {
"volume": "Volume",
"wait-time-countdown": "Countdown",
"wait-time-estimated": "Estimated finish time",
"waiting-for-input": "Waiting for input...",
"wallpaper": "Wallpaper",
"webgl2": "WebGL2",
};
export class Translations {
static readonly #EN_US = 'en-US';
static readonly #KEY_LOCALE = 'better_xcloud_locale';
static readonly #KEY_TRANSLATIONS = 'better_xcloud_translations';
private static readonly EN_US = 'en-US';
private static readonly KEY_LOCALE = StorageKey.LOCALE;
private static readonly KEY_TRANSLATIONS = StorageKey.LOCALE_TRANSLATIONS;
static #enUsIndex = -1;
static #selectedLocaleIndex = -1;
static #selectedLocale: keyof typeof SUPPORTED_LANGUAGES = 'en-US';
private static selectedLocaleIndex = -1;
private static selectedLocale: keyof typeof SUPPORTED_LANGUAGES = 'en-US';
static #supportedLocales = Object.keys(SUPPORTED_LANGUAGES);
static #foreignTranslations: any = {};
private static supportedLocales = Object.keys(SUPPORTED_LANGUAGES);
private static foreignTranslations: any = {};
private static enUsIndex = Translations.supportedLocales.indexOf(Translations.EN_US);
static async init() {
Translations.#enUsIndex = Translations.#supportedLocales.indexOf(Translations.#EN_US);
Translations.refreshLocale();
await Translations.#loadTranslations();
await Translations.loadTranslations();
}
static refreshLocale(newLocale?: string) {
let locale;
if (newLocale) {
localStorage.setItem(Translations.#KEY_LOCALE, newLocale);
localStorage.setItem(Translations.KEY_LOCALE, newLocale);
locale = newLocale;
} else {
locale = localStorage.getItem(Translations.#KEY_LOCALE);
locale = localStorage.getItem(Translations.KEY_LOCALE);
}
const supportedLocales = Translations.#supportedLocales;
const supportedLocales = Translations.supportedLocales;
if (!locale) {
// Get browser's locale
locale = window.navigator.language || Translations.#EN_US;
locale = window.navigator.language || Translations.EN_US;
if (supportedLocales.indexOf(locale) === -1) {
locale = Translations.#EN_US;
locale = Translations.EN_US;
}
localStorage.setItem(Translations.#KEY_LOCALE, locale);
localStorage.setItem(Translations.KEY_LOCALE, locale);
}
Translations.#selectedLocale = locale as keyof typeof SUPPORTED_LANGUAGES;
Translations.#selectedLocaleIndex = supportedLocales.indexOf(locale);
Translations.selectedLocale = locale as keyof typeof SUPPORTED_LANGUAGES;
Translations.selectedLocaleIndex = supportedLocales.indexOf(locale);
}
static get<T=string>(key: keyof typeof Texts, values?: any): T {
let text = null;
if (Translations.#foreignTranslations && Translations.#selectedLocale !== Translations.#EN_US) {
text = Translations.#foreignTranslations[key];
if (Translations.foreignTranslations && Translations.selectedLocale !== Translations.EN_US) {
text = Translations.foreignTranslations[key];
}
if (!text) {
@@ -435,7 +454,7 @@ export class Translations {
let translation: unknown;
if (Array.isArray(text)) {
translation = text[Translations.#selectedLocaleIndex] || text[Translations.#enUsIndex];
translation = text[Translations.selectedLocaleIndex] || text[Translations.enUsIndex];
return (translation as any)(values);
}
@@ -443,44 +462,44 @@ export class Translations {
return translation as T;
}
static async #loadTranslations() {
if (Translations.#selectedLocale === Translations.#EN_US) {
private static async loadTranslations() {
if (Translations.selectedLocale === Translations.EN_US) {
return;
}
try {
Translations.#foreignTranslations = JSON.parse(window.localStorage.getItem(Translations.#KEY_TRANSLATIONS)!);
Translations.foreignTranslations = JSON.parse(window.localStorage.getItem(Translations.KEY_TRANSLATIONS)!);
} catch(e) {}
if (!Translations.#foreignTranslations) {
await this.downloadTranslations(Translations.#selectedLocale);
if (!Translations.foreignTranslations) {
await this.downloadTranslations(Translations.selectedLocale);
}
}
static async updateTranslations(async=false) {
// Don't have to download en-US
if (Translations.#selectedLocale === Translations.#EN_US) {
localStorage.removeItem(Translations.#KEY_TRANSLATIONS);
if (Translations.selectedLocale === Translations.EN_US) {
localStorage.removeItem(Translations.KEY_TRANSLATIONS);
return;
}
if (async) {
Translations.downloadTranslationsAsync(Translations.#selectedLocale);
Translations.downloadTranslationsAsync(Translations.selectedLocale);
} else {
await Translations.downloadTranslations(Translations.#selectedLocale);
await Translations.downloadTranslations(Translations.selectedLocale);
}
}
static async downloadTranslations(locale: string) {
try {
const resp = await NATIVE_FETCH(`https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/translations/${locale}.json`);
const resp = await NATIVE_FETCH(GhPagesUtils.getUrl(`translations/${locale}.json`));
const translations = await resp.json();
// Prevent saving incorrect translations
let currentLocale = localStorage.getItem(Translations.#KEY_LOCALE);
let currentLocale = localStorage.getItem(Translations.KEY_LOCALE);
if (currentLocale === locale) {
window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations));
Translations.#foreignTranslations = translations;
window.localStorage.setItem(Translations.KEY_TRANSLATIONS, JSON.stringify(translations));
Translations.foreignTranslations = translations;
}
return true;
} catch (e) {
@@ -491,16 +510,16 @@ export class Translations {
}
static downloadTranslationsAsync(locale: string) {
NATIVE_FETCH(`https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/translations/${locale}.json`)
NATIVE_FETCH(GhPagesUtils.getUrl(`translations/${locale}.json`))
.then(resp => resp.json())
.then(translations => {
window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations));
Translations.#foreignTranslations = translations;
window.localStorage.setItem(Translations.KEY_TRANSLATIONS, JSON.stringify(translations));
Translations.foreignTranslations = translations;
});
}
static switchLocale(locale: string) {
localStorage.setItem(Translations.#KEY_LOCALE, locale);
localStorage.setItem(Translations.KEY_LOCALE, locale);
}
}

6
src/utils/true-achievements.ts Normal file → Executable file
View File

@@ -21,7 +21,7 @@ export class TrueAchievements {
url: '#',
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.NORMAL_LINK,
onClick: this.onClick.bind(this),
onClick: this.onClick,
});
this.$button = createButton<HTMLAnchorElement>({
@@ -29,7 +29,7 @@ export class TrueAchievements {
title: t('true-achievements'),
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE,
onClick: this.onClick.bind(this),
onClick: this.onClick,
});
this.$hiddenLink = CE<HTMLAnchorElement>('a', {
@@ -37,7 +37,7 @@ export class TrueAchievements {
});
}
private onClick(e: Event) {
private onClick = (e: Event) => {
e.preventDefault();
// Close all xCloud's dialogs

3
src/utils/user-agent.ts Normal file → Executable file
View File

@@ -1,5 +1,6 @@
import { UserAgentProfile } from "@enums/user-agent";
import { BX_FLAGS } from "./bx-flags";
import { StorageKey } from "@/enums/pref-keys";
type UserAgentConfig = {
profile: UserAgentProfile,
@@ -18,7 +19,7 @@ if (!!(window as any).chrome || window.navigator.userAgent.includes('Chrome')) {
}
export class UserAgent {
static readonly STORAGE_KEY = 'better_xcloud_user_agent';
static readonly STORAGE_KEY = StorageKey.USER_AGENT;
static #config: UserAgentConfig;
static #isMobile: boolean | null = null;

35
src/utils/utils.ts Normal file → Executable file
View File

@@ -1,9 +1,10 @@
import { AppInterface, SCRIPT_VERSION } from "@utils/global";
import { UserAgent } from "@utils/user-agent";
import { Translations } from "./translation";
import { t, Translations } from "./translation";
import { Toast } from "./toast";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "./settings-storages/global-settings-storage";
import { LocalDb } from "./local-db/local-db";
/**
* Check for update
@@ -16,8 +17,8 @@ export function checkForUpdate() {
const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours
const currentVersion = getPref(PrefKey.CURRENT_VERSION);
const lastCheck = getPref(PrefKey.LAST_UPDATE_CHECK);
const currentVersion = getPref<VersionCurrent>(PrefKey.VERSION_CURRENT);
const lastCheck = getPref<VersionLastCheck>(PrefKey.VERSION_LAST_CHECK);
const now = Math.round((+new Date) / 1000);
if (currentVersion === SCRIPT_VERSION && now - lastCheck < CHECK_INTERVAL_SECONDS) {
@@ -25,13 +26,13 @@ export function checkForUpdate() {
}
// Start checking
setPref(PrefKey.LAST_UPDATE_CHECK, now);
setPref(PrefKey.VERSION_LAST_CHECK, now);
fetch('https://api.github.com/repos/redphx/better-xcloud/releases/latest')
.then(response => response.json())
.then(json => {
// Store the latest version
setPref(PrefKey.LATEST_VERSION, json.tag_name.substring(1));
setPref(PrefKey.CURRENT_VERSION, SCRIPT_VERSION);
setPref(PrefKey.VERSION_LATEST, json.tag_name.substring(1));
setPref(PrefKey.VERSION_CURRENT, SCRIPT_VERSION);
});
// Update translations
@@ -132,3 +133,25 @@ export function parseDetailsPath(path: string) {
return {titleSlug, productId};
}
export function clearAllData() {
// Delete localStorage items
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) {
continue;
}
// Delete key
if (key.startsWith('BetterXcloud') || key.startsWith('better_xcloud')) {
localStorage.removeItem(key);
}
}
// Delete IndexedDB database
try {
indexedDB.deleteDatabase(LocalDb.DB_NAME);
} catch (e) {};
alert(t('clear-data-success'));
}

0
src/utils/xbox-api.ts Normal file → Executable file
View File

4
src/utils/xcloud-api.ts Normal file → Executable file
View File

@@ -29,7 +29,7 @@ export class XcloudApi {
const response = await NATIVE_FETCH(`${baseUri}/v2/titles`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${STATES.gsToken}`,
Authorization: `Bearer ${STATES.gsToken}`,
'Content-Type': 'application/json',
},
@@ -63,7 +63,7 @@ export class XcloudApi {
const response = await NATIVE_FETCH(`${baseUri}/v1/waittime/${id}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${STATES.gsToken}`,
Authorization: `Bearer ${STATES.gsToken}`,
},
});

84
src/utils/xcloud-interceptor.ts Normal file → Executable file
View File

@@ -11,7 +11,8 @@ import { patchIceCandidates } from "./network";
import { getPreferredServerRegion } from "./region";
import { BypassServerIps } from "@/enums/bypass-servers";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, StreamResolution, StreamTouchController } from "./settings-storages/global-settings-storage";
import { getPref } from "./settings-storages/global-settings-storage";
import { NativeMkbMode, StreamResolution, TouchControllerMode } from "@/enums/pref-values";
export class XcloudInterceptor {
private static readonly SERVER_EXTRA_INFO: Record<string, [string, ServerContinent]> = {
@@ -41,8 +42,48 @@ export class XcloudInterceptor {
WestEurope: ['🇪🇺', 'europe'],
};
private static readonly BASE_DEVICE_INFO = {
appInfo: {
env: {
clientAppId: window.location.host,
clientAppType: 'browser',
clientAppVersion: '24.17.36',
clientSdkVersion: '10.1.14',
httpEnvironment: 'prod',
sdkInstallId: '',
},
},
dev: {
displayInfo: {
dimensions: {
widthInPixels: 1920,
heightInPixels: 1080,
},
pixelDensity: {
dpiX: 1,
dpiY: 1,
},
},
hw: {
make: 'Microsoft',
model: 'unknown',
sdktype: 'web',
},
os: {
name: 'windows',
ver: '22631.2715',
platform: 'desktop',
},
browser: {
browserName: 'chrome',
browserVersion: '125.0',
},
},
};
private static async handleLogin(request: RequestInfo | URL, init?: RequestInit) {
const bypassServer = getPref(PrefKey.SERVER_BYPASS_RESTRICTION);
const bypassServer = getPref<string>(PrefKey.SERVER_BYPASS_RESTRICTION);
if (bypassServer !== 'off') {
const ip = BypassServerIps[bypassServer as keyof typeof BypassServerIps];
ip && (request as Request).headers.set('X-Forwarded-For', ip);
@@ -58,7 +99,7 @@ export class XcloudInterceptor {
const obj = await response.clone().json();
// Store xCloud token
RemotePlayManager.getInstance().xcloudToken = obj.gsToken;
RemotePlayManager.getInstance()?.setXcloudToken(obj.gsToken);
// Get server list
const serverRegex = /\/\/(\w+)\./;
@@ -108,8 +149,8 @@ export class XcloudInterceptor {
private static async handlePlay(request: RequestInfo | URL, init?: RequestInit) {
BxEvent.dispatch(window, BxEvent.STREAM_LOADING);
const PREF_STREAM_TARGET_RESOLUTION = getPref(PrefKey.STREAM_TARGET_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE);
const PREF_STREAM_TARGET_RESOLUTION = getPref<StreamResolution>(PrefKey.STREAM_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref<StreamPreferredLocale>(PrefKey.STREAM_PREFERRED_LOCALE);
const url = (typeof request === 'string') ? request : (request as Request).url;
const parsedUrl = new URL(url);
@@ -127,9 +168,31 @@ export class XcloudInterceptor {
const clone = (request as Request).clone();
const body = await clone.json();
const headers: {[index: string]: string} = {};
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
// Force stream's resolution
if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') {
const osName = (PREF_STREAM_TARGET_RESOLUTION === StreamResolution.DIM_720P) ? 'android' : 'windows';
let osName;
switch (PREF_STREAM_TARGET_RESOLUTION) {
case StreamResolution.DIM_1080P_HQ:
osName = 'tizen';
const deviceInfo = XcloudInterceptor.BASE_DEVICE_INFO;
deviceInfo.dev.os.name = 'tizen';
headers['x-ms-device-info'] = JSON.stringify(deviceInfo);
break;
case StreamResolution.DIM_1080P:
osName = 'windows';
break;
default:
osName = 'android';
break
}
body.settings.osName = osName;
}
@@ -140,6 +203,7 @@ export class XcloudInterceptor {
const newRequest = new Request(request, {
body: JSON.stringify(body),
headers: headers,
});
return NATIVE_FETCH(newRequest);
@@ -148,7 +212,7 @@ export class XcloudInterceptor {
private static async handleWaitTime(request: RequestInfo | URL, init?: RequestInit) {
const response = await NATIVE_FETCH(request, init);
if (getPref(PrefKey.UI_LOADING_SCREEN_WAIT_TIME)) {
if (getPref(PrefKey.LOADING_SCREEN_SHOW_WAIT_TIME)) {
const json = await response.clone().json();
if (json.estimatedAllocationTimeInSeconds > 0) {
// Setup wait time overlay
@@ -165,7 +229,7 @@ export class XcloudInterceptor {
}
// Touch controller for all games
if (isFullVersion() && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === StreamTouchController.ALL) {
if (isFullVersion() && getPref<TouchControllerMode>(PrefKey.TOUCH_CONTROLLER_MODE) === TouchControllerMode.ALL) {
const titleInfo = STATES.currentStream.titleInfo;
if (titleInfo?.details.hasTouchSupport) {
TouchController.disable();
@@ -191,11 +255,11 @@ export class XcloudInterceptor {
let overrideMkb: boolean | null = null;
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
if (getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.ON || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
overrideMkb = true;
}
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
if (getPref<NativeMkbMode>(PrefKey.NATIVE_MKB_MODE) === NativeMkbMode.OFF) {
overrideMkb = false;
}

22
src/utils/xhome-interceptor.ts Normal file → Executable file
View File

@@ -5,9 +5,10 @@ import { NATIVE_FETCH } from "./bx-flags";
import { STATES } from "./global";
import { patchIceCandidates } from "./network";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, StreamResolution, StreamTouchController } from "./settings-storages/global-settings-storage";
import { getPref } from "./settings-storages/global-settings-storage";
import type { RemotePlayConsoleAddresses } from "@/types/network";
import { RemotePlayManager } from "@/modules/remote-play-manager";
import { StreamResolution, TouchControllerMode } from "@/enums/pref-values";
export class XhomeInterceptor {
private static consoleAddrs: RemotePlayConsoleAddresses = {};
@@ -109,7 +110,7 @@ export class XhomeInterceptor {
private static async handleInputConfigs(request: Request | URL, opts: {[index: string]: any}) {
const response = await NATIVE_FETCH(request);
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.ALL) {
if (getPref<TouchControllerMode>(PrefKey.TOUCH_CONTROLLER_MODE) !== TouchControllerMode.ALL) {
return response;
}
@@ -150,7 +151,7 @@ export class XhomeInterceptor {
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
headers.authorization = `Bearer ${RemotePlayManager.getInstance().xcloudToken}`;
headers.authorization = `Bearer ${RemotePlayManager.getInstance()!.getXcloudToken()}`;
const index = request.url.indexOf('.xboxlive.com');
request = new Request('https://wus.core.gssv-play-prod' + request.url.substring(index), {
@@ -187,12 +188,21 @@ export class XhomeInterceptor {
headers[pair[0]] = pair[1];
}
// Add xHome token to headers
headers.authorization = `Bearer ${RemotePlayManager.getInstance().xhomeToken}`;
headers.authorization = `Bearer ${RemotePlayManager.getInstance()!.getXhomeToken()}`;
// Patch resolution
const deviceInfo = XhomeInterceptor.BASE_DEVICE_INFO;
if (getPref(PrefKey.REMOTE_PLAY_RESOLUTION) === StreamResolution.DIM_720P) {
deviceInfo.dev.os.name = 'android';
const resolution = getPref<StreamResolution>(PrefKey.REMOTE_PLAY_STREAM_RESOLUTION);
switch (resolution) {
case StreamResolution.DIM_1080P_HQ:
deviceInfo.dev.os.name = 'tizen';
break;
case StreamResolution.DIM_720P:
deviceInfo.dev.os.name = 'android';
break;
default:
deviceInfo.dev.os.name = 'windows';
break;
}
headers['x-ms-device-info'] = JSON.stringify(deviceInfo);