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

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);
}