Optimize + refactor code

This commit is contained in:
redphx 2024-10-21 20:50:12 +07:00
parent 075b15aa48
commit de76364a46
44 changed files with 1794 additions and 1274 deletions

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@ import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler";
import { StreamBadges } from "@modules/stream/stream-badges";
import { StreamStats } from "@modules/stream/stream-stats";
import { addCss, preloadFonts } from "@utils/css";
import { Toast } from "@utils/toast";
import { LoadingScreen } from "@modules/loading-screen";
import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider";
import { TouchController } from "@modules/touch-controller";
@ -26,7 +25,7 @@ import { disableAdobeAudienceManager, patchAudioContext, patchCanvasContext, pat
import { AppInterface, STATES } from "@utils/global";
import { BxLogger } from "@utils/bx-logger";
import { GameBar } from "./modules/game-bar/game-bar";
import { Screenshot } from "./utils/screenshot";
import { ScreenshotManager } from "./utils/screenshot-manager";
import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler";
import { GuideMenu } from "./modules/ui/guide-menu";
import { updateVideoPlayer } from "./modules/stream/stream-settings-utils";
@ -170,7 +169,7 @@ document.addEventListener('readystatechange', e => {
// Hide "Play with Friends" skeleton section
if (getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.FRIENDS)) {
const $parent = document.querySelector('div[class*=PlayWithFriendsSkeleton]')?.closest('div[class*=HomePage-module]') as HTMLElement;
const $parent = document.querySelector('div[class*=PlayWithFriendsSkeleton]')?.closest<HTMLElement>('div[class*=HomePage-module]');
$parent && ($parent.style.display = 'none');
}
@ -194,7 +193,7 @@ window.addEventListener(BxEvent.XCLOUD_SERVERS_UNAVAILABLE, e => {
window.setTimeout(HeaderSection.watchHeader, 2000);
// Open Settings dialog on Unsupported page
const $unsupportedPage = document.querySelector('div[class^=UnsupportedMarketPage-module__container]') as HTMLElement;
const $unsupportedPage = document.querySelector<HTMLElement>('div[class^=UnsupportedMarketPage-module__container]');
if ($unsupportedPage) {
SettingsNavigationDialog.getInstance().show();
}
@ -241,7 +240,7 @@ window.addEventListener(BxEvent.STREAM_PLAYING, e => {
if (isFullVersion()) {
const $video = (e as any).$video as HTMLVideoElement;
Screenshot.updateCanvasSize($video.videoWidth, $video.videoHeight);
ScreenshotManager.getInstance().updateCanvasSize($video.videoWidth, $video.videoHeight);
}
updateVideoPlayer();
@ -316,7 +315,7 @@ function unload() {
if (isFullVersion()) {
MouseCursorHider.stop();
TouchController.reset();
GameBar.getInstance().disable();
(getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance().disable();
}
}
@ -326,7 +325,7 @@ window.addEventListener('pagehide', e => {
});
isFullVersion() && window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => {
Screenshot.takeScreenshot();
ScreenshotManager.getInstance().takeScreenshot();
});
@ -354,17 +353,13 @@ function main() {
// Setup UI
addCss();
Toast.setup();
GuideMenu.addEventListeners();
GuideMenu.getInstance().addEventListeners();
StreamStatsCollector.setupEvents();
StreamBadges.setupEvents();
StreamStats.setupEvents();
if (isFullVersion()) {
(getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance();
Screenshot.setup();
STATES.userAgent.capabilities.touch && TouchController.updateCustomList();
overridePreloadState();

View File

@ -1,4 +1,4 @@
import { Screenshot } from "@utils/screenshot";
import { ScreenshotManager } from "@/utils/screenshot-manager";
import { GamepadKey } from "@enums/mkb";
import { PrompFont } from "@enums/prompt-font";
import { CE, removeChildElements } from "@utils/html";
@ -97,7 +97,7 @@ export class ControllerShortcut {
break;
case ShortcutAction.STREAM_SCREENSHOT_CAPTURE:
Screenshot.takeScreenshot();
ScreenshotManager.getInstance().takeScreenshot();
break;
case ShortcutAction.STREAM_STATS_TOGGLE:
@ -163,8 +163,6 @@ export class ControllerShortcut {
// Save to storage
window.localStorage.setItem(ControllerShortcut.STORAGE_KEY, JSON.stringify(ControllerShortcut.ACTIONS));
console.log(ControllerShortcut.ACTIONS);
}
private static updateProfileList(e?: GamepadEvent) {

View File

@ -30,7 +30,7 @@ export class Dialog {
} = options;
// Create dialog overlay
const $overlay = document.querySelector('.bx-dialog-overlay') as HTMLElement;
const $overlay = document.querySelector<HTMLElement>('.bx-dialog-overlay');
if (!$overlay) {
this.$overlay = CE('div', {'class': 'bx-dialog-overlay bx-gone'});

View File

@ -2,7 +2,7 @@ import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle } from "@utils/html";
import { BaseGameBarAction } from "./action-base";
import { t } from "@utils/translation";
import { Screenshot } from "@/utils/screenshot";
import { ScreenshotManager } from "@/utils/screenshot-manager";
export class ScreenshotAction extends BaseGameBarAction {
$content: HTMLElement;
@ -20,6 +20,6 @@ export class ScreenshotAction extends BaseGameBarAction {
onClick(e: Event): void {
super.onClick(e);
Screenshot.takeScreenshot();
ScreenshotManager.getInstance().takeScreenshot();
}
}

View File

@ -18,6 +18,6 @@ export class TrueAchievementsAction extends BaseGameBarAction {
onClick(e: Event) {
super.onClick(e);
TrueAchievements.open(false);
TrueAchievements.getInstance().open(false);
}
}

View File

@ -11,11 +11,13 @@ import { getPref, StreamTouchController, type GameBarPosition } from "@/utils/se
import { TrueAchievementsAction } from "./action-true-achievements";
import { SpeakerAction } from "./action-speaker";
import { RendererAction } from "./action-renderer";
import { BxLogger } from "@/utils/bx-logger";
export class GameBar {
private static instance: GameBar;
public static getInstance = () => GameBar.instance ?? (GameBar.instance = new GameBar());
private readonly LOG_TAG = 'GameBar';
private static readonly VISIBLE_DURATION = 2000;
@ -27,6 +29,8 @@ export class GameBar {
private actions: BaseGameBarAction[] = [];
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
let $container;
const position = getPref(PrefKey.GAME_BAR_POSITION) as GameBarPosition;

View File

@ -6,7 +6,6 @@ import { createButton, ButtonStyle, CE } from "@utils/html";
import { BxEvent } from "@utils/bx-event";
import { Toast } from "@utils/toast";
import { t } from "@utils/translation";
import { LocalDb } from "@utils/local-db";
import { KeyHelper } from "./key-helper";
import type { MkbStoredPreset } from "@/types/mkb";
import { AppInterface, STATES } from "@utils/global";
@ -19,8 +18,7 @@ import { SettingsNavigationDialog } from "../ui/dialog/settings-dialog";
import { NavigationDialogManager } from "../ui/dialog/navigation-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'MkbHandler';
import { MkbPresetsDb } from "@/utils/local-db/mkb-presets-db";
const PointerToMouseButton = {
1: 0,
@ -126,6 +124,7 @@ Source: https://github.com/yuzu-emu/yuzu-mainline/blob/master/src/input_common/d
export class EmulatedMkbHandler extends MkbHandler {
private static instance: EmulatedMkbHandler;
public static getInstance = () => EmulatedMkbHandler.instance ?? (EmulatedMkbHandler.instance = new EmulatedMkbHandler());
private static readonly LOG_TAG = 'EmulatedMkbHandler';
#CURRENT_PRESET_DATA = MkbPreset.convert(MkbPreset.DEFAULT_PRESET);
@ -167,8 +166,9 @@ export class EmulatedMkbHandler extends MkbHandler {
#RIGHT_STICK_X: GamepadKey[] = [];
#RIGHT_STICK_Y: GamepadKey[] = [];
constructor() {
private constructor() {
super();
BxLogger.info(EmulatedMkbHandler.LOG_TAG, 'constructor()');
this.#STICK_MAP = {
[GamepadKey.LS_LEFT]: [this.#LEFT_STICK_X, 0, -1],
@ -431,7 +431,7 @@ export class EmulatedMkbHandler extends MkbHandler {
#getCurrentPreset = (): Promise<MkbStoredPreset> => {
return new Promise(resolve => {
const presetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
LocalDb.INSTANCE.getPreset(presetId).then((preset: MkbStoredPreset) => {
MkbPresetsDb.getInstance().getPreset(presetId).then((preset: MkbStoredPreset) => {
resolve(preset);
});
});
@ -680,7 +680,7 @@ export class EmulatedMkbHandler extends MkbHandler {
AppInterface && NativeMkbHandler.getInstance().init();
}
} else if (getPref(PrefKey.MKB_ENABLED) && (AppInterface || !UserAgent.isMobile())) {
BxLogger.info(LOG_TAG, 'Emulate MKB');
BxLogger.info(EmulatedMkbHandler.LOG_TAG, 'Emulate MKB');
EmulatedMkbHandler.getInstance().init();
}
});

View File

@ -130,7 +130,6 @@ export class MkbPreset {
mouse[MkbPresetKey.MOUSE_MAP_TO] = MkbPreset.MOUSE_SETTINGS[MkbPresetKey.MOUSE_MAP_TO].default;
}
console.log(obj);
return obj;
}
}

View File

@ -4,7 +4,6 @@ import { Dialog } from "@modules/dialog";
import { KeyHelper } from "./key-helper";
import { MkbPreset } from "./mkb-preset";
import { EmulatedMkbHandler } from "./mkb-handler";
import { LocalDb } from "@utils/local-db";
import { BxIcon } from "@utils/bx-icon";
import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb";
import { MkbPresetKey, GamepadKey, GamepadKeyName } from "@enums/mkb";
@ -12,18 +11,10 @@ import { deepClone } from "@utils/global";
import { SettingElement } from "@/utils/setting-element";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
import { MkbPresetsDb } from "@/utils/local-db/mkb-presets-db";
import { BxLogger } from "@/utils/bx-logger";
type MkbRemapperElements = {
wrapper: HTMLElement | null;
presetsSelect: HTMLSelectElement | null;
activateButton: HTMLButtonElement | null;
currentBindingKey: HTMLElement | null;
allKeyElements: HTMLElement[];
allMouseElements: {[key in MkbPresetKey]?: HTMLElement};
};
type MkbRemapperStates = {
currentPresetId: number;
presets: MkbStoredPresets;
@ -33,7 +24,7 @@ type MkbRemapperStates = {
};
export class MkbRemapper {
readonly #BUTTON_ORDERS = [
private readonly BUTTON_ORDERS = [
GamepadKey.UP,
GamepadKey.DOWN,
GamepadKey.LEFT,
@ -66,16 +57,11 @@ export class MkbRemapper {
GamepadKey.RS_RIGHT,
];
static #instance: MkbRemapper;
static get INSTANCE() {
if (!MkbRemapper.#instance) {
MkbRemapper.#instance = new MkbRemapper();
}
private static instance: MkbRemapper;
public static getInstance = () => MkbRemapper.instance ?? (MkbRemapper.instance = new MkbRemapper());
private readonly LOG_TAG = 'MkbRemapper';
return MkbRemapper.#instance;
};
#STATE: MkbRemapperStates = {
private STATE: MkbRemapperStates = {
currentPresetId: 0,
presets: {},
@ -84,151 +70,150 @@ export class MkbRemapper {
isEditing: false,
};
#$: MkbRemapperElements = {
wrapper: null,
presetsSelect: null,
activateButton: null,
private $wrapper!: HTMLElement;
private $presetsSelect!: HTMLSelectElement;
private $activateButton!: HTMLButtonElement;
currentBindingKey: null,
private $currentBindingKey!: HTMLElement;
allKeyElements: [],
allMouseElements: {},
};
private allKeyElements: HTMLElement[] = [];
private allMouseElements: {[key in MkbPresetKey]?: HTMLElement} = {};
bindingDialog: Dialog;
constructor() {
this.#STATE.currentPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.STATE.currentPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
this.bindingDialog = new Dialog({
className: 'bx-binding-dialog',
content: CE('div', {},
CE('p', {}, t('press-to-bind')),
CE('i', {}, t('press-esc-to-cancel')),
),
CE('p', {}, t('press-to-bind')),
CE('i', {}, t('press-esc-to-cancel')),
),
hideCloseButton: true,
});
}
#clearEventListeners = () => {
window.removeEventListener('keydown', this.#onKeyDown);
window.removeEventListener('mousedown', this.#onMouseDown);
window.removeEventListener('wheel', this.#onWheel);
private clearEventListeners = () => {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('mousedown', this.onMouseDown);
window.removeEventListener('wheel', this.onWheel);
};
#bindKey = ($elm: HTMLElement, key: any) => {
const buttonIndex = parseInt($elm.getAttribute('data-button-index')!);
const keySlot = parseInt($elm.getAttribute('data-key-slot')!);
private bindKey = ($elm: HTMLElement, key: any) => {
const buttonIndex = parseInt($elm.dataset.buttonIndex!);
const keySlot = parseInt($elm.dataset.keySlot!);
// Ignore if bind the save key to the same element
if ($elm.getAttribute('data-key-code') === key.code) {
if ($elm.dataset.keyCode! === key.code) {
return;
}
// Unbind duplicated keys
for (const $otherElm of this.#$.allKeyElements) {
if ($otherElm.getAttribute('data-key-code') === key.code) {
this.#unbindKey($otherElm);
for (const $otherElm of this.allKeyElements) {
if ($otherElm.dataset.keyCode === key.code) {
this.unbindKey($otherElm);
}
}
this.#STATE.editingPresetData!.mapping[buttonIndex][keySlot] = key.code;
this.STATE.editingPresetData!.mapping[buttonIndex][keySlot] = key.code;
$elm.textContent = key.name;
$elm.setAttribute('data-key-code', key.code);
$elm.dataset.keyCode = key.code;
}
#unbindKey = ($elm: HTMLElement) => {
const buttonIndex = parseInt($elm.getAttribute('data-button-index')!);
const keySlot = parseInt($elm.getAttribute('data-key-slot')!);
private unbindKey = ($elm: HTMLElement) => {
const buttonIndex = parseInt($elm.dataset.buttonIndex!);
const keySlot = parseInt($elm.dataset.keySlot!);
// Remove key from preset
this.#STATE.editingPresetData!.mapping[buttonIndex][keySlot] = null;
this.STATE.editingPresetData!.mapping[buttonIndex][keySlot] = null;
$elm.textContent = '';
$elm.removeAttribute('data-key-code');
delete $elm.dataset.keyCode;
}
#onWheel = (e: WheelEvent) => {
private onWheel = (e: WheelEvent) => {
e.preventDefault();
this.#clearEventListeners();
this.clearEventListeners();
this.#bindKey(this.#$.currentBindingKey!, KeyHelper.getKeyFromEvent(e));
this.bindKey(this.$currentBindingKey!, KeyHelper.getKeyFromEvent(e));
window.setTimeout(() => this.bindingDialog.hide(), 200);
};
#onMouseDown = (e: MouseEvent) => {
private onMouseDown = (e: MouseEvent) => {
e.preventDefault();
this.#clearEventListeners();
this.clearEventListeners();
this.#bindKey(this.#$.currentBindingKey!, KeyHelper.getKeyFromEvent(e));
this.bindKey(this.$currentBindingKey!, KeyHelper.getKeyFromEvent(e));
window.setTimeout(() => this.bindingDialog.hide(), 200);
};
#onKeyDown = (e: KeyboardEvent) => {
private onKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
this.#clearEventListeners();
this.clearEventListeners();
if (e.code !== 'Escape') {
this.#bindKey(this.#$.currentBindingKey!, KeyHelper.getKeyFromEvent(e));
this.bindKey(this.$currentBindingKey!, KeyHelper.getKeyFromEvent(e));
}
window.setTimeout(() => this.bindingDialog.hide(), 200);
};
#onBindingKey = (e: MouseEvent) => {
if (!this.#STATE.isEditing || e.button !== 0) {
private onBindingKey = (e: MouseEvent) => {
if (!this.STATE.isEditing || e.button !== 0) {
return;
}
console.log(e);
this.#$.currentBindingKey = e.target as HTMLElement;
this.$currentBindingKey = e.target as HTMLElement;
window.addEventListener('keydown', this.#onKeyDown);
window.addEventListener('mousedown', this.#onMouseDown);
window.addEventListener('wheel', this.#onWheel);
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('mousedown', this.onMouseDown);
window.addEventListener('wheel', this.onWheel);
this.bindingDialog.show({title: this.#$.currentBindingKey.getAttribute('data-prompt')!});
this.bindingDialog.show({title: this.$currentBindingKey.dataset.prompt!});
};
#onContextMenu = (e: Event) => {
private onContextMenu = (e: Event) => {
e.preventDefault();
if (!this.#STATE.isEditing) {
if (!this.STATE.isEditing) {
return;
}
this.#unbindKey(e.target as HTMLElement);
this.unbindKey(e.target as HTMLElement);
};
#getPreset = (presetId: number) => {
return this.#STATE.presets[presetId];
private getPreset = (presetId: number) => {
return this.STATE.presets[presetId];
}
#getCurrentPreset = () => {
return this.#getPreset(this.#STATE.currentPresetId);
private getCurrentPreset = () => {
return this.getPreset(this.STATE.currentPresetId);
}
#switchPreset = (presetId: number) => {
this.#STATE.currentPresetId = presetId;
const presetData = this.#getCurrentPreset().data;
private switchPreset = (presetId: number) => {
this.STATE.currentPresetId = presetId;
const presetData = this.getCurrentPreset().data;
for (const $elm of this.#$.allKeyElements) {
const buttonIndex = parseInt($elm.getAttribute('data-button-index')!);
const keySlot = parseInt($elm.getAttribute('data-key-slot')!);
for (const $elm of this.allKeyElements) {
const buttonIndex = parseInt($elm.dataset.buttonIndex!);
const keySlot = parseInt($elm.dataset.keySlot!);
const buttonKeys = presetData.mapping[buttonIndex];
if (buttonKeys && buttonKeys[keySlot]) {
$elm.textContent = KeyHelper.codeToKeyName(buttonKeys[keySlot]!);
$elm.setAttribute('data-key-code', buttonKeys[keySlot]!);
$elm.dataset.keyCode = buttonKeys[keySlot]!;
} else {
$elm.textContent = '';
$elm.removeAttribute('data-key-code');
delete $elm.dataset.keyCode;
}
}
let key: MkbPresetKey;
for (key in this.#$.allMouseElements) {
const $elm = this.#$.allMouseElements[key]!;
for (key in this.allMouseElements) {
const $elm = this.allMouseElements[key]!;
let value = presetData.mouse[key];
if (typeof value === 'undefined') {
value = MkbPreset.MOUSE_SETTINGS[key].default;
@ -238,26 +223,26 @@ export class MkbRemapper {
}
// Update state of Activate button
const activated = getPref(PrefKey.MKB_DEFAULT_PRESET_ID) === this.#STATE.currentPresetId;
this.#$.activateButton!.disabled = activated;
this.#$.activateButton!.querySelector('span')!.textContent = activated ? t('activated') : t('activate');
const activated = getPref(PrefKey.MKB_DEFAULT_PRESET_ID) === this.STATE.currentPresetId;
this.$activateButton.disabled = activated;
this.$activateButton.querySelector('span')!.textContent = activated ? t('activated') : t('activate');
}
#refresh() {
private refresh() {
// Clear presets select
while (this.#$.presetsSelect!.firstChild) {
this.#$.presetsSelect!.removeChild(this.#$.presetsSelect!.firstChild);
while (this.$presetsSelect.firstChild) {
this.$presetsSelect.removeChild(this.$presetsSelect.firstChild);
}
LocalDb.INSTANCE.getPresets().then(presets => {
this.#STATE.presets = presets;
MkbPresetsDb.getInstance().getPresets().then(presets => {
this.STATE.presets = presets;
const $fragment = document.createDocumentFragment();
let defaultPresetId;
if (this.#STATE.currentPresetId === 0) {
this.#STATE.currentPresetId = parseInt(Object.keys(presets)[0]);
if (this.STATE.currentPresetId === 0) {
this.STATE.currentPresetId = parseInt(Object.keys(presets)[0]);
defaultPresetId = this.#STATE.currentPresetId;
defaultPresetId = this.STATE.currentPresetId;
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, defaultPresetId);
EmulatedMkbHandler.getInstance().refreshPresetData();
} else {
@ -272,40 +257,40 @@ export class MkbRemapper {
}
const $options = CE<HTMLOptionElement>('option', {value: id}, name);
$options.selected = parseInt(id) === this.#STATE.currentPresetId;
$options.selected = parseInt(id) === this.STATE.currentPresetId;
$fragment.appendChild($options);
};
this.#$.presetsSelect!.appendChild($fragment);
this.$presetsSelect.appendChild($fragment);
// Update state of Activate button
const activated = defaultPresetId === this.#STATE.currentPresetId;
this.#$.activateButton!.disabled = activated;
this.#$.activateButton!.querySelector('span')!.textContent = activated ? t('activated') : t('activate');
const activated = defaultPresetId === this.STATE.currentPresetId;
this.$activateButton.disabled = activated;
this.$activateButton.querySelector('span')!.textContent = activated ? t('activated') : t('activate');
!this.#STATE.isEditing && this.#switchPreset(this.#STATE.currentPresetId);
!this.STATE.isEditing && this.switchPreset(this.STATE.currentPresetId);
});
}
#toggleEditing = (force?: boolean) => {
this.#STATE.isEditing = typeof force !== 'undefined' ? force : !this.#STATE.isEditing;
this.#$.wrapper!.classList.toggle('bx-editing', this.#STATE.isEditing);
private toggleEditing = (force?: boolean) => {
this.STATE.isEditing = typeof force !== 'undefined' ? force : !this.STATE.isEditing;
this.$wrapper.classList.toggle('bx-editing', this.STATE.isEditing);
if (this.#STATE.isEditing) {
this.#STATE.editingPresetData = deepClone(this.#getCurrentPreset().data);
if (this.STATE.isEditing) {
this.STATE.editingPresetData = deepClone(this.getCurrentPreset().data);
} else {
this.#STATE.editingPresetData = null;
this.STATE.editingPresetData = null;
}
const childElements = this.#$.wrapper!.querySelectorAll('select, button, input');
const childElements = this.$wrapper.querySelectorAll('select, button, input');
for (const $elm of Array.from(childElements)) {
if ($elm.parentElement!.parentElement!.classList.contains('bx-mkb-action-buttons')) {
continue;
}
let disable = !this.#STATE.isEditing;
let disable = !this.STATE.isEditing;
if ($elm.parentElement!.classList.contains('bx-mkb-preset-tools')) {
disable = !disable;
@ -316,14 +301,14 @@ export class MkbRemapper {
}
render() {
this.#$.wrapper = CE('div', {'class': 'bx-mkb-settings'});
this.$wrapper = CE('div', {class: 'bx-mkb-settings'});
this.#$.presetsSelect = CE<HTMLSelectElement>('select', {tabindex: -1});
this.#$.presetsSelect!.addEventListener('change', e => {
this.#switchPreset(parseInt((e.target as HTMLSelectElement).value));
this.$presetsSelect = CE<HTMLSelectElement>('select', {tabindex: -1});
this.$presetsSelect.addEventListener('change', e => {
this.switchPreset(parseInt((e.target as HTMLSelectElement).value));
});
const promptNewName = (value?: string) => {
const promptNewName = (value: string) => {
let newName: string | null = '';
while (!newName) {
newName = prompt(t('prompt-preset-name'), value);
@ -336,15 +321,15 @@ export class MkbRemapper {
return newName ? newName : false;
};
const $header = CE('div', {'class': 'bx-mkb-preset-tools'},
this.#$.presetsSelect,
const $header = CE('div', {class: 'bx-mkb-preset-tools'},
this.$presetsSelect,
// Rename button
createButton({
title: t('rename'),
icon: BxIcon.CURSOR_TEXT,
tabIndex: -1,
onClick: e => {
const preset = this.#getCurrentPreset();
const preset = this.getCurrentPreset();
let newName = promptNewName(preset.name);
if (!newName || newName === preset.name) {
@ -353,28 +338,28 @@ export class MkbRemapper {
// Update preset with new name
preset.name = newName;
LocalDb.INSTANCE.updatePreset(preset).then(id => this.#refresh());
MkbPresetsDb.getInstance().updatePreset(preset).then(id => this.refresh());
},
}),
// New button
createButton({
icon: BxIcon.NEW,
title: t('new'),
tabIndex: -1,
onClick: e => {
let newName = promptNewName('');
if (!newName) {
return;
}
icon: BxIcon.NEW,
title: t('new'),
tabIndex: -1,
onClick: e => {
let newName = promptNewName('');
if (!newName) {
return;
}
// Create new preset selected name
LocalDb.INSTANCE.newPreset(newName, MkbPreset.DEFAULT_PRESET).then(id => {
this.#STATE.currentPresetId = id;
this.#refresh();
});
},
}),
// Create new preset selected name
MkbPresetsDb.getInstance().newPreset(newName, MkbPreset.DEFAULT_PRESET).then(id => {
this.STATE.currentPresetId = id;
this.refresh();
});
},
}),
// Copy button
createButton({
@ -382,7 +367,7 @@ export class MkbRemapper {
title: t('copy'),
tabIndex: -1,
onClick: e => {
const preset = this.#getCurrentPreset();
const preset = this.getCurrentPreset();
let newName = promptNewName(`${preset.name} (2)`);
if (!newName) {
@ -390,9 +375,9 @@ export class MkbRemapper {
}
// Create new preset selected name
LocalDb.INSTANCE.newPreset(newName, preset.data).then(id => {
this.#STATE.currentPresetId = id;
this.#refresh();
MkbPresetsDb.getInstance().newPreset(newName, preset.data).then(id => {
this.STATE.currentPresetId = id;
this.refresh();
});
},
}),
@ -408,23 +393,23 @@ export class MkbRemapper {
return;
}
LocalDb.INSTANCE.deletePreset(this.#STATE.currentPresetId).then(id => {
this.#STATE.currentPresetId = 0;
this.#refresh();
MkbPresetsDb.getInstance().deletePreset(this.STATE.currentPresetId).then(id => {
this.STATE.currentPresetId = 0;
this.refresh();
});
},
}),
);
this.#$.wrapper!.appendChild($header);
this.$wrapper.appendChild($header);
const $rows = CE('div', {'class': 'bx-mkb-settings-rows'},
CE('i', {'class': 'bx-mkb-note'}, t('right-click-to-unbind')),
const $rows = CE('div', {class: 'bx-mkb-settings-rows'},
CE('i', {class: 'bx-mkb-note'}, t('right-click-to-unbind')),
);
// Render keys
const keysPerButton = 2;
for (const buttonIndex of this.#BUTTON_ORDERS) {
for (const buttonIndex of this.BUTTON_ORDERS) {
const [buttonName, buttonPrompt] = GamepadKeyName[buttonIndex];
let $elm;
@ -437,22 +422,22 @@ export class MkbRemapper {
'data-key-slot': i,
}, ' ');
$elm.addEventListener('mouseup', this.#onBindingKey);
$elm.addEventListener('contextmenu', this.#onContextMenu);
$elm.addEventListener('mouseup', this.onBindingKey);
$elm.addEventListener('contextmenu', this.onContextMenu);
$fragment.appendChild($elm);
this.#$.allKeyElements.push($elm);
this.allKeyElements.push($elm);
}
const $keyRow = CE('div', {'class': 'bx-mkb-key-row'},
CE('label', {'title': buttonName}, buttonPrompt),
const $keyRow = CE('div', {class: 'bx-mkb-key-row'},
CE('label', {title: buttonName}, buttonPrompt),
$fragment,
);
$rows.appendChild($keyRow);
}
$rows.appendChild(CE('i', {'class': 'bx-mkb-note'}, t('mkb-adjust-ingame-settings')),);
$rows.appendChild(CE('i', {class: 'bx-mkb-note'}, t('mkb-adjust-ingame-settings')),);
// Render mouse settings
const $mouseSettings = document.createDocumentFragment();
@ -463,7 +448,7 @@ export class MkbRemapper {
let $elm;
const onChange = (e: Event, value: any) => {
(this.#STATE.editingPresetData!.mouse as any)[key] = value;
(this.STATE.editingPresetData!.mouse as any)[key] = value;
};
const $row = CE('label', {
class: 'bx-settings-row',
@ -474,32 +459,32 @@ export class MkbRemapper {
);
$mouseSettings.appendChild($row);
this.#$.allMouseElements[key as MkbPresetKey] = $elm;
this.allMouseElements[key as MkbPresetKey] = $elm;
}
$rows.appendChild($mouseSettings);
this.#$.wrapper!.appendChild($rows);
this.$wrapper.appendChild($rows);
// Render action buttons
const $actionButtons = CE('div', {'class': 'bx-mkb-action-buttons'},
const $actionButtons = CE('div', {class: 'bx-mkb-action-buttons'},
CE('div', {},
// Edit button
createButton({
label: t('edit'),
tabIndex: -1,
onClick: e => this.#toggleEditing(true),
onClick: e => this.toggleEditing(true),
}),
// Activate button
this.#$.activateButton = createButton({
this.$activateButton = createButton({
label: t('activate'),
style: ButtonStyle.PRIMARY,
tabIndex: -1,
onClick: e => {
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId);
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.STATE.currentPresetId);
EmulatedMkbHandler.getInstance().refreshPresetData();
this.#refresh();
this.refresh();
},
}),
),
@ -512,8 +497,8 @@ export class MkbRemapper {
tabIndex: -1,
onClick: e => {
// Restore preset
this.#switchPreset(this.#STATE.currentPresetId);
this.#toggleEditing(false);
this.switchPreset(this.STATE.currentPresetId);
this.toggleEditing(false);
},
}),
@ -523,27 +508,27 @@ export class MkbRemapper {
style: ButtonStyle.PRIMARY,
tabIndex: -1,
onClick: e => {
const updatedPreset = deepClone(this.#getCurrentPreset());
updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData;
const updatedPreset = deepClone(this.getCurrentPreset());
updatedPreset.data = this.STATE.editingPresetData as MkbPresetData;
LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => {
MkbPresetsDb.getInstance().updatePreset(updatedPreset).then(id => {
// If this is the default preset => refresh preset data
if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) {
EmulatedMkbHandler.getInstance().refreshPresetData();
}
this.#toggleEditing(false);
this.#refresh();
this.toggleEditing(false);
this.refresh();
});
},
}),
),
);
this.#$.wrapper!.appendChild($actionButtons);
this.$wrapper.appendChild($actionButtons);
this.#toggleEditing(false);
this.#refresh();
return this.#$.wrapper;
this.toggleEditing(false);
this.refresh();
return this.$wrapper;
}
}

View File

@ -7,6 +7,7 @@ import { BxEvent } from "@/utils/bx-event";
import { ButtonStyle, CE, createButton } from "@/utils/html";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { BxLogger } from "@/utils/bx-logger";
type NativeMouseData = {
X: number,
@ -24,6 +25,7 @@ type XcloudInputSink = {
export class NativeMkbHandler extends MkbHandler {
private static instance: NativeMkbHandler;
public static getInstance = () => NativeMkbHandler.instance ?? (NativeMkbHandler.instance = new NativeMkbHandler());
private readonly LOG_TAG = 'NativeMkbHandler';
#pointerClient: PointerClient | undefined;
#enabled: boolean = false;
@ -39,6 +41,11 @@ export class NativeMkbHandler extends MkbHandler {
#$message?: HTMLElement;
private constructor() {
super();
BxLogger.info(this.LOG_TAG, 'constructor()');
}
#onKeyboardEvent(e: KeyboardEvent) {
if (e.type === 'keyup' && e.code === 'F8') {
e.preventDefault();

View File

@ -2,8 +2,6 @@ import { BxLogger } from "@/utils/bx-logger";
import { Toast } from "@/utils/toast";
import type { MkbHandler } from "./base-mkb-handler";
const LOG_TAG = 'PointerClient';
enum PointerAction {
MOVE = 1,
BUTTON_PRESS = 2,
@ -16,10 +14,15 @@ enum PointerAction {
export class PointerClient {
private static instance: PointerClient;
public static getInstance = () => PointerClient.instance ?? (PointerClient.instance = new PointerClient());
private readonly LOG_TAG = 'PointerClient';
private socket: WebSocket | undefined | null;
private mkbHandler: MkbHandler | undefined;
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
}
start(port: number, mkbHandler: MkbHandler) {
if (!port) {
throw new Error('PointerServer port is 0');
@ -33,12 +36,12 @@ export class PointerClient {
// Connection opened
this.socket.addEventListener('open', (event) => {
BxLogger.info(LOG_TAG, 'connected')
BxLogger.info(this.LOG_TAG, 'connected')
});
// Error
this.socket.addEventListener('error', (event) => {
BxLogger.error(LOG_TAG, event);
BxLogger.error(this.LOG_TAG, event);
Toast.show('Cannot setup mouse: ' + event);
});

View File

@ -1212,7 +1212,7 @@ export class PatcherCache {
*/
static #getSignature(): number {
const scriptVersion = SCRIPT_VERSION;
const webVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement)?.content;
const webVersion = (document.querySelector<HTMLMetaElement>('meta[name=gamepass-app-version]'))?.content;
const patches = JSON.stringify(ALL_PATCHES);
// Calculate signature

View File

@ -9,8 +9,6 @@ import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
import { RemotePlayNavigationDialog } from "./ui/dialog/remote-play-dialog";
const LOG_TAG = 'RemotePlay';
export const enum RemotePlayConsoleState {
ON = 'On',
OFF = 'Off',
@ -38,6 +36,7 @@ type RemotePlayConsole = {
export class RemotePlayManager {
private static instance: RemotePlayManager;
public static getInstance = () => RemotePlayManager.instance ?? (RemotePlayManager.instance = new RemotePlayManager());
private readonly LOG_TAG = 'RemotePlayManager';
private isInitialized = false;
@ -47,6 +46,10 @@ export class RemotePlayManager {
private consoles!: Array<RemotePlayConsole>;
private regions: Array<RemotePlayRegion> = [];
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
}
initialize() {
if (this.isInitialized) {
return;
@ -56,9 +59,9 @@ export class RemotePlayManager {
this.getXhomeToken(() => {
this.getConsolesList(() => {
BxLogger.info(LOG_TAG, 'Consoles', this.consoles);
BxLogger.info(this.LOG_TAG, 'Consoles', this.consoles);
STATES.supportedRegion && HeaderSection.showRemotePlayButton();
STATES.supportedRegion && HeaderSection.getInstance().showRemotePlayButton();
BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY);
});
});

View File

@ -77,13 +77,7 @@ export class SoundShortcut {
return;
}
let $media: HTMLMediaElement;
$media = document.querySelector('div[data-testid=media-container] audio') as HTMLAudioElement;
if (!$media) {
$media = document.querySelector('div[data-testid=media-container] video') as HTMLAudioElement;
}
const $media = document.querySelector<HTMLAudioElement>('div[data-testid=media-container] audio') ?? document.querySelector<HTMLAudioElement>('div[data-testid=media-container] video');
if ($media) {
$media.muted = !$media.muted;

View File

@ -2,7 +2,7 @@ import { isFullVersion } from "@macros/build" with {type: "macro"};
import { CE } from "@/utils/html";
import { WebGL2Player } from "./player/webgl2-player";
import { Screenshot } from "@/utils/screenshot";
import { ScreenshotManager } from "@/utils/screenshot-manager";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { STATES } from "@/utils/global";
import { PrefKey } from "@/enums/pref-keys";
@ -237,7 +237,7 @@ export class StreamPlayer {
webGL2Player.setFilter(2);
}
isFullVersion() && Screenshot.updateCanvasFilters('none');
isFullVersion() && ScreenshotManager.getInstance().updateCanvasFilters('none');
webGL2Player.setSharpness(options.sharpness || 0);
webGL2Player.setSaturation(options.saturation || 100);
@ -252,7 +252,7 @@ export class StreamPlayer {
// Apply video filters to screenshots
if (isFullVersion() && getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
Screenshot.updateCanvasFilters(filters);
ScreenshotManager.getInstance().updateCanvasFilters(filters);
}
let css = '';

View File

@ -50,6 +50,7 @@ enum StreamBadge {
export class StreamBadges {
private static instance: StreamBadges;
public static getInstance = () => StreamBadges.instance ?? (StreamBadges.instance = new StreamBadges());
private readonly LOG_TAG = 'StreamBadges';
private serverInfo: StreamServerInfo = {};
@ -96,6 +97,10 @@ export class StreamBadges {
private intervalId?: number | null;
private readonly REFRESH_INTERVAL = 3 * 1000;
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
}
setRegion(region: string) {
this.serverInfo.server = {
region: region,

View File

@ -18,7 +18,7 @@ export function onChangeVideoPlayerType() {
let isDisabled = false;
const $optCas = $videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement;
const $optCas = $videoProcessing.querySelector<HTMLOptionElement>(`option[value=${StreamVideoProcessing.CAS}]`);
if (playerType === StreamPlayerType.WEBGL2) {
$optCas && ($optCas.disabled = false);

View File

@ -5,11 +5,13 @@ import { STATES } from "@utils/global"
import { PrefKey } from "@/enums/pref-keys"
import { getPref } from "@/utils/settings-storages/global-settings-storage"
import { StreamStat, StreamStatsCollector, type StreamStatGrade } from "@/utils/stream-stats-collector"
import { BxLogger } from "@/utils/bx-logger"
export class StreamStats {
private static instance: StreamStats;
public static getInstance = () => StreamStats.instance ?? (StreamStats.instance = new StreamStats());
private readonly LOG_TAG = 'StreamStats';
private intervalId?: number | null;
private readonly REFRESH_INTERVAL = 1 * 1000;
@ -69,7 +71,8 @@ export class StreamStats {
quickGlanceObserver?: MutationObserver | null;
constructor() {
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.render();
}

View File

@ -39,7 +39,7 @@ export class StreamUiHandler {
return;
}
const $streamHud = (e.target as HTMLElement).closest('#StreamHud') as HTMLElement;
const $streamHud = (e.target as HTMLElement).closest<HTMLElement>('#StreamHud');
if (!$streamHud) {
return;
}
@ -58,13 +58,13 @@ export class StreamUiHandler {
$container.addEventListener('transitionend', onTransitionEnd);
}
const $button = $container.querySelector('button') as HTMLElement;
const $button = $container.querySelector<HTMLButtonElement>('button');
if (!$button) {
return null;
}
$button.setAttribute('title', label);
const $orgSvg = $button.querySelector('svg') as SVGElement;
const $orgSvg = $button.querySelector<SVGElement>('svg');
if (!$orgSvg) {
return null;
}
@ -102,7 +102,7 @@ export class StreamUiHandler {
}
private static async handleStreamMenu() {
const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]') as HTMLElement;
const $btnCloseHud = document.querySelector<HTMLElement>('button[class*=StreamMenu-module__backButton]');
if (!$btnCloseHud) {
return;
}
@ -136,14 +136,14 @@ export class StreamUiHandler {
private static handleSystemMenu($streamHud: HTMLElement) {
// Get the last button
const $orgButton = $streamHud.querySelector('div[class^=HUDButton]') as HTMLElement;
const $orgButton = $streamHud.querySelector<HTMLElement>('div[class^=HUDButton]');
if (!$orgButton) {
return;
}
const hideGripHandle = () => {
// Grip handle
const $gripHandle = document.querySelector('#StreamHud button[class^=GripHandle]') as HTMLElement;
const $gripHandle = document.querySelector<HTMLElement>('#StreamHud button[class^=GripHandle]');
if ($gripHandle && $gripHandle.ariaExpanded === 'true') {
$gripHandle.dispatchEvent(new PointerEvent('pointerdown'));
$gripHandle.click();

View File

@ -2,6 +2,7 @@ import { GamepadKey } from "@/enums/mkb";
import { PrefKey } from "@/enums/pref-keys";
import { VIRTUAL_GAMEPAD_ID } from "@/modules/mkb/mkb-handler";
import { BxEvent } from "@/utils/bx-event";
import { BxLogger } from "@/utils/bx-logger";
import { STATES } from "@/utils/global";
import { CE, isElementVisible } from "@/utils/html";
import { setNearby } from "@/utils/navigation-utils";
@ -89,6 +90,7 @@ export abstract class NavigationDialog {
export class NavigationDialogManager {
private static instance: NavigationDialogManager;
public static getInstance = () => NavigationDialogManager.instance ?? (NavigationDialogManager.instance = new NavigationDialogManager());
private readonly LOG_TAG = 'NavigationDialogManager';
private static readonly GAMEPAD_POLLING_INTERVAL = 50;
private static readonly GAMEPAD_KEYS = [
@ -136,7 +138,9 @@ export class NavigationDialogManager {
private $container: HTMLElement;
private dialog: NavigationDialog | null = null;
constructor() {
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.$overlay = CE('div', {class: 'bx-navigation-dialog-overlay bx-gone'});
this.$overlay.addEventListener('click', e => {
e.preventDefault();
@ -185,17 +189,17 @@ export class NavigationDialogManager {
const rect = $select.getBoundingClientRect();
let $label;
let $label: HTMLElement;
let width = Math.ceil(rect.width);
if (!width) {
return;
}
if (($select as HTMLSelectElement).multiple) {
$label = $parent.querySelector('.bx-select-value') as HTMLElement;
$label = $parent.querySelector<HTMLElement>('.bx-select-value')!;
width += 20; // Add checkbox's width
} else {
$label = $parent.querySelector('div') as HTMLElement;
$label = $parent.querySelector<HTMLElement>('div')!;
}
// Set min-width

View File

@ -7,11 +7,13 @@ import { t } from "@/utils/translation";
import { RemotePlayConsoleState, RemotePlayManager } from "@/modules/remote-play-manager";
import { BxSelectElement } from "@/web-components/bx-select";
import { BxEvent } from "@/utils/bx-event";
import { BxLogger } from "@/utils/bx-logger";
export class RemotePlayNavigationDialog extends NavigationDialog {
private static instance: RemotePlayNavigationDialog;
public static getInstance = () => RemotePlayNavigationDialog.instance ?? (RemotePlayNavigationDialog.instance = new RemotePlayNavigationDialog());
private readonly LOG_TAG = 'RemotePlayNavigationDialog';
private readonly STATE_LABELS: Record<RemotePlayConsoleState, string> = {
[RemotePlayConsoleState.ON]: t('powered-on'),
@ -22,8 +24,9 @@ export class RemotePlayNavigationDialog extends NavigationDialog {
$container!: HTMLElement;
constructor() {
private constructor() {
super();
BxLogger.info(this.LOG_TAG, 'constructor()');
this.setupDialog();
}
@ -124,7 +127,7 @@ export class RemotePlayNavigationDialog extends NavigationDialog {
}
focusIfNeeded(): void {
const $btnConnect = this.$container.querySelector('.bx-remote-play-device-wrapper button') as HTMLElement;
const $btnConnect = this.$container.querySelector<HTMLElement>('.bx-remote-play-device-wrapper button');
$btnConnect && $btnConnect.focus();
}
}

View File

@ -27,12 +27,13 @@ import { ControllerDeviceVibration, getPref, getPrefDefinition, setPref, StreamT
import { SettingElement, type BxHtmlSettingElement } from "@/utils/setting-element";
import type { RecommendedSettings, SettingDefinition, SuggestedSettingCategory as SuggestedSettingProfile } from "@/types/setting-definition";
import { FullscreenText } from "../fullscreen-text";
import { BxLogger } from "@/utils/bx-logger";
type SettingTabContentItem = Partial<{
pref: PrefKey;
label: string;
note: string;
note: string | (() => HTMLElement);
experimental: string;
content: HTMLElement | (() => HTMLElement);
options: {[key: string]: string};
@ -51,24 +52,29 @@ type SettingTabContent = {
unsupportedNote?: string | Text | null;
helpUrl?: string;
content?: any;
lazyContent?: boolean | (() => HTMLElement);
items?: Array<SettingTabContentItem | PrefKey | (($parent: HTMLElement) => void) | false>;
requiredVariants?: BuildVariant | Array<BuildVariant>;
};
type SettingTab = {
icon: SVGElement;
group: 'global';
items: Array<SettingTabContent | false>;
group: SettingTabGroup,
items: Array<SettingTabContent | false> | (() => Array<SettingTabContent | false>);
requiredVariants?: BuildVariant | Array<BuildVariant>;
lazyContent?: boolean;
};
type SettingTabGroup = 'global' | 'stream' | 'controller' | 'mkb' | 'native-mkb' | 'shortcuts' | 'stats';
export class SettingsNavigationDialog extends NavigationDialog {
private static instance: SettingsNavigationDialog;
public static getInstance = () => SettingsNavigationDialog.instance ?? (SettingsNavigationDialog.instance = new SettingsNavigationDialog());
private readonly LOG_TAG = 'SettingsNavigationDialog';
$container!: HTMLElement;
private $tabs!: HTMLElement;
private $settings!: HTMLElement;
private $tabContents!: HTMLElement;
private $btnReload!: HTMLElement;
private $btnGlobalReload!: HTMLButtonElement;
@ -326,8 +332,8 @@ export class SettingsNavigationDialog extends NavigationDialog {
// xCloud version
($parent) => {
try {
const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content;
const appDate = new Date((document.querySelector('meta[name=gamepass-app-date]') as HTMLMetaElement).content).toISOString().substring(0, 10);
const appVersion = document.querySelector<HTMLMetaElement>('meta[name=gamepass-app-version]')!.content;
const appDate = new Date(document.querySelector<HTMLMetaElement>('meta[name=gamepass-app-date]')!.content).toISOString().substring(0, 10);
$parent.appendChild(CE('div', {
class: 'bx-settings-app-version',
}, `xCloud website version ${appVersion} (${appDate})`));
@ -380,7 +386,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL),
},
onCreated: (setting: SettingTabContentItem, $elm: HTMLElement) => {
const $range = $elm.querySelector('input[type=range') as HTMLInputElement;
const $range = $elm.querySelector<HTMLInputElement>('input[type=range')!;
window.addEventListener(BxEvent.SETTINGS_CHANGED, e => {
const { storageKey, settingKey, settingValue } = e as any;
if (storageKey !== StorageKey.GLOBAL || settingKey !== PrefKey.AUDIO_VOLUME) {
@ -511,11 +517,11 @@ export class SettingsNavigationDialog extends NavigationDialog {
}],
}];
private readonly TAB_VIRTUAL_CONTROLLER_ITEMS: Array<SettingTabContent | false> = [{
private readonly TAB_VIRTUAL_CONTROLLER_ITEMS: (() => Array<SettingTabContent | false>) = () => [{
group: 'mkb',
label: t('virtual-controller'),
helpUrl: 'https://better-xcloud.github.io/mouse-and-keyboard/',
content: isFullVersion() && MkbRemapper.INSTANCE.render(),
content: MkbRemapper.getInstance().render(),
}];
private readonly TAB_NATIVE_MKB_ITEMS: Array<SettingTabContent | false> = [{
@ -535,7 +541,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
}] : [],
}];
private readonly TAB_SHORTCUTS_ITEMS: Array<SettingTabContent | false> = [{
private readonly TAB_SHORTCUTS_ITEMS: (() => Array<SettingTabContent | false>) = () => [{
requiredVariants: 'full',
group: 'controller-shortcuts',
label: t('controller-shortcuts'),
@ -576,56 +582,59 @@ export class SettingsNavigationDialog extends NavigationDialog {
],
}];
private readonly SETTINGS_UI: Array<SettingTab> = [
{
icon: BxIcon.HOME,
private readonly SETTINGS_UI: Record<SettingTabGroup, SettingTab> = {
global: {
group: 'global',
icon: BxIcon.HOME,
items: this.TAB_GLOBAL_ITEMS,
},
{
icon: BxIcon.DISPLAY,
stream: {
group: 'stream',
icon: BxIcon.DISPLAY,
items: this.TAB_DISPLAY_ITEMS,
},
{
icon: BxIcon.CONTROLLER,
controller: {
group: 'controller',
icon: BxIcon.CONTROLLER,
items: this.TAB_CONTROLLER_ITEMS,
requiredVariants: 'full',
},
isFullVersion() && getPref(PrefKey.MKB_ENABLED) && {
icon: BxIcon.VIRTUAL_CONTROLLER,
mkb: isFullVersion() && getPref(PrefKey.MKB_ENABLED) && {
group: 'mkb',
icon: BxIcon.VIRTUAL_CONTROLLER,
items: this.TAB_VIRTUAL_CONTROLLER_ITEMS,
lazyContent: true,
requiredVariants: 'full',
},
isFullVersion() && AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && {
icon: BxIcon.NATIVE_MKB,
'native-mkb': isFullVersion() && AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && {
group: 'native-mkb',
icon: BxIcon.NATIVE_MKB,
items: this.TAB_NATIVE_MKB_ITEMS,
requiredVariants: 'full',
},
{
icon: BxIcon.COMMAND,
shortcuts: {
group: 'shortcuts',
icon: BxIcon.COMMAND,
items: this.TAB_SHORTCUTS_ITEMS,
lazyContent: true,
requiredVariants: 'full',
},
{
icon: BxIcon.STREAM_STATS,
stats: {
group: 'stats',
icon: BxIcon.STREAM_STATS,
items: this.TAB_STATS_ITEMS,
},
];
};
constructor() {
private constructor() {
super();
BxLogger.info(this.LOG_TAG, 'constructor()');
this.renderFullSettings = STATES.supportedRegion && STATES.isSignedIn;
this.setupDialog();
@ -653,7 +662,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
}
// Trigger event
const $selectUserAgent = document.querySelector(`#bx_setting_${PrefKey.USER_AGENT_PROFILE}`) as HTMLSelectElement;
const $selectUserAgent = document.querySelector<HTMLSelectElement>(`#bx_setting_${PrefKey.USER_AGENT_PROFILE}`);
if ($selectUserAgent) {
$selectUserAgent.disabled = true;
BxEvent.dispatch($selectUserAgent, 'input', {});
@ -757,8 +766,11 @@ export class SettingsNavigationDialog extends NavigationDialog {
}
// Get labels
for (const settingTab of this.SETTINGS_UI) {
if (!settingTab || !settingTab.items) {
let settingTabGroup: keyof typeof this.SETTINGS_UI;
for (settingTabGroup in this.SETTINGS_UI) {
const settingTab = this.SETTINGS_UI[settingTabGroup];
if (!settingTab || !settingTab.items || typeof settingTab.items === 'function') {
continue;
}
@ -901,7 +913,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
let prefKey: PrefKey;
for (prefKey in settings) {
const suggestedValue = settings[prefKey];
const $checkBox = $content.querySelector(`#bx_suggest_${prefKey}`) as HTMLInputElement;
const $checkBox = $content.querySelector<HTMLInputElement>(`#bx_suggest_${prefKey}`)!;
if (!$checkBox.checked || $checkBox.disabled) {
continue;
}
@ -961,36 +973,57 @@ export class SettingsNavigationDialog extends NavigationDialog {
}, t('suggest-settings-link')),
);
$btnSuggest?.insertAdjacentElement('afterend', $content);
$btnSuggest.insertAdjacentElement('afterend', $content);
}
private onTabClicked(e: Event) {
const $svg = (e.target as SVGElement).closest('svg')!;
// Render tab content lazily
if (!!$svg.dataset.lazy) {
// Remove attribute
delete $svg.dataset.lazy;
// Render data
const settingTab = this.SETTINGS_UI[$svg.dataset.group as SettingTabGroup];
const items = (settingTab.items as Function)();
const $tabContent = this.renderTabContent.call(this, settingTab, items);
this.$tabContents.appendChild($tabContent);
}
// Switch tab
let $child: HTMLElement;
const children = Array.from(this.$tabContents.children) as HTMLElement[];
for ($child of children) {
if ($child.dataset.tabGroup === $svg.dataset.group) {
// Show tab content
$child.classList.remove('bx-gone');
// Calculate size of controller-friendly select boxes
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
this.dialogManager.calculateSelectBoxes($child as HTMLElement);
}
} else {
// Hide tab content
$child.classList.add('bx-gone');
}
}
// Highlight current tab button
for (const $child of Array.from(this.$tabs.children)) {
$child.classList.remove('bx-active');
}
$svg.classList.add('bx-active');
}
private renderTab(settingTab: SettingTab) {
const $svg = createSvgIcon(settingTab.icon as any);
$svg.dataset.group = settingTab.group;
$svg.tabIndex = 0;
settingTab.lazyContent && ($svg.dataset.lazy = settingTab.lazyContent.toString());
$svg.addEventListener('click', e => {
// Switch tab
for (const $child of Array.from(this.$settings.children)) {
if ($child.getAttribute('data-tab-group') === settingTab.group) {
$child.classList.remove('bx-gone');
// Calculate size of controller-friendly select boxes
if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
this.dialogManager.calculateSelectBoxes($child as HTMLElement);
}
} else {
$child.classList.add('bx-gone');
}
}
// Highlight current tab button
for (const $child of Array.from(this.$tabs.children)) {
$child.classList.remove('bx-active');
}
$svg.classList.add('bx-active');
});
$svg.addEventListener('click', this.onTabClicked.bind(this));
return $svg;
}
@ -1137,10 +1170,19 @@ export class SettingsNavigationDialog extends NavigationDialog {
}
let label = prefDefinition?.label || setting.label;
let note = prefDefinition?.note || setting.note;
let unsupportedNote = prefDefinition?.unsupportedNote || setting.unsupportedNote;
let note: string | undefined | (() => HTMLElement) | HTMLElement = prefDefinition?.note || setting.note;
let unsupportedNote: string | undefined | (() => HTMLElement) | HTMLElement = prefDefinition?.unsupportedNote || setting.unsupportedNote;
const experimental = prefDefinition?.experimental || setting.experimental;
// Render note lazily
if (typeof note === 'function') {
note = note();
}
if (typeof unsupportedNote === 'function') {
unsupportedNote = unsupportedNote();
}
if (settingTabContent.label && setting.pref) {
if (prefDefinition?.suggest) {
typeof prefDefinition.suggest.lowest !== 'undefined' && (this.suggestedSettings.lowest[setting.pref] = prefDefinition.suggest.lowest);
@ -1195,9 +1237,101 @@ export class SettingsNavigationDialog extends NavigationDialog {
!prefDefinition?.unsupported && setting.onCreated && setting.onCreated(setting, $control);
}
private renderTabContent(settingTab: SettingTab, items: Array<SettingTabContent | false>): HTMLElement {
const $tabContent = CE('div', {
class: 'bx-gone',
'data-tab-group': settingTab.group,
});
for (const settingTabContent of items) {
if (!settingTabContent) {
continue;
}
if (!this.isSupportedVariant(settingTabContent.requiredVariants)) {
continue;
}
// Don't render other settings in unsupported regions
if (!this.renderFullSettings && settingTab.group === 'global' && settingTabContent.group !== 'general' && settingTabContent.group !== 'footer') {
continue;
}
let label = settingTabContent.label;
// If label is "Better xCloud" => create a link to Releases page
if (label === t('better-xcloud')) {
label += ' ' + SCRIPT_VERSION;
if (SCRIPT_VARIANT === 'lite') {
label += ' (Lite)';
}
label = createButton({
label: label,
url: 'https://github.com/redphx/better-xcloud/releases',
style: ButtonStyle.NORMAL_CASE | ButtonStyle.FROSTED | ButtonStyle.FOCUSABLE,
});
}
if (label) {
const $title = CE('h2', {
_nearby: {
orientation: 'horizontal',
}
},
CE('span', {}, label),
settingTabContent.helpUrl && createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
url: settingTabContent.helpUrl,
title: t('help'),
}),
);
$tabContent.appendChild($title);
}
// Add note
if (settingTabContent.unsupportedNote) {
const $note = CE('b', {class: 'bx-note-unsupported'}, settingTabContent.unsupportedNote);
$tabContent.appendChild($note);
}
// Don't render settings if this is an unsupported feature
if (settingTabContent.unsupported) {
continue;
}
// Add content DOM
if (settingTabContent.content) {
$tabContent.appendChild(settingTabContent.content);
continue;
}
// Render list of settings
settingTabContent.items = settingTabContent.items || [];
for (const setting of settingTabContent.items) {
if (setting === false) {
continue;
}
if (typeof setting === 'function') {
setting.apply(this, [$tabContent]);
continue;
}
this.renderSettingRow(settingTab, $tabContent, settingTabContent, setting);
}
}
return $tabContent;
}
private setupDialog() {
let $tabs: HTMLElement;
let $settings: HTMLElement;
let $tabContents: HTMLElement;
const $container = CE('div', {
class: 'bx-settings-dialog',
@ -1245,7 +1379,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
),
),
$settings = CE('div', {
$tabContents = CE('div', {
class: 'bx-settings-tab-contents',
_nearby: {
orientation: 'vertical',
@ -1264,7 +1398,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
this.$container = $container;
this.$tabs = $tabs;
this.$settings = $settings;
this.$tabContents = $tabContents;
// Close dialog when not clicking on any child elements in the dialog
$container.addEventListener('click', e => {
@ -1275,7 +1409,10 @@ export class SettingsNavigationDialog extends NavigationDialog {
}
});
for (const settingTab of this.SETTINGS_UI) {
let settingTabGroup: keyof typeof this.SETTINGS_UI
for (settingTabGroup in this.SETTINGS_UI) {
const settingTab = this.SETTINGS_UI[settingTabGroup];
if (!settingTab) {
continue;
}
@ -1293,95 +1430,13 @@ export class SettingsNavigationDialog extends NavigationDialog {
const $svg = this.renderTab(settingTab);
$tabs.appendChild($svg);
const $tabContent = CE('div', {
class: 'bx-gone',
'data-tab-group': settingTab.group,
});
for (const settingTabContent of settingTab.items) {
if (settingTabContent === false) {
continue;
}
if (!this.isSupportedVariant(settingTabContent.requiredVariants)) {
continue;
}
// Don't render other settings in unsupported regions
if (!this.renderFullSettings && settingTab.group === 'global' && settingTabContent.group !== 'general' && settingTabContent.group !== 'footer') {
continue;
}
let label = settingTabContent.label;
// If label is "Better xCloud" => create a link to Releases page
if (label === t('better-xcloud')) {
label += ' ' + SCRIPT_VERSION;
if (SCRIPT_VARIANT === 'lite') {
label += ' (Lite)';
}
label = createButton({
label: label,
url: 'https://github.com/redphx/better-xcloud/releases',
style: ButtonStyle.NORMAL_CASE | ButtonStyle.FROSTED | ButtonStyle.FOCUSABLE,
});
}
if (label) {
const $title = CE('h2', {
_nearby: {
orientation: 'horizontal',
}
},
CE('span', {}, label),
settingTabContent.helpUrl && createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
url: settingTabContent.helpUrl,
title: t('help'),
}),
);
$tabContent.appendChild($title);
}
// Add note
if (settingTabContent.unsupportedNote) {
const $note = CE('b', {class: 'bx-note-unsupported'}, settingTabContent.unsupportedNote);
$tabContent.appendChild($note);
}
// Don't render settings if this is an unsupported feature
if (settingTabContent.unsupported) {
continue;
}
// Add content DOM
if (settingTabContent.content) {
$tabContent.appendChild(settingTabContent.content);
continue;
}
// Render list of settings
settingTabContent.items = settingTabContent.items || [];
for (const setting of settingTabContent.items) {
if (setting === false) {
continue;
}
if (typeof setting === 'function') {
setting.apply(this, [$tabContent]);
continue;
}
this.renderSettingRow(settingTab, $tabContent, settingTabContent, setting);
}
// Don't render lazy tab content
if (typeof settingTab.items === 'function') {
continue;
}
$settings.appendChild($tabContent);
const $tabContent = this.renderTabContent.call(this, settingTab, settingTab.items);
$tabContents.appendChild($tabContent);
}
// Select first tab
@ -1398,13 +1453,13 @@ export class SettingsNavigationDialog extends NavigationDialog {
}
private focusActiveTab() {
const $currentTab = this.$tabs!.querySelector('.bx-active') as HTMLElement;
const $currentTab = this.$tabs!.querySelector<HTMLElement>('.bx-active');
$currentTab && $currentTab.focus();
return true;
}
private focusVisibleSetting(type: 'first' | 'last' = 'first'): boolean {
const controls = Array.from(this.$settings.querySelectorAll('div[data-tab-group]:not(.bx-gone) > *'));
const controls = Array.from(this.$tabContents.querySelectorAll('div[data-tab-group]:not(.bx-gone) > *'));
if (!controls.length) {
return false;
}
@ -1450,7 +1505,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
}
private jumpToSettingGroup(direction: 'next' | 'previous'): boolean {
const $tabContent = this.$settings.querySelector('div[data-tab-group]:not(.bx-gone)');
const $tabContent = this.$tabContents.querySelector('div[data-tab-group]:not(.bx-gone)');
if (!$tabContent) {
return false;
}
@ -1461,7 +1516,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
$header = $tabContent.querySelector('h2');
} else {
// Find the parent element
const $parent = $focusing.closest('[data-tab-group] > *') as HTMLElement;
const $parent = $focusing.closest<HTMLElement>('[data-tab-group] > *');
const siblingProperty = direction === 'next' ? 'nextSibling' : 'previousSibling';
let $tmp = $parent;

View File

@ -1,12 +1,15 @@
import { BxLogger } from "@/utils/bx-logger";
import { CE } from "@/utils/html";
export class FullscreenText {
private static instance: FullscreenText;
public static getInstance = () => FullscreenText.instance ?? (FullscreenText.instance = new FullscreenText());
private readonly LOG_TAG = 'FullscreenText';
$text: HTMLElement;
constructor() {
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.$text = CE('div', {
class: 'bx-fullscreen-text bx-gone',
});

View File

@ -13,101 +13,104 @@ export enum GuideMenuTab {
}
export class GuideMenu {
static #BUTTONS = {
scriptSettings: createButton({
label: t('better-xcloud'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.PRIMARY,
onClick: e => {
// Wait until the Guide dialog is closed
window.addEventListener(BxEvent.XCLOUD_DIALOG_DISMISSED, e => {
setTimeout(() => SettingsNavigationDialog.getInstance().show(), 50);
}, {once: true});
private static instance: GuideMenu;
public static getInstance = () => GuideMenu.instance ?? (GuideMenu.instance = new GuideMenu());
// Close all xCloud's dialogs
GuideMenu.#closeGuideMenu();
},
}),
private $renderedButtons?: HTMLElement;
closeApp: AppInterface && createButton({
icon: BxIcon.POWER,
label: t('close-app'),
title: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
attributes: {
'data-state': 'normal',
},
}),
reloadPage: createButton({
icon: BxIcon.REFRESH,
label: t('reload-page'),
title: t('reload-page'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
if (STATES.isPlaying) {
confirm(t('confirm-reload-stream')) && window.location.reload();
} else {
window.location.reload();
}
// Close all xCloud's dialogs
GuideMenu.#closeGuideMenu();
},
}),
backToHome: createButton({
icon: BxIcon.HOME,
label: t('back-to-home'),
title: t('back-to-home'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
// Close all xCloud's dialogs
GuideMenu.#closeGuideMenu();
},
attributes: {
'data-state': 'playing',
},
}),
}
static #$renderedButtons: HTMLElement;
static #closeGuideMenu() {
closeGuideMenu() {
if (window.BX_EXPOSED.dialogRoutes) {
window.BX_EXPOSED.dialogRoutes.closeAll();
return;
}
// Use alternative method for Lite version
const $btnClose = document.querySelector('#gamepass-dialog-root button[class^=Header-module__closeButton]') as HTMLElement;
const $btnClose = document.querySelector<HTMLElement>('#gamepass-dialog-root button[class^=Header-module__closeButton]');
$btnClose && $btnClose.click();
}
static #renderButtons() {
if (GuideMenu.#$renderedButtons) {
return GuideMenu.#$renderedButtons;
private renderButtons() {
if (this.$renderedButtons) {
return this.$renderedButtons;
}
const buttons = {
scriptSettings: createButton({
label: t('better-xcloud'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.PRIMARY,
onClick: (() => {
// Wait until the Guide dialog is closed
window.addEventListener(BxEvent.XCLOUD_DIALOG_DISMISSED, e => {
setTimeout(() => SettingsNavigationDialog.getInstance().show(), 50);
}, {once: true});
// Close all xCloud's dialogs
this.closeGuideMenu();
}).bind(this),
}),
closeApp: AppInterface && createButton({
icon: BxIcon.POWER,
label: t('close-app'),
title: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
attributes: {
'data-state': 'normal',
},
}),
reloadPage: createButton({
icon: BxIcon.REFRESH,
label: t('reload-page'),
title: t('reload-page'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: (() => {
// Close all xCloud's dialogs
this.closeGuideMenu();
if (STATES.isPlaying) {
confirm(t('confirm-reload-stream')) && window.location.reload();
} else {
window.location.reload();
}
}).bind(this),
}),
backToHome: createButton({
icon: BxIcon.HOME,
label: t('back-to-home'),
title: t('back-to-home'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: (() => {
// Close all xCloud's dialogs
this.closeGuideMenu();
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
}).bind(this),
attributes: {
'data-state': 'playing',
},
}),
};
const buttonsLayout = [
buttons.scriptSettings,
[
buttons.backToHome,
buttons.reloadPage,
buttons.closeApp,
],
];
const $div = CE('div', {
class: 'bx-guide-home-buttons',
});
const buttons = [
GuideMenu.#BUTTONS.scriptSettings,
[
GuideMenu.#BUTTONS.backToHome,
GuideMenu.#BUTTONS.reloadPage,
GuideMenu.#BUTTONS.closeApp,
],
];
for (const $button of buttons) {
for (const $button of buttonsLayout) {
if (!$button) {
continue;
}
@ -123,15 +126,15 @@ export class GuideMenu {
}
}
GuideMenu.#$renderedButtons = $div;
this.$renderedButtons = $div;
return $div;
}
static #injectHome($root: HTMLElement, isPlaying = false) {
injectHome($root: HTMLElement, isPlaying = false) {
if (isFullVersion()) {
const $achievementsProgress = $root.querySelector('button[class*=AchievementsButton-module__progressBarContainer]');
if ($achievementsProgress) {
TrueAchievements.injectAchievementsProgress($achievementsProgress as HTMLElement);
TrueAchievements.getInstance().injectAchievementsProgress($achievementsProgress as HTMLElement);
}
}
@ -142,7 +145,7 @@ export class GuideMenu {
$target = $root.querySelector('a[class*=QuitGameButton]');
// Hide xCloud's Home button
const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement;
const $btnXcloudHome = $root.querySelector<HTMLElement>('div[class^=HomeButtonWithDivider]');
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
} else {
// Last divider
@ -156,29 +159,30 @@ export class GuideMenu {
return false;
}
const $buttons = GuideMenu.#renderButtons();
const $buttons = this.renderButtons();
$buttons.dataset.isPlaying = isPlaying.toString();
$target.insertAdjacentElement('afterend', $buttons);
}
static async #onShown(e: Event) {
async onShown(e: Event) {
const where = (e as any).where as GuideMenuTab;
if (where === GuideMenuTab.HOME) {
const $root = document.querySelector('#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]') as HTMLElement;
$root && GuideMenu.#injectHome($root, STATES.isPlaying);
const $root = document.querySelector<HTMLElement>('#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]');
$root && this.injectHome($root, STATES.isPlaying);
}
}
static addEventListeners() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown);
addEventListeners() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, this.onShown.bind(this));
}
static observe($addedElm: HTMLElement) {
observe($addedElm: HTMLElement) {
const className = $addedElm.className;
// TrueAchievements
if (isFullVersion() && className.includes('AchievementsButton-module__progressBarContainer')) {
TrueAchievements.injectAchievementsProgress($addedElm);
TrueAchievements.getInstance().injectAchievementsProgress($addedElm);
return;
}
@ -192,7 +196,7 @@ export class GuideMenu {
if (isFullVersion()) {
const $achievDetailPage = $addedElm.querySelector('div[class*=AchievementDetailPage]');
if ($achievDetailPage) {
TrueAchievements.injectAchievementDetailPage($achievDetailPage as HTMLElement);
TrueAchievements.getInstance().injectAchievementDetailPage($achievDetailPage as HTMLElement);
return;
}
}

View File

@ -7,36 +7,45 @@ import { t } from "@utils/translation";
import { SettingsNavigationDialog } from "./dialog/settings-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { BxLogger } from "@/utils/bx-logger";
export class HeaderSection {
static #$remotePlayBtn = createButton({
classes: ['bx-header-remote-play-button', 'bx-gone'],
icon: BxIcon.REMOTE_PLAY,
title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.CIRCULAR,
onClick: e => {
RemotePlayManager.getInstance().togglePopup();
},
});
private static instance: HeaderSection;
public static getInstance = () => HeaderSection.instance ?? (HeaderSection.instance = new HeaderSection());
private readonly LOG_TAG = 'HeaderSection';
static #$settingsBtn = createButton({
classes: ['bx-header-settings-button'],
label: '???',
style: ButtonStyle.FROSTED | ButtonStyle.DROP_SHADOW | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
onClick: e => {
SettingsNavigationDialog.getInstance().show();
},
});
private $btnRemotePlay: HTMLElement;
private $btnSettings: HTMLElement;
private $buttonsWrapper: HTMLElement;
static #$buttonsWrapper = CE('div', {},
getPref(PrefKey.REMOTE_PLAY_ENABLED) ? HeaderSection.#$remotePlayBtn : null,
HeaderSection.#$settingsBtn,
);
private observer?: MutationObserver;
private timeoutId?: number | null;
static #observer: MutationObserver;
static #timeout: number | null;
constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
static #injectSettingsButton($parent?: HTMLElement) {
this.$btnRemotePlay = createButton({
classes: ['bx-header-remote-play-button', 'bx-gone'],
icon: BxIcon.REMOTE_PLAY,
title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.CIRCULAR,
onClick: e => RemotePlayManager.getInstance().togglePopup(),
});
this.$btnSettings = createButton({
classes: ['bx-header-settings-button'],
label: '???',
style: ButtonStyle.FROSTED | ButtonStyle.DROP_SHADOW | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
onClick: e => SettingsNavigationDialog.getInstance().show(),
});
this.$buttonsWrapper = CE('div', {},
getPref(PrefKey.REMOTE_PLAY_ENABLED) ? this.$btnRemotePlay : null,
this.$btnSettings,
);
}
private injectSettingsButton($parent?: HTMLElement) {
if (!$parent) {
return;
}
@ -44,8 +53,8 @@ export class HeaderSection {
const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION);
// Setup Settings button
const $btnSettings = HeaderSection.#$settingsBtn;
if (isElementVisible(HeaderSection.#$buttonsWrapper)) {
const $btnSettings = this.$btnSettings;
if (isElementVisible(this.$buttonsWrapper)) {
return;
}
@ -57,38 +66,42 @@ export class HeaderSection {
}
// Add the Settings button to the web page
$parent.appendChild(HeaderSection.#$buttonsWrapper);
$parent.appendChild(this.$buttonsWrapper);
}
static checkHeader() {
private checkHeader() {
let $target = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
if (!$target) {
$target = document.querySelector('div[class^=UnsupportedMarketPage-module__buttons]');
}
$target && HeaderSection.#injectSettingsButton($target as HTMLElement);
$target && this.injectSettingsButton($target as HTMLElement);
}
static showRemotePlayButton() {
HeaderSection.#$remotePlayBtn.classList.remove('bx-gone');
}
static watchHeader() {
private watchHeader() {
const $root = document.querySelector('#PageContent header') || document.querySelector('#root');
if (!$root) {
return;
}
HeaderSection.#timeout && clearTimeout(HeaderSection.#timeout);
HeaderSection.#timeout = null;
this.timeoutId && clearTimeout(this.timeoutId);
this.timeoutId = null;
HeaderSection.#observer && HeaderSection.#observer.disconnect();
HeaderSection.#observer = new MutationObserver(mutationList => {
HeaderSection.#timeout && clearTimeout(HeaderSection.#timeout);
HeaderSection.#timeout = window.setTimeout(HeaderSection.checkHeader, 2000);
this.observer && this.observer.disconnect();
this.observer = new MutationObserver(mutationList => {
this.timeoutId && clearTimeout(this.timeoutId);
this.timeoutId = window.setTimeout(this.checkHeader.bind(this), 2000);
});
HeaderSection.#observer.observe($root, {subtree: true, childList: true});
this.observer.observe($root, {subtree: true, childList: true});
HeaderSection.checkHeader();
this.checkHeader();
}
showRemotePlayButton() {
this.$btnRemotePlay.classList.remove('bx-gone');
}
static watchHeader() {
HeaderSection.getInstance().watchHeader();
}
}

View File

@ -4,8 +4,8 @@ export type PreferenceSetting = {
options?: {[index: string]: string};
multipleOptions?: {[index: string]: string};
unsupported?: boolean;
unsupported_note?: string | HTMLElement;
note?: string | HTMLElement;
unsupportedNote?: string | (() => HTMLElement);
note?: string | (() => HTMLElement);
type?: SettingElementType;
ready?: (setting: PreferenceSetting) => void;
migrate?: (this: Preferences, savedPrefs: any, value: any) => void;

View File

@ -18,10 +18,10 @@ export type SettingDefinition = {
default: any;
} & Partial<{
label: string;
note: string | HTMLElement;
note: string | (() => HTMLElement);
experimental: boolean;
unsupported: boolean;
unsupportedNote: string | HTMLElement;
unsupportedNote: string | (() => HTMLElement);
suggest: PartialRecord<SuggestedSettingCategory, any>,
ready: (setting: SettingDefinition) => void;
type: SettingElementType,

View File

@ -1,3 +1,5 @@
import { BX_FLAGS } from "./bx-flags";
const enum TextColor {
INFO = '#008746',
WARNING = '#c1a404',
@ -10,7 +12,7 @@ export class BxLogger {
static error = (tag: string, ...args: any[]) => BxLogger.log(TextColor.ERROR, tag, ...args);
private static log(color: string, tag: string, ...args: any) {
console.log(`%c[BxC]`, `color:${color};font-weight:bold;`, tag, '//', ...args);
BX_FLAGS.Debug && console.log(`%c[BxC]`, `color:${color};font-weight:bold;`, tag, '//', ...args);
}
}

View File

@ -56,6 +56,8 @@ function createElement<T=HTMLElement>(elmName: string, props: CreateElementOptio
let $elm;
const hasNs = 'xmlns' in props;
// console.trace('createElement', elmName, props);
if (hasNs) {
$elm = document.createElementNS(props.xmlns, elmName);
delete props.xmlns;
@ -111,11 +113,11 @@ const ButtonStyleIndices = Object.keys(ButtonStyleClass).map(i => parseInt(i));
export function createButton<T=HTMLButtonElement>(options: BxButton): T {
let $btn;
if (options.url) {
$btn = CE('a', {'class': 'bx-button'}) as HTMLAnchorElement;
$btn = CE<HTMLAnchorElement>('a', {'class': 'bx-button'});
$btn.href = options.url;
$btn.target = '_blank';
} else {
$btn = CE('button', {'class': 'bx-button', type: 'button'}) as HTMLButtonElement;
$btn = CE<HTMLButtonElement>('button', {'class': 'bx-button', type: 'button'});
}
const style = (options.style || 0) as number;

View File

@ -1,165 +0,0 @@
import { MkbPreset } from "@modules/mkb/mkb-preset";
import { t } from "@utils/translation";
import type { MkbStoredPreset, MkbStoredPresets } from "@/types/mkb";
import { PrefKey } from "@/enums/pref-keys";
import { setPref } from "./settings-storages/global-settings-storage";
export class LocalDb {
static #instance: LocalDb;
static get INSTANCE() {
if (!LocalDb.#instance) {
LocalDb.#instance = new LocalDb();
}
return LocalDb.#instance;
}
static readonly DB_NAME = 'BetterXcloud';
static readonly DB_VERSION = 1;
static readonly TABLE_PRESETS = 'mkb_presets';
#DB: any;
#open() {
return new Promise<void>((resolve, reject) => {
if (this.#DB) {
resolve();
return;
}
const request = window.indexedDB.open(LocalDb.DB_NAME, LocalDb.DB_VERSION);
request.onupgradeneeded = (e: IDBVersionChangeEvent) => {
const db = (e.target! as any).result;
switch (e.oldVersion) {
case 0: {
const presets = db.createObjectStore(LocalDb.TABLE_PRESETS, {keyPath: 'id', autoIncrement: true});
presets.createIndex('name_idx', 'name');
break;
}
}
};
request.onerror = e => {
console.log(e);
alert((e.target as any).error.message);
reject && reject();
};
request.onsuccess = e => {
this.#DB = (e.target as any).result;
resolve();
};
});
}
#table(name: string, type: string): 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
#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]);
};
});
}
#count(table: IDBObjectStore): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.#call(table.count, ...arguments);
}
#add(table: IDBObjectStore, data: any): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.#call(table.add, ...arguments);
}
#put(table: IDBObjectStore, data: any): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.#call(table.put, ...arguments);
}
#delete(table: IDBObjectStore, data: any): Promise<[IDBObjectStore, number]> {
// @ts-ignore
return this.#call(table.delete, ...arguments);
}
#get(table: IDBObjectStore, id: number): Promise<any> {
// @ts-ignore
return this.#call(table.get, ...arguments);
}
#getAll(table: IDBObjectStore): Promise<[IDBObjectStore, any]> {
// @ts-ignore
return this.#call(table.getAll, ...arguments);
}
newPreset(name: string, data: any) {
return this.#open()
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
.then(table => this.#add(table, {name, data}))
.then(([table, id]) => new Promise<number>(resolve => resolve(id)));
}
updatePreset(preset: MkbStoredPreset) {
return this.#open()
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
.then(table => this.#put(table, preset))
.then(([table, id]) => new Promise(resolve => resolve(id)));
}
deletePreset(id: number) {
return this.#open()
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
.then(table => this.#delete(table, id))
.then(([table, id]) => new Promise(resolve => resolve(id)));
}
getPreset(id: number): Promise<MkbStoredPreset> {
return this.#open()
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
.then(table => this.#get(table, id))
.then(([table, preset]) => new Promise(resolve => resolve(preset)));
}
getPresets(): Promise<MkbStoredPresets> {
return this.#open()
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
.then(table => this.#count(table))
.then(([table, count]) => {
if (count > 0) {
return new Promise(resolve => {
this.#getAll(table)
.then(([table, items]) => {
const presets: MkbStoredPresets = {};
items.forEach((item: MkbStoredPreset) => (presets[item.id!] = item));
resolve(presets);
});
});
}
// Create "Default" preset when the table is empty
const preset: MkbStoredPreset = {
name: t('default'),
data: MkbPreset.DEFAULT_PRESET,
}
return new Promise<MkbStoredPresets>(resolve => {
this.#add(table, preset)
.then(([table, id]) => {
preset.id = id;
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, id);
resolve({[id]: preset});
});
});
});
}
}

View File

@ -0,0 +1,79 @@
export abstract class LocalDb {
static readonly DB_NAME = 'BetterXcloud';
static readonly DB_VERSION = 1;
private db: any;
protected open() {
return new Promise<void>((resolve, reject) => {
if (this.db) {
resolve();
return;
}
const request = window.indexedDB.open(LocalDb.DB_NAME, LocalDb.DB_VERSION);
request.onupgradeneeded = this.onUpgradeNeeded;
request.onerror = e => {
console.log(e);
alert((e.target as any).error.message);
reject && reject();
};
request.onsuccess = e => {
this.db = (e.target as any).result;
resolve();
};
});
}
protected abstract onUpgradeNeeded(e: IDBVersionChangeEvent): void;
protected table(name: string, type: string): 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,96 @@
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()');
}
protected onUpgradeNeeded(e: IDBVersionChangeEvent): void {
const db = (e.target! as any).result;
switch (e.oldVersion) {
case 0: {
const presets = db.createObjectStore(this.TABLE_PRESETS, {
keyPath: 'id',
autoIncrement: true,
});
presets.createIndex('name_idx', 'name');
break;
}
}
}
private presetsTable() {
return this.open()
.then(() => this.table(this.TABLE_PRESETS, 'readwrite'))
}
newPreset(name: string, data: any) {
return this.presetsTable()
.then(table => this.add(table, {name, data}))
.then(([table, id]) => new Promise<number>(resolve => resolve(id)));
}
updatePreset(preset: MkbStoredPreset) {
return this.presetsTable()
.then(table => this.put(table, preset))
.then(([table, id]) => new Promise(resolve => resolve(id)));
}
deletePreset(id: number) {
return this.presetsTable()
.then(table => this.delete(table, id))
.then(([table, id]) => new Promise(resolve => resolve(id)));
}
getPreset(id: number): Promise<MkbStoredPreset> {
return this.presetsTable()
.then(table => this.get(table, id))
.then(([table, preset]) => new Promise(resolve => resolve(preset)));
}
getPresets(): Promise<MkbStoredPresets> {
return this.presetsTable()
.then(table => this.count(table))
.then(([table, count]) => {
if (count > 0) {
return new Promise(resolve => {
this.getAll(table)
.then(([table, items]) => {
const presets: MkbStoredPresets = {};
items.forEach((item: MkbStoredPreset) => (presets[item.id!] = item));
resolve(presets);
});
});
}
// Create "Default" preset when the table is empty
const preset: MkbStoredPreset = {
name: t('default'),
data: MkbPreset.DEFAULT_PRESET,
}
return new Promise<MkbStoredPresets>(resolve => {
this.add(table, preset)
.then(([table, id]) => {
preset.id = id;
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, id);
resolve({[id]: preset});
});
});
});
}
}

View File

@ -1,6 +1,5 @@
import { isFullVersion } from "@macros/build" with {type: "macro"};
import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS, NATIVE_FETCH } from "@utils/bx-flags";
import { TouchController } from "@modules/touch-controller";
import { STATES } from "@utils/global";
@ -29,9 +28,7 @@ function clearDbLogs(dbName: string, table: string) {
const objectStore = db.transaction(table, 'readwrite').objectStore(table);
const objectStoreRequest = objectStore.clear();
objectStoreRequest.onsuccess = function() {
console.log(`[Better xCloud] Cleared ${dbName}.${table}`);
};
objectStoreRequest.onsuccess = () => BxLogger.info('clearDbLogs', `Cleared ${dbName}.${table}`);
} catch (ex) {}
}
}
@ -134,6 +131,7 @@ export function interceptHttpRequests() {
'https://browser.events.data.microsoft.com',
'https://dc.services.visualstudio.com',
'https://2c06dea3f26c40c69b8456d319791fd0@o427368.ingest.sentry.io',
'https://mscom.demdex.net',
]);
}
@ -172,29 +170,42 @@ export function interceptHttpRequests() {
};
let gamepassAllGames: string[] = [];
const IGNORED_DOMAINS = [
'accounts.xboxlive.com',
'chat.xboxlive.com',
'notificationinbox.xboxlive.com',
'peoplehub.xboxlive.com',
'rta.xboxlive.com',
'userpresence.xboxlive.com',
'xblmessaging.xboxlive.com',
'consent.config.office.com',
'arc.msn.com',
'browser.events.data.microsoft.com',
'dc.services.visualstudio.com',
'2c06dea3f26c40c69b8456d319791fd0@o427368.ingest.sentry.io',
];
(window as any).BX_FETCH = window.fetch = async (request: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
let url = (typeof request === 'string') ? request : (request as Request).url;
// Check blocked URLs
for (let blocked of BLOCKED_URLS) {
if (!url.startsWith(blocked)) {
continue;
if (url.startsWith(blocked)) {
return new Response('{"acc":1,"webResult":{}}', {
status: 200,
statusText: '200 OK',
});
}
return new Response('{"acc":1,"webResult":{}}', {
status: 200,
statusText: '200 OK',
});
}
if (url.endsWith('/play')) {
BxEvent.dispatch(window, BxEvent.STREAM_LOADING);
// Ignore URLs
const domain = (new URL(url)).hostname;
if (IGNORED_DOMAINS.includes(domain)) {
return NATIVE_FETCH(request, init);
}
if (url.endsWith('/configuration')) {
BxEvent.dispatch(window, BxEvent.STREAM_STARTING);
}
// BxLogger.info('fetch', url);
// Override experimentals
if (url.startsWith('https://emerald.xboxservices.com/xboxcomfd/experimentation')) {
@ -212,6 +223,7 @@ export function interceptHttpRequests() {
return response;
} catch (e) {
console.log(e);
return NATIVE_FETCH(request, init);
}
}

View File

@ -60,7 +60,7 @@ export class RootDialogObserver {
}
} else if ($root.querySelector('div[class*=GuideDialog]')) {
// Guide menu
GuideMenu.observe($addedElm);
GuideMenu.getInstance().observe($addedElm);
return true;
}

View File

@ -0,0 +1,105 @@
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";
export class ScreenshotManager {
private static instance: ScreenshotManager;
public static getInstance = () => ScreenshotManager.instance ?? (ScreenshotManager.instance = new ScreenshotManager());
private readonly LOG_TAG = 'ScreenshotManager';
private $download: HTMLAnchorElement;
private $canvas: HTMLCanvasElement;
private canvasContext: CanvasRenderingContext2D;
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.$download = CE<HTMLAnchorElement>('a');
this.$canvas = CE<HTMLCanvasElement>('canvas', {'class': 'bx-gone'});
this.canvasContext = this.$canvas.getContext('2d', {
alpha: false,
willReadFrequently: false,
})!;
}
updateCanvasSize(width: number, height: number) {
this.$canvas.width = width;
this.$canvas.height = height;
}
updateCanvasFilters(filters: string) {
this.canvasContext.filter = filters;
}
private onAnimationEnd(e: Event) {
(e.target as HTMLElement).classList.remove('bx-taking-screenshot');
}
takeScreenshot(callback?: any) {
const currentStream = STATES.currentStream;
const streamPlayer = currentStream.streamPlayer;
const $canvas = this.$canvas;
if (!streamPlayer || !$canvas) {
return;
}
let $player;
if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
$player = streamPlayer.getPlayerElement();
} else {
$player = streamPlayer.getPlayerElement(StreamPlayerType.VIDEO);
}
if (!$player || !$player.isConnected) {
return;
}
$player.parentElement!.addEventListener('animationend', this.onAnimationEnd, { once: true });
$player.parentElement!.classList.add('bx-taking-screenshot');
const canvasContext = this.canvasContext;
if ($player instanceof HTMLCanvasElement) {
streamPlayer.getWebGL2Player().drawFrame(true);
}
canvasContext.drawImage($player, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app
if (AppInterface) {
const data = $canvas.toDataURL('image/png').split(';base64,')[1];
AppInterface.saveScreenshot(currentStream.titleSlug, data);
// Free screenshot from memory
canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
return;
}
$canvas.toBlob(blob => {
if (!blob) {
return;
}
// Download screenshot
const now = +new Date;
const $download = this.$download;
$download.download = `${currentStream.titleSlug}-${now}.png`;
$download.href = URL.createObjectURL(blob);
$download.click();
// Free screenshot from memory
URL.revokeObjectURL($download.href);
$download.href = '';
$download.download = '';
canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
}, 'image/png');
}
}

View File

@ -1,99 +0,0 @@
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";
export class Screenshot {
static #$canvas: HTMLCanvasElement;
static #canvasContext: CanvasRenderingContext2D;
static setup() {
if (Screenshot.#$canvas) {
return;
}
Screenshot.#$canvas = CE<HTMLCanvasElement>('canvas', {'class': 'bx-gone'});
Screenshot.#canvasContext = Screenshot.#$canvas.getContext('2d', {
alpha: false,
willReadFrequently: false,
})!;
}
static updateCanvasSize(width: number, height: number) {
const $canvas = Screenshot.#$canvas;
if ($canvas) {
$canvas.width = width;
$canvas.height = height;
}
}
static updateCanvasFilters(filters: string) {
Screenshot.#canvasContext && (Screenshot.#canvasContext.filter = filters);
}
static #onAnimationEnd(e: Event) {
const $target = e.target as HTMLElement;
$target.classList.remove('bx-taking-screenshot');
}
static takeScreenshot(callback?: any) {
const currentStream = STATES.currentStream;
const streamPlayer = currentStream.streamPlayer;
const $canvas = Screenshot.#$canvas;
if (!streamPlayer || !$canvas) {
return;
}
let $player;
if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
$player = streamPlayer.getPlayerElement();
} else {
$player = streamPlayer.getPlayerElement(StreamPlayerType.VIDEO);
}
if (!$player || !$player.isConnected) {
return;
}
$player.parentElement!.addEventListener('animationend', this.#onAnimationEnd, { once: true });
$player.parentElement!.classList.add('bx-taking-screenshot');
const canvasContext = Screenshot.#canvasContext;
if ($player instanceof HTMLCanvasElement) {
streamPlayer.getWebGL2Player().drawFrame(true);
}
canvasContext.drawImage($player, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app
if (AppInterface) {
const data = $canvas.toDataURL('image/png').split(';base64,')[1];
AppInterface.saveScreenshot(currentStream.titleSlug, data);
// Free screenshot from memory
canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
return;
}
$canvas && $canvas.toBlob(blob => {
// Download screenshot
const now = +new Date;
const $anchor = CE<HTMLAnchorElement>('a', {
'download': `${currentStream.titleSlug}-${now}.png`,
'href': URL.createObjectURL(blob!),
});
$anchor.click();
// Free screenshot from memory
URL.revokeObjectURL($anchor.href);
canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
}, 'image/png');
}
}

View File

@ -339,10 +339,10 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
requiredVariants: 'full',
label: t('enable-local-co-op-support'),
default: false,
note: CE<HTMLAnchorElement>('a', {
href: 'https://github.com/redphx/better-xcloud/discussions/275',
target: '_blank',
}, t('enable-local-co-op-support-note')),
note: () => CE<HTMLAnchorElement>('a', {
href: 'https://github.com/redphx/better-xcloud/discussions/275',
target: '_blank',
}, t('enable-local-co-op-support-note')),
},
/*
@ -409,10 +409,10 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
url = 'https://better-xcloud.github.io/mouse-and-keyboard/#disclaimer';
}
setting.unsupportedNote = CE('a', {
href: url,
target: '_blank',
}, '⚠️ ' + note);
setting.unsupportedNote = () => CE<HTMLAnchorElement>('a', {
href: url,
target: '_blank',
}, '⚠️ ' + note);
},
},

View File

@ -3,6 +3,7 @@ import { BxEvent } from "./bx-event";
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',
@ -95,6 +96,7 @@ type CurrentStats = {
export class StreamStatsCollector {
private static instance: StreamStatsCollector;
public static getInstance = () => StreamStatsCollector.instance ?? (StreamStatsCollector.instance = new StreamStatsCollector());
private readonly LOG_TAG = 'StreamStatsCollector';
// Collect in background - 60 seconds
static readonly INTERVAL_BACKGROUND = 60 * 1000;
@ -214,6 +216,10 @@ export class StreamStatsCollector {
private lastVideoStat?: RTCInboundRtpStreamStats | null;
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
}
async collect() {
const stats = await STATES.currentStream.peerConnection?.getStats();
if (!stats) {

View File

@ -1,4 +1,5 @@
import { CE } from "@utils/html";
import { BxLogger } from "./bx-logger";
type ToastOptions = {
instant?: boolean;
@ -6,85 +7,100 @@ type ToastOptions = {
}
export class Toast {
private static $wrapper: HTMLElement;
private static $msg: HTMLElement;
private static $status: HTMLElement;
private static stack: Array<[string, string, ToastOptions]> = [];
private static isShowing = false;
private static instance: Toast;
public static getInstance = () => Toast.instance ?? (Toast.instance = new Toast());
private readonly LOG_TAG = 'Toast';
private static timeout?: number | null;
private static DURATION = 3000;
private $wrapper: HTMLElement;
private $msg: HTMLElement;
private $status: HTMLElement;
static show(msg: string, status?: string, options: Partial<ToastOptions> = {}) {
private stack: Array<[string, string, ToastOptions]> = [];
private isShowing = false;
private timeoutId?: number | null;
private DURATION = 3000;
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.$wrapper = CE('div', {class: 'bx-toast bx-offscreen'},
this.$msg = CE('span', {class: 'bx-toast-msg'}),
this.$status = CE('span', {class: 'bx-toast-status'}),
);
this.$wrapper.addEventListener('transitionend', e => {
const classList = this.$wrapper.classList;
if (classList.contains('bx-hide')) {
classList.remove('bx-offscreen', 'bx-hide');
classList.add('bx-offscreen');
this.showNext();
}
});
document.documentElement.appendChild(this.$wrapper);
}
private show(msg: string, status?: string, options: Partial<ToastOptions> = {}) {
options = options || {};
const args = Array.from(arguments) as [string, string, ToastOptions];
if (options.instant) {
// Clear stack
Toast.stack = [args];
Toast.showNext();
this.stack = [args];
this.showNext();
} else {
Toast.stack.push(args);
!Toast.isShowing && Toast.showNext();
this.stack.push(args);
!this.isShowing && this.showNext();
}
}
private static showNext() {
if (!Toast.stack.length) {
Toast.isShowing = false;
private showNext() {
if (!this.stack.length) {
this.isShowing = false;
return;
}
Toast.isShowing = true;
this.isShowing = true;
Toast.timeout && clearTimeout(Toast.timeout);
Toast.timeout = window.setTimeout(Toast.hide, Toast.DURATION);
this.timeoutId && clearTimeout(this.timeoutId);
this.timeoutId = window.setTimeout(this.hide.bind(this), this.DURATION);
// Get values from item
const [msg, status, options] = Toast.stack.shift()!;
const [msg, status, options] = this.stack.shift()!;
if (options && options.html) {
Toast.$msg.innerHTML = msg;
this.$msg.innerHTML = msg;
} else {
Toast.$msg.textContent = msg;
this.$msg.textContent = msg;
}
if (status) {
Toast.$status.classList.remove('bx-gone');
Toast.$status.textContent = status;
this.$status.classList.remove('bx-gone');
this.$status.textContent = status;
} else {
Toast.$status.classList.add('bx-gone');
this.$status.classList.add('bx-gone');
}
const classList = Toast.$wrapper.classList;
const classList = this.$wrapper.classList;
classList.remove('bx-offscreen', 'bx-hide');
classList.add('bx-show');
}
private static hide() {
Toast.timeout = null;
private hide() {
this.timeoutId = null;
const classList = Toast.$wrapper.classList;
const classList = this.$wrapper.classList;
classList.remove('bx-show');
classList.add('bx-hide');
}
static setup() {
Toast.$wrapper = CE('div', {'class': 'bx-toast bx-offscreen'},
Toast.$msg = CE('span', {'class': 'bx-toast-msg'}),
Toast.$status = CE('span', {'class': 'bx-toast-status'}),
);
static show(msg: string, status?: string, options: Partial<ToastOptions> = {}) {
Toast.getInstance().show(msg, status, options);
}
Toast.$wrapper.addEventListener('transitionend', e => {
const classList = Toast.$wrapper.classList;
if (classList.contains('bx-hide')) {
classList.remove('bx-offscreen', 'bx-hide');
classList.add('bx-offscreen');
Toast.showNext();
}
});
document.documentElement.appendChild(Toast.$wrapper);
static showNext() {
Toast.getInstance().showNext();
}
}

View File

@ -1,42 +1,55 @@
import { BxIcon } from "./bx-icon";
import { BxLogger } from "./bx-logger";
import { AppInterface, SCRIPT_VARIANT, STATES } from "./global";
import { ButtonStyle, CE, clearDataSet, createButton, getReactProps } from "./html";
import { t } from "./translation";
export class TrueAchievements {
private static $link = createButton({
label: t('true-achievements'),
url: '#',
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.NORMAL_LINK,
onClick: TrueAchievements.onClick,
}) as HTMLAnchorElement;
private static instance: TrueAchievements;
public static getInstance = () => TrueAchievements.instance ?? (TrueAchievements.instance = new TrueAchievements());
private readonly LOG_TAG = 'TrueAchievements';
static $button = createButton({
label: t('true-achievements'),
title: t('true-achievements'),
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE,
onClick: TrueAchievements.onClick,
}) as HTMLAnchorElement;
private $link: HTMLElement;
private $button: HTMLElement;
private $hiddenLink: HTMLAnchorElement;
private static onClick(e: Event) {
constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.$link = createButton<HTMLAnchorElement>({
label: t('true-achievements'),
url: '#',
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.NORMAL_LINK,
onClick: this.onClick.bind(this),
});
this.$button = createButton<HTMLAnchorElement>({
label: t('true-achievements'),
title: t('true-achievements'),
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE,
onClick: this.onClick.bind(this),
});
this.$hiddenLink = CE<HTMLAnchorElement>('a', {
target: '_blank',
});
}
private onClick(e: Event) {
e.preventDefault();
const dataset = TrueAchievements.$link.dataset;
TrueAchievements.open(true, dataset.xboxTitleId, dataset.id);
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes?.closeAll();
const dataset = this.$link.dataset;
this.open(true, dataset.xboxTitleId, dataset.id);
}
private static $hiddenLink = CE<HTMLAnchorElement>('a', {
target: '_blank',
});
private static updateIds(xboxTitleId?: string, id?: string) {
const $link = TrueAchievements.$link;
const $button = TrueAchievements.$button;
private updateIds(xboxTitleId?: string, id?: string) {
const $link = this.$link;
const $button = this.$button;
clearDataSet($link);
clearDataSet($button);
@ -52,7 +65,7 @@ export class TrueAchievements {
}
}
static injectAchievementsProgress($elm: HTMLElement) {
injectAchievementsProgress($elm: HTMLElement) {
// Only do this in Full version
if (SCRIPT_VARIANT !== 'full') {
return;
@ -68,7 +81,7 @@ export class TrueAchievements {
// Get xboxTitleId of the game
let xboxTitleId: string | number | undefined;
try {
const $container = $parent.closest('div[class*=AchievementsPreview-module__container]') as HTMLElement;
const $container = $parent.closest<HTMLElement>('div[class*=AchievementsPreview-module__container]');
if ($container) {
const props = getReactProps($container);
xboxTitleId = props.children.props.data.data.xboxTitleId;
@ -76,24 +89,24 @@ export class TrueAchievements {
} catch (e) {}
if (!xboxTitleId) {
xboxTitleId = TrueAchievements.getStreamXboxTitleId();
xboxTitleId = this.getStreamXboxTitleId();
}
if (typeof xboxTitleId !== 'undefined') {
xboxTitleId = xboxTitleId.toString();
}
TrueAchievements.updateIds(xboxTitleId);
this.updateIds(xboxTitleId);
if (document.documentElement.dataset.xdsPlatform === 'tv') {
$div.appendChild(TrueAchievements.$link);
$div.appendChild(this.$link);
} else {
$div.appendChild(TrueAchievements.$button);
$div.appendChild(this.$button);
}
$parent.appendChild($div);
}
static injectAchievementDetailPage($parent: HTMLElement) {
injectAchievementDetailPage($parent: HTMLElement) {
// Only do this in Full version
if (SCRIPT_VARIANT !== 'full') {
return;
@ -109,7 +122,7 @@ export class TrueAchievements {
const achievementList: XboxAchievement[] = props.children.props.data.data;
// Get current achievement name
const $header = $parent.querySelector('div[class*=AchievementDetailHeader]') as HTMLElement;
const $header = $parent.querySelector<HTMLElement>('div[class*=AchievementDetailHeader]')!;
const achievementName = getReactProps($header).children[0].props.achievementName;
// Find achievement based on name
@ -125,19 +138,19 @@ export class TrueAchievements {
// Found achievement -> add TrueAchievements button
if (id) {
TrueAchievements.updateIds(xboxTitleId, id);
$parent.appendChild(TrueAchievements.$link);
this.updateIds(xboxTitleId, id);
$parent.appendChild(this.$link);
}
} catch (e) {};
}
private static getStreamXboxTitleId() : number | undefined {
private getStreamXboxTitleId() : number | undefined {
return STATES.currentStream.xboxTitleId || STATES.currentStream.titleInfo?.details.xboxTitleId;
}
static open(override: boolean, xboxTitleId?: number | string, id?: number | string) {
open(override: boolean, xboxTitleId?: number | string, id?: number | string) {
if (!xboxTitleId || xboxTitleId === 'undefined') {
xboxTitleId = TrueAchievements.getStreamXboxTitleId();
xboxTitleId = this.getStreamXboxTitleId();
}
if (AppInterface && AppInterface.openTrueAchievementsLink) {
@ -154,7 +167,7 @@ export class TrueAchievements {
}
}
TrueAchievements.$hiddenLink.href = url;
TrueAchievements.$hiddenLink.click();
this.$hiddenLink.href = url;
this.$hiddenLink.click();
}
}

View File

@ -1,13 +1,19 @@
import { NATIVE_FETCH } from "./bx-flags";
import { BxLogger } from "./bx-logger";
import { STATES } from "./global";
export class XcloudApi {
private static instance: XcloudApi;
public static getInstance = () => XcloudApi.instance ?? (XcloudApi.instance = new XcloudApi());
private readonly LOG_TAG = 'XcloudApi';
private CACHE_TITLES: {[key: string]: XcloudTitleInfo} = {};
private CACHE_WAIT_TIME: {[key: string]: XcloudWaitTimeInfo} = {};
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
}
async getTitleInfo(id: string): Promise<XcloudTitleInfo | null> {
if (id in this.CACHE_TITLES) {
return this.CACHE_TITLES[id];

View File

@ -92,6 +92,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);
@ -165,6 +167,8 @@ export class XcloudInterceptor {
return response;
}
BxEvent.dispatch(window, BxEvent.STREAM_STARTING);
const obj = JSON.parse(text);
let overrides = JSON.parse(obj.clientStreamingConfigOverrides || '{}') || {};

View File

@ -54,8 +54,7 @@ export class XhomeInterceptor {
private static async handleLogin(request: Request) {
try {
const clone = (request as Request).clone();
const clone = request.clone();
const obj = await clone.json();
obj.offeringId = 'xhome';
@ -75,30 +74,30 @@ export class XhomeInterceptor {
}
private static async handleConfiguration(request: Request | URL) {
BxEvent.dispatch(window, BxEvent.STREAM_STARTING);
const response = await NATIVE_FETCH(request);
const obj = await response.clone().json()
console.log(obj);
const processPorts = (port: number): number[] => {
const ports = new Set<number>();
port && ports.add(port);
ports.add(9002);
return Array.from(ports);
};
const obj = await response.clone().json();
const serverDetails = obj.serverDetails;
if (serverDetails.ipAddress) {
XhomeInterceptor.consoleAddrs[serverDetails.ipAddress] = processPorts(serverDetails.port);
}
const pairs = [
['ipAddress', 'port'],
['ipV4Address', 'ipV4Port'],
['ipV6Address', 'ipV6Port'],
];
if (serverDetails.ipV4Address) {
XhomeInterceptor.consoleAddrs[serverDetails.ipV4Address] = processPorts(serverDetails.ipV4Port);
}
if (serverDetails.ipV6Address) {
XhomeInterceptor.consoleAddrs[serverDetails.ipV6Address] = processPorts(serverDetails.ipV6Port);
XhomeInterceptor.consoleAddrs = {};
for (const pair in pairs) {
const [keyAddr, keyPort] = pair;
if (serverDetails[keyAddr]) {
const port = serverDetails[keyPort];
// Add port 9002 to the list of ports
const ports = new Set<number>();
port && ports.add(port);
ports.add(9002);
// Save it
XhomeInterceptor.consoleAddrs[serverDetails[keyAddr]] = Array.from(ports);
}
}
response.json = () => Promise.resolve(obj);
@ -164,6 +163,8 @@ export class XhomeInterceptor {
}
private static async handlePlay(request: RequestInfo | URL) {
BxEvent.dispatch(window, BxEvent.STREAM_LOADING);
const clone = (request as Request).clone();
const body = await clone.json();
@ -196,23 +197,25 @@ export class XhomeInterceptor {
headers['x-ms-device-info'] = JSON.stringify(deviceInfo);
const opts: {[index: string]: any} = {
const opts: Record<string, any> = {
method: clone.method,
headers: headers,
};
// Copy body
if (clone.method === 'POST') {
opts.body = await clone.text();
}
let newUrl = request.url;
if (!newUrl.includes('/servers/home')) {
const index = request.url.indexOf('.xboxlive.com');
newUrl = STATES.remotePlay.server + request.url.substring(index + 13);
// Replace xCloud domain with xHome domain
let url = request.url;
if (!url.includes('/servers/home')) {
const parsed = new URL(url);
url = STATES.remotePlay.server + parsed.pathname;
}
request = new Request(newUrl, opts);
let url = (typeof request === 'string') ? request : request.url;
// Create new Request instance
request = new Request(url, opts);
// Get console IP
if (url.includes('/configuration')) {
@ -225,7 +228,7 @@ export class XhomeInterceptor {
return XhomeInterceptor.handleLogin(request);
} else if (url.endsWith('/titles')) {
return XhomeInterceptor.handleTitles(request);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && request.method === 'GET') {
return patchIceCandidates(request, XhomeInterceptor.consoleAddrs);
}