diff --git a/dist/better-xcloud.lite.user.js b/dist/better-xcloud.lite.user.js index 60e9c06..a30e557 100644 --- a/dist/better-xcloud.lite.user.js +++ b/dist/better-xcloud.lite.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Better xCloud (Lite) // @namespace https://github.com/redphx -// @version 5.8.6 +// @version 5.9.0-beta // @description Improve Xbox Cloud Gaming (xCloud) experience // @author redphx // @license MIT @@ -16,7 +16,7 @@ class BxLogger { static warning = (tag, ...args) => BxLogger.log("#c1a404", tag, ...args); static error = (tag, ...args) => BxLogger.log("#c10404", tag, ...args); static log(color, tag, ...args) { - 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); } } window.BxLogger = BxLogger; @@ -105,7 +105,7 @@ class UserAgent { }); } } -var SCRIPT_VERSION = "5.8.6", SCRIPT_VARIANT = "lite", AppInterface = window.AppInterface; +var SCRIPT_VERSION = "5.9.0-beta", SCRIPT_VARIANT = "lite", AppInterface = window.AppInterface; UserAgent.init(); var userAgent = window.navigator.userAgent.toLowerCase(), isTv = userAgent.includes("smart-tv") || userAgent.includes("smarttv") || /\baft.*\b/.test(userAgent), isVr = window.navigator.userAgent.includes("VR") && window.navigator.userAgent.includes("OculusBrowser"), browserHasTouchSupport = "ontouchstart" in window || navigator.maxTouchPoints > 0, userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport, supportMkb = AppInterface || !userAgent.match(/(android|iphone|ipad)/), STATES = { supportedRegion: !0, @@ -935,6 +935,7 @@ class BaseSettingsStore { class StreamStatsCollector { static instance; static getInstance = () => StreamStatsCollector.instance ?? (StreamStatsCollector.instance = new StreamStatsCollector); + LOG_TAG = "StreamStatsCollector"; static INTERVAL_BACKGROUND = 60000; calculateGrade(value, grades) { return value > grades[2] ? "bad" : value > grades[1] ? "ok" : value > grades[0] ? "good" : ""; @@ -1034,6 +1035,9 @@ class StreamStatsCollector { } }; lastVideoStat; + constructor() { + BxLogger.info(this.LOG_TAG, "constructor()"); + } async collect() { let stats = await STATES.currentStream.peerConnection?.getStats(); if (!stats) return; @@ -1331,7 +1335,7 @@ class GlobalSettingsStorage extends BaseSettingsStore { requiredVariants: "full", label: t("enable-local-co-op-support"), default: !1, - note: CE("a", { + note: () => CE("a", { href: "https://github.com/redphx/better-xcloud/discussions/275", target: "_blank" }, t("enable-local-co-op-support-note")) @@ -1381,7 +1385,7 @@ class GlobalSettingsStorage extends BaseSettingsStore { let note, url; if (setting.unsupported) note = t("browser-unsupported-feature"), url = "https://github.com/redphx/better-xcloud/issues/206#issuecomment-1920475657"; else note = t("mkb-disclaimer"), url = "https://better-xcloud.github.io/mouse-and-keyboard/#disclaimer"; - setting.unsupportedNote = CE("a", { + setting.unsupportedNote = () => CE("a", { href: url, target: "_blank" }, "⚠️ " + note); @@ -1798,6 +1802,7 @@ var MouseMapTo; class StreamStats { static instance; static getInstance = () => StreamStats.instance ?? (StreamStats.instance = new StreamStats); + LOG_TAG = "StreamStats"; intervalId; REFRESH_INTERVAL = 1000; stats = { @@ -1853,7 +1858,7 @@ class StreamStats { $container; quickGlanceObserver; constructor() { - this.render(); + BxLogger.info(this.LOG_TAG, "constructor()"), this.render(); } async start(glancing = !1) { if (!this.isHidden() || glancing && this.isGlancing()) return; @@ -1938,43 +1943,52 @@ class StreamStats { } } class Toast { - static $wrapper; - static $msg; - static $status; - static stack = []; - static isShowing = !1; - static timeout; - static DURATION = 3000; - static show(msg, status, options = {}) { + static instance; + static getInstance = () => Toast.instance ?? (Toast.instance = new Toast); + LOG_TAG = "Toast"; + $wrapper; + $msg; + $status; + stack = []; + isShowing = !1; + timeoutId; + DURATION = 3000; + 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) => { + let 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); + } + show(msg, status, options = {}) { options = options || {}; let args = Array.from(arguments); - if (options.instant) Toast.stack = [args], Toast.showNext(); - else Toast.stack.push(args), !Toast.isShowing && Toast.showNext(); + if (options.instant) this.stack = [args], this.showNext(); + else this.stack.push(args), !this.isShowing && this.showNext(); } - static showNext() { - if (!Toast.stack.length) { - Toast.isShowing = !1; + showNext() { + if (!this.stack.length) { + this.isShowing = !1; return; } - Toast.isShowing = !0, Toast.timeout && clearTimeout(Toast.timeout), Toast.timeout = window.setTimeout(Toast.hide, Toast.DURATION); - let [msg, status, options] = Toast.stack.shift(); - if (options && options.html) Toast.$msg.innerHTML = msg; - else Toast.$msg.textContent = msg; - if (status) Toast.$status.classList.remove("bx-gone"), Toast.$status.textContent = status; - else Toast.$status.classList.add("bx-gone"); - let classList = Toast.$wrapper.classList; + this.isShowing = !0, this.timeoutId && clearTimeout(this.timeoutId), this.timeoutId = window.setTimeout(this.hide.bind(this), this.DURATION); + let [msg, status, options] = this.stack.shift(); + if (options && options.html) this.$msg.innerHTML = msg; + else this.$msg.textContent = msg; + if (status) this.$status.classList.remove("bx-gone"), this.$status.textContent = status; + else this.$status.classList.add("bx-gone"); + let classList = this.$wrapper.classList; classList.remove("bx-offscreen", "bx-hide"), classList.add("bx-show"); } - static hide() { - Toast.timeout = null; - let classList = Toast.$wrapper.classList; + hide() { + this.timeoutId = null; + let 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" })), Toast.$wrapper.addEventListener("transitionend", (e) => { - let 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 show(msg, status, options = {}) { + Toast.getInstance().show(msg, status, options); + } + static showNext() { + Toast.getInstance().showNext(); } } function ceilToNearest(value, interval) { @@ -2028,8 +2042,7 @@ class SoundShortcut { }); return; } - let $media; - if ($media = document.querySelector("div[data-testid=media-container] audio"), !$media) $media = document.querySelector("div[data-testid=media-container] video"); + let $media = document.querySelector("div[data-testid=media-container] audio") ?? document.querySelector("div[data-testid=media-container] video"); if ($media) { $media.muted = !$media.muted; let status = $media.muted ? t("muted") : t("unmuted"); @@ -2237,102 +2250,7 @@ class MkbPreset { let mouseMapTo = MouseMapTo[mouse["map_to"]]; if (typeof mouseMapTo !== "undefined") mouse["map_to"] = mouseMapTo; else mouse["map_to"] = MkbPreset.MOUSE_SETTINGS["map_to"].default; - return console.log(obj), obj; - } -} -class LocalDb { - static #instance; - static get INSTANCE() { - if (!LocalDb.#instance) LocalDb.#instance = new LocalDb; - return LocalDb.#instance; - } - static DB_NAME = "BetterXcloud"; - static DB_VERSION = 1; - static TABLE_PRESETS = "mkb_presets"; - #DB; - #open() { - return new Promise((resolve, reject) => { - if (this.#DB) { - resolve(); - return; - } - let request = window.indexedDB.open(LocalDb.DB_NAME, LocalDb.DB_VERSION); - request.onupgradeneeded = (e) => { - let db = e.target.result; - switch (e.oldVersion) { - case 0: { - db.createObjectStore(LocalDb.TABLE_PRESETS, { keyPath: "id", autoIncrement: !0 }).createIndex("name_idx", "name"); - break; - } - } - }, request.onerror = (e) => { - console.log(e), alert(e.target.error.message), reject && reject(); - }, request.onsuccess = (e) => { - this.#DB = e.target.result, resolve(); - }; - }); - } - #table(name, type) { - let table = this.#DB.transaction(name, type || "readonly").objectStore(name); - return new Promise((resolve) => resolve(table)); - } - #call(method) { - let table = arguments[1]; - return new Promise((resolve) => { - let request = method.call(table, ...Array.from(arguments).slice(2)); - request.onsuccess = (e) => { - resolve([table, e.target.result]); - }; - }); - } - #count(table) { - return this.#call(table.count, ...arguments); - } - #add(table, data) { - return this.#call(table.add, ...arguments); - } - #put(table, data) { - return this.#call(table.put, ...arguments); - } - #delete(table, data) { - return this.#call(table.delete, ...arguments); - } - #get(table, id) { - return this.#call(table.get, ...arguments); - } - #getAll(table) { - return this.#call(table.getAll, ...arguments); - } - newPreset(name, data) { - return this.#open().then(() => this.#table(LocalDb.TABLE_PRESETS, "readwrite")).then((table) => this.#add(table, { name, data })).then(([table, id]) => new Promise((resolve) => resolve(id))); - } - updatePreset(preset) { - 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) { - return this.#open().then(() => this.#table(LocalDb.TABLE_PRESETS, "readwrite")).then((table) => this.#delete(table, id)).then(([table, id2]) => new Promise((resolve) => resolve(id2))); - } - getPreset(id) { - 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() { - 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(([table2, items]) => { - let presets = {}; - items.forEach((item) => presets[item.id] = item), resolve(presets); - }); - }); - let preset = { - name: t("default"), - data: MkbPreset.DEFAULT_PRESET - }; - return new Promise((resolve) => { - this.#add(table, preset).then(([table2, id]) => { - preset.id = id, setPref("mkb_default_preset_id", id), resolve({ [id]: preset }); - }); - }); - }); + return obj; } } class KeyHelper { @@ -2362,18 +2280,21 @@ class KeyHelper { return KeyHelper.#NON_PRINTABLE_KEYS[code] || code.startsWith("Key") && code.substring(3) || code.startsWith("Digit") && code.substring(5) || code.startsWith("Numpad") && "Numpad " + code.substring(6) || code.startsWith("Arrow") && "Arrow " + code.substring(5) || code.endsWith("Lock") && code.replace("Lock", " Lock") || code.endsWith("Left") && "Left " + code.replace("Left", "") || code.endsWith("Right") && "Right " + code.replace("Right", "") || code; } } -var LOG_TAG = "PointerClient"; class PointerClient { static instance; static getInstance = () => PointerClient.instance ?? (PointerClient.instance = new PointerClient); + LOG_TAG = "PointerClient"; socket; mkbHandler; + constructor() { + BxLogger.info(this.LOG_TAG, "constructor()"); + } start(port, mkbHandler) { if (!port) throw new Error("PointerServer port is 0"); this.mkbHandler = mkbHandler, this.socket = new WebSocket(`ws://localhost:${port}`), this.socket.binaryType = "arraybuffer", this.socket.addEventListener("open", (event) => { - BxLogger.info(LOG_TAG, "connected"); + BxLogger.info(this.LOG_TAG, "connected"); }), this.socket.addEventListener("error", (event) => { - BxLogger.error(LOG_TAG, event), Toast.show("Cannot setup mouse: " + event); + BxLogger.error(this.LOG_TAG, event), Toast.show("Cannot setup mouse: " + event); }), this.socket.addEventListener("close", (event) => { this.socket = null; }), this.socket.addEventListener("message", (event) => { @@ -2436,6 +2357,112 @@ class MouseDataProvider { } } class MkbHandler {} +class LocalDb { + static DB_NAME = "BetterXcloud"; + static DB_VERSION = 1; + db; + open() { + return new Promise((resolve, reject) => { + if (this.db) { + resolve(); + return; + } + let request = window.indexedDB.open(LocalDb.DB_NAME, LocalDb.DB_VERSION); + request.onupgradeneeded = this.onUpgradeNeeded, request.onerror = (e) => { + console.log(e), alert(e.target.error.message), reject && reject(); + }, request.onsuccess = (e) => { + this.db = e.target.result, resolve(); + }; + }); + } + table(name, type) { + let table = this.db.transaction(name, type || "readonly").objectStore(name); + return new Promise((resolve) => resolve(table)); + } + call(method) { + let table = arguments[1]; + return new Promise((resolve) => { + let request = method.call(table, ...Array.from(arguments).slice(2)); + request.onsuccess = (e) => { + resolve([table, e.target.result]); + }; + }); + } + count(table) { + return this.call(table.count, ...arguments); + } + add(table, data) { + return this.call(table.add, ...arguments); + } + put(table, data) { + return this.call(table.put, ...arguments); + } + delete(table, data) { + return this.call(table.delete, ...arguments); + } + get(table, id) { + return this.call(table.get, ...arguments); + } + getAll(table) { + return this.call(table.getAll, ...arguments); + } +} +class MkbPresetsDb extends LocalDb { + static instance; + static getInstance = () => MkbPresetsDb.instance ?? (MkbPresetsDb.instance = new MkbPresetsDb); + LOG_TAG = "MkbPresetsDb"; + TABLE_PRESETS = "mkb_presets"; + constructor() { + super(); + BxLogger.info(this.LOG_TAG, "constructor()"); + } + onUpgradeNeeded(e) { + let db = e.target.result; + switch (e.oldVersion) { + case 0: { + db.createObjectStore(this.TABLE_PRESETS, { + keyPath: "id", + autoIncrement: !0 + }).createIndex("name_idx", "name"); + break; + } + } + } + presetsTable() { + return this.open().then(() => this.table(this.TABLE_PRESETS, "readwrite")); + } + newPreset(name, data) { + return this.presetsTable().then((table) => this.add(table, { name, data })).then(([table, id]) => new Promise((resolve) => resolve(id))); + } + updatePreset(preset) { + return this.presetsTable().then((table) => this.put(table, preset)).then(([table, id]) => new Promise((resolve) => resolve(id))); + } + deletePreset(id) { + return this.presetsTable().then((table) => this.delete(table, id)).then(([table, id2]) => new Promise((resolve) => resolve(id2))); + } + getPreset(id) { + return this.presetsTable().then((table) => this.get(table, id)).then(([table, preset]) => new Promise((resolve) => resolve(preset))); + } + getPresets() { + return this.presetsTable().then((table) => this.count(table)).then(([table, count]) => { + if (count > 0) return new Promise((resolve) => { + this.getAll(table).then(([table2, items]) => { + let presets = {}; + items.forEach((item) => presets[item.id] = item), resolve(presets); + }); + }); + let preset = { + name: t("default"), + data: MkbPreset.DEFAULT_PRESET + }; + return new Promise((resolve) => { + this.add(table, preset).then(([table2, id]) => { + preset.id = id, setPref("mkb_default_preset_id", id), resolve({ [id]: preset }); + }); + }); + }); + } +} var PointerToMouseButton = { 1: 0, 2: 2, @@ -2498,6 +2525,7 @@ class PointerLockMouseDataProvider extends MouseDataProvider { class EmulatedMkbHandler extends MkbHandler { static instance; static getInstance = () => EmulatedMkbHandler.instance ?? (EmulatedMkbHandler.instance = new EmulatedMkbHandler); + static LOG_TAG = "EmulatedMkbHandler"; #CURRENT_PRESET_DATA = MkbPreset.convert(MkbPreset.DEFAULT_PRESET); static DEFAULT_PANNING_SENSITIVITY = 0.001; static DEFAULT_DEADZONE_COUNTERWEIGHT = 0.01; @@ -2529,7 +2557,7 @@ class EmulatedMkbHandler extends MkbHandler { #RIGHT_STICK_Y = []; constructor() { super(); - this.#STICK_MAP = { + BxLogger.info(EmulatedMkbHandler.LOG_TAG, "constructor()"), this.#STICK_MAP = { 102: [this.#LEFT_STICK_X, 0, -1], 103: [this.#LEFT_STICK_X, 0, 1], 100: [this.#LEFT_STICK_Y, 1, -1], @@ -2646,7 +2674,7 @@ class EmulatedMkbHandler extends MkbHandler { #getCurrentPreset = () => { return new Promise((resolve) => { let presetId = getPref("mkb_default_preset_id"); - LocalDb.INSTANCE.getPreset(presetId).then((preset) => { + MkbPresetsDb.getInstance().getPreset(presetId).then((preset) => { resolve(preset); }); }); @@ -2775,6 +2803,7 @@ class NavigationDialog { class NavigationDialogManager { static instance; static getInstance = () => NavigationDialogManager.instance ?? (NavigationDialogManager.instance = new NavigationDialogManager); + LOG_TAG = "NavigationDialogManager"; static GAMEPAD_POLLING_INTERVAL = 50; static GAMEPAD_KEYS = [ 12, @@ -2815,7 +2844,7 @@ class NavigationDialogManager { $container; dialog = null; constructor() { - if (this.$overlay = CE("div", { class: "bx-navigation-dialog-overlay bx-gone" }), this.$overlay.addEventListener("click", (e) => { + if (BxLogger.info(this.LOG_TAG, "constructor()"), this.$overlay = CE("div", { class: "bx-navigation-dialog-overlay bx-gone" }), this.$overlay.addEventListener("click", (e) => { e.preventDefault(), e.stopPropagation(), this.hide(); }), document.documentElement.appendChild(this.$overlay), this.$container = CE("div", { class: "bx-navigation-dialog bx-gone" }), document.documentElement.appendChild(this.$container), window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, (e) => this.hide()), getPref("ui_controller_friendly")) new MutationObserver((mutationList) => { @@ -3055,6 +3084,293 @@ var BxIcon = { UPLOAD: "", AUDIO: "" }; +class Dialog { + $dialog; + $title; + $content; + $overlay; + onClose; + constructor(options) { + let { + title, + className, + content, + hideCloseButton, + onClose, + helpUrl + } = options, $overlay = document.querySelector(".bx-dialog-overlay"); + if (!$overlay) this.$overlay = CE("div", { class: "bx-dialog-overlay bx-gone" }), this.$overlay.addEventListener("contextmenu", (e) => e.preventDefault()), document.documentElement.appendChild(this.$overlay); + else this.$overlay = $overlay; + let $close; + this.onClose = onClose, this.$dialog = CE("div", { class: `bx-dialog ${className || ""} bx-gone` }, this.$title = CE("h2", {}, CE("b", {}, title), helpUrl && createButton({ + icon: BxIcon.QUESTION, + style: 4, + title: t("help"), + url: helpUrl + })), this.$content = CE("div", { class: "bx-dialog-content" }, content), !hideCloseButton && ($close = CE("button", { type: "button" }, t("close")))), $close && $close.addEventListener("click", (e) => { + this.hide(e); + }), !title && this.$title.classList.add("bx-gone"), !content && this.$content.classList.add("bx-gone"), this.$dialog.addEventListener("contextmenu", (e) => e.preventDefault()), document.documentElement.appendChild(this.$dialog); + } + show(newOptions) { + if (document.activeElement && document.activeElement.blur(), newOptions && newOptions.title) this.$title.querySelector("b").textContent = newOptions.title, this.$title.classList.remove("bx-gone"); + this.$dialog.classList.remove("bx-gone"), this.$overlay.classList.remove("bx-gone"), document.body.classList.add("bx-no-scroll"); + } + hide(e) { + this.$dialog.classList.add("bx-gone"), this.$overlay.classList.add("bx-gone"), document.body.classList.remove("bx-no-scroll"), this.onClose && this.onClose(e); + } + toggle() { + this.$dialog.classList.toggle("bx-gone"), this.$overlay.classList.toggle("bx-gone"); + } +} +class MkbRemapper { + BUTTON_ORDERS = [ + 12, + 13, + 14, + 15, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 16, + 10, + 100, + 101, + 102, + 103, + 11, + 200, + 201, + 202, + 203 + ]; + static instance; + static getInstance = () => MkbRemapper.instance ?? (MkbRemapper.instance = new MkbRemapper); + LOG_TAG = "MkbRemapper"; + STATE = { + currentPresetId: 0, + presets: {}, + editingPresetData: null, + isEditing: !1 + }; + $wrapper; + $presetsSelect; + $activateButton; + $currentBindingKey; + allKeyElements = []; + allMouseElements = {}; + bindingDialog; + constructor() { + BxLogger.info(this.LOG_TAG, "constructor()"), this.STATE.currentPresetId = getPref("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"))), + hideCloseButton: !0 + }); + } + clearEventListeners = () => { + window.removeEventListener("keydown", this.onKeyDown), window.removeEventListener("mousedown", this.onMouseDown), window.removeEventListener("wheel", this.onWheel); + }; + bindKey = ($elm, key) => { + let buttonIndex = parseInt($elm.dataset.buttonIndex), keySlot = parseInt($elm.dataset.keySlot); + if ($elm.dataset.keyCode === key.code) return; + for (let $otherElm of this.allKeyElements) + if ($otherElm.dataset.keyCode === key.code) this.unbindKey($otherElm); + this.STATE.editingPresetData.mapping[buttonIndex][keySlot] = key.code, $elm.textContent = key.name, $elm.dataset.keyCode = key.code; + }; + unbindKey = ($elm) => { + let buttonIndex = parseInt($elm.dataset.buttonIndex), keySlot = parseInt($elm.dataset.keySlot); + this.STATE.editingPresetData.mapping[buttonIndex][keySlot] = null, $elm.textContent = "", delete $elm.dataset.keyCode; + }; + onWheel = (e) => { + e.preventDefault(), this.clearEventListeners(), this.bindKey(this.$currentBindingKey, KeyHelper.getKeyFromEvent(e)), window.setTimeout(() => this.bindingDialog.hide(), 200); + }; + onMouseDown = (e) => { + e.preventDefault(), this.clearEventListeners(), this.bindKey(this.$currentBindingKey, KeyHelper.getKeyFromEvent(e)), window.setTimeout(() => this.bindingDialog.hide(), 200); + }; + onKeyDown = (e) => { + if (e.preventDefault(), e.stopPropagation(), this.clearEventListeners(), e.code !== "Escape") this.bindKey(this.$currentBindingKey, KeyHelper.getKeyFromEvent(e)); + window.setTimeout(() => this.bindingDialog.hide(), 200); + }; + onBindingKey = (e) => { + if (!this.STATE.isEditing || e.button !== 0) return; + console.log(e), this.$currentBindingKey = e.target, window.addEventListener("keydown", this.onKeyDown), window.addEventListener("mousedown", this.onMouseDown), window.addEventListener("wheel", this.onWheel), this.bindingDialog.show({ title: this.$currentBindingKey.dataset.prompt }); + }; + onContextMenu = (e) => { + if (e.preventDefault(), !this.STATE.isEditing) return; + this.unbindKey(e.target); + }; + getPreset = (presetId) => { + return this.STATE.presets[presetId]; + }; + getCurrentPreset = () => { + return this.getPreset(this.STATE.currentPresetId); + }; + switchPreset = (presetId) => { + this.STATE.currentPresetId = presetId; + let presetData = this.getCurrentPreset().data; + for (let $elm of this.allKeyElements) { + let buttonIndex = parseInt($elm.dataset.buttonIndex), keySlot = parseInt($elm.dataset.keySlot), buttonKeys = presetData.mapping[buttonIndex]; + if (buttonKeys && buttonKeys[keySlot]) $elm.textContent = KeyHelper.codeToKeyName(buttonKeys[keySlot]), $elm.dataset.keyCode = buttonKeys[keySlot]; + else $elm.textContent = "", delete $elm.dataset.keyCode; + } + let key; + for (key in this.allMouseElements) { + let $elm = this.allMouseElements[key], value = presetData.mouse[key]; + if (typeof value === "undefined") value = MkbPreset.MOUSE_SETTINGS[key].default; + "setValue" in $elm && $elm.setValue(value); + } + let activated = getPref("mkb_default_preset_id") === this.STATE.currentPresetId; + this.$activateButton.disabled = activated, this.$activateButton.querySelector("span").textContent = activated ? t("activated") : t("activate"); + }; + refresh() { + while (this.$presetsSelect.firstChild) + this.$presetsSelect.removeChild(this.$presetsSelect.firstChild); + MkbPresetsDb.getInstance().getPresets().then((presets) => { + this.STATE.presets = presets; + let $fragment = document.createDocumentFragment(), defaultPresetId; + if (this.STATE.currentPresetId === 0) this.STATE.currentPresetId = parseInt(Object.keys(presets)[0]), defaultPresetId = this.STATE.currentPresetId, setPref("mkb_default_preset_id", defaultPresetId), EmulatedMkbHandler.getInstance().refreshPresetData(); + else defaultPresetId = getPref("mkb_default_preset_id"); + for (let id in presets) { + let name = presets[id].name; + if (id === defaultPresetId) name = "🎮 " + name; + let $options = CE("option", { value: id }, name); + $options.selected = parseInt(id) === this.STATE.currentPresetId, $fragment.appendChild($options); + } + this.$presetsSelect.appendChild($fragment); + let 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); + }); + } + toggleEditing = (force) => { + if (this.STATE.isEditing = typeof force !== "undefined" ? force : !this.STATE.isEditing, this.$wrapper.classList.toggle("bx-editing", this.STATE.isEditing), this.STATE.isEditing) this.STATE.editingPresetData = deepClone(this.getCurrentPreset().data); + else this.STATE.editingPresetData = null; + let childElements = this.$wrapper.querySelectorAll("select, button, input"); + for (let $elm of Array.from(childElements)) { + if ($elm.parentElement.parentElement.classList.contains("bx-mkb-action-buttons")) continue; + let disable = !this.STATE.isEditing; + if ($elm.parentElement.classList.contains("bx-mkb-preset-tools")) disable = !disable; + $elm.disabled = disable; + } + }; + render() { + this.$wrapper = CE("div", { class: "bx-mkb-settings" }), this.$presetsSelect = CE("select", { tabindex: -1 }), this.$presetsSelect.addEventListener("change", (e) => { + this.switchPreset(parseInt(e.target.value)); + }); + let promptNewName = (value) => { + let newName = ""; + while (!newName) { + if (newName = prompt(t("prompt-preset-name"), value), newName === null) return !1; + newName = newName.trim(); + } + return newName ? newName : !1; + }, $header = CE("div", { class: "bx-mkb-preset-tools" }, this.$presetsSelect, createButton({ + title: t("rename"), + icon: BxIcon.CURSOR_TEXT, + tabIndex: -1, + onClick: (e) => { + let preset = this.getCurrentPreset(), newName = promptNewName(preset.name); + if (!newName || newName === preset.name) return; + preset.name = newName, MkbPresetsDb.getInstance().updatePreset(preset).then((id) => this.refresh()); + } + }), createButton({ + icon: BxIcon.NEW, + title: t("new"), + tabIndex: -1, + onClick: (e) => { + let newName = promptNewName(""); + if (!newName) return; + MkbPresetsDb.getInstance().newPreset(newName, MkbPreset.DEFAULT_PRESET).then((id) => { + this.STATE.currentPresetId = id, this.refresh(); + }); + } + }), createButton({ + icon: BxIcon.COPY, + title: t("copy"), + tabIndex: -1, + onClick: (e) => { + let preset = this.getCurrentPreset(), newName = promptNewName(`${preset.name} (2)`); + if (!newName) return; + MkbPresetsDb.getInstance().newPreset(newName, preset.data).then((id) => { + this.STATE.currentPresetId = id, this.refresh(); + }); + } + }), createButton({ + icon: BxIcon.TRASH, + style: 2, + title: t("delete"), + tabIndex: -1, + onClick: (e) => { + if (!confirm(t("confirm-delete-preset"))) return; + MkbPresetsDb.getInstance().deletePreset(this.STATE.currentPresetId).then((id) => { + this.STATE.currentPresetId = 0, this.refresh(); + }); + } + })); + this.$wrapper.appendChild($header); + let $rows = CE("div", { class: "bx-mkb-settings-rows" }, CE("i", { class: "bx-mkb-note" }, t("right-click-to-unbind"))), keysPerButton = 2; + for (let buttonIndex of this.BUTTON_ORDERS) { + let [buttonName, buttonPrompt] = GamepadKeyName[buttonIndex], $elm, $fragment = document.createDocumentFragment(); + for (let i = 0;i < keysPerButton; i++) + $elm = CE("button", { + type: "button", + "data-prompt": buttonPrompt, + "data-button-index": buttonIndex, + "data-key-slot": i + }, " "), $elm.addEventListener("mouseup", this.onBindingKey), $elm.addEventListener("contextmenu", this.onContextMenu), $fragment.appendChild($elm), this.allKeyElements.push($elm); + let $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"))); + let $mouseSettings = document.createDocumentFragment(); + for (let key in MkbPreset.MOUSE_SETTINGS) { + let setting = MkbPreset.MOUSE_SETTINGS[key], value = setting.default, $elm, onChange = (e, value2) => { + this.STATE.editingPresetData.mouse[key] = value2; + }, $row = CE("label", { + class: "bx-settings-row", + for: `bx_setting_${key}` + }, CE("span", { class: "bx-settings-label" }, setting.label), $elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params)); + $mouseSettings.appendChild($row), this.allMouseElements[key] = $elm; + } + $rows.appendChild($mouseSettings), this.$wrapper.appendChild($rows); + let $actionButtons = CE("div", { class: "bx-mkb-action-buttons" }, CE("div", {}, createButton({ + label: t("edit"), + tabIndex: -1, + onClick: (e) => this.toggleEditing(!0) + }), this.$activateButton = createButton({ + label: t("activate"), + style: 1, + tabIndex: -1, + onClick: (e) => { + setPref("mkb_default_preset_id", this.STATE.currentPresetId), EmulatedMkbHandler.getInstance().refreshPresetData(), this.refresh(); + } + })), CE("div", {}, createButton({ + label: t("cancel"), + style: 4, + tabIndex: -1, + onClick: (e) => { + this.switchPreset(this.STATE.currentPresetId), this.toggleEditing(!1); + } + }), createButton({ + label: t("save"), + style: 1, + tabIndex: -1, + onClick: (e) => { + let updatedPreset = deepClone(this.getCurrentPreset()); + updatedPreset.data = this.STATE.editingPresetData, MkbPresetsDb.getInstance().updatePreset(updatedPreset).then((id) => { + if (id === getPref("mkb_default_preset_id")) EmulatedMkbHandler.getInstance().refreshPresetData(); + this.toggleEditing(!1), this.refresh(); + }); + } + }))); + return this.$wrapper.appendChild($actionButtons), this.toggleEditing(!1), this.refresh(), this.$wrapper; + } +} var VIBRATION_DATA_MAP = { gamepadIndex: 8, leftMotorPercent: 8, @@ -3136,9 +3452,10 @@ if (BX_FLAGS.FeatureGates) FeatureGates = Object.assign(BX_FLAGS.FeatureGates, F class FullscreenText { static instance; static getInstance = () => FullscreenText.instance ?? (FullscreenText.instance = new FullscreenText); + LOG_TAG = "FullscreenText"; $text; constructor() { - this.$text = CE("div", { + BxLogger.info(this.LOG_TAG, "constructor()"), this.$text = CE("div", { class: "bx-fullscreen-text bx-gone" }), document.documentElement.appendChild(this.$text); } @@ -3152,9 +3469,10 @@ class FullscreenText { class SettingsNavigationDialog extends NavigationDialog { static instance; static getInstance = () => SettingsNavigationDialog.instance ?? (SettingsNavigationDialog.instance = new SettingsNavigationDialog); + LOG_TAG = "SettingsNavigationDialog"; $container; $tabs; - $settings; + $tabContents; $btnReload; $btnGlobalReload; $noteGlobalReload; @@ -3466,11 +3784,11 @@ class SettingsNavigationDialog extends NavigationDialog { }, !1 ]; - TAB_VIRTUAL_CONTROLLER_ITEMS = [{ + TAB_VIRTUAL_CONTROLLER_ITEMS = () => [{ group: "mkb", label: t("virtual-controller"), helpUrl: "https://better-xcloud.github.io/mouse-and-keyboard/", - content: !1 + content: MkbRemapper.getInstance().render() }]; TAB_NATIVE_MKB_ITEMS = [{ requiredVariants: "full", @@ -3478,7 +3796,7 @@ class SettingsNavigationDialog extends NavigationDialog { label: t("native-mkb"), items: [] }]; - TAB_SHORTCUTS_ITEMS = [{ + TAB_SHORTCUTS_ITEMS = () => [{ requiredVariants: "full", group: "controller-shortcuts", label: t("controller-shortcuts"), @@ -3525,40 +3843,41 @@ class SettingsNavigationDialog extends NavigationDialog { } ] }]; - SETTINGS_UI = [ - { - icon: BxIcon.HOME, + SETTINGS_UI = { + 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" }, - !1, - !1, - { - icon: BxIcon.COMMAND, + mkb: !1, + "native-mkb": !1, + shortcuts: { group: "shortcuts", + icon: BxIcon.COMMAND, items: this.TAB_SHORTCUTS_ITEMS, + lazyContent: !0, requiredVariants: "full" }, - { - icon: BxIcon.STREAM_STATS, + stats: { group: "stats", + icon: BxIcon.STREAM_STATS, items: this.TAB_STATS_ITEMS } - ]; + }; constructor() { super(); - this.renderFullSettings = STATES.supportedRegion && STATES.isSignedIn, this.setupDialog(); + BxLogger.info(this.LOG_TAG, "constructor()"), this.renderFullSettings = STATES.supportedRegion && STATES.isSignedIn, this.setupDialog(); } getDialog() { return this; @@ -3619,8 +3938,10 @@ class SettingsNavigationDialog extends NavigationDialog { BxEvent.dispatch($content.querySelector("select"), "input"); return; } - for (let settingTab of this.SETTINGS_UI) { - if (!settingTab || !settingTab.items) continue; + let settingTabGroup; + for (settingTabGroup in this.SETTINGS_UI) { + let settingTab = this.SETTINGS_UI[settingTabGroup]; + if (!settingTab || !settingTab.items || typeof settingTab.items === "function") continue; for (let settingTabContent of settingTab.items) { if (!settingTabContent || !settingTabContent.items) continue; for (let setting of settingTabContent.items) { @@ -3716,19 +4037,27 @@ class SettingsNavigationDialog extends NavigationDialog { href: "https://github.com/redphx/better-xcloud-devices", target: "_blank", tabindex: 0 - }, t("suggest-settings-link"))), $btnSuggest?.insertAdjacentElement("afterend", $content); + }, t("suggest-settings-link"))), $btnSuggest.insertAdjacentElement("afterend", $content); + } + onTabClicked(e) { + let $svg = e.target.closest("svg"); + if ($svg.dataset.lazy) { + delete $svg.dataset.lazy; + let settingTab = this.SETTINGS_UI[$svg.dataset.group], items = settingTab.items(), $tabContent = this.renderTabContent.call(this, settingTab, items); + this.$tabContents.appendChild($tabContent); + } + let $child, children = Array.from(this.$tabContents.children); + for ($child of children) + if ($child.dataset.tabGroup === $svg.dataset.group) { + if ($child.classList.remove("bx-gone"), getPref("ui_controller_friendly")) this.dialogManager.calculateSelectBoxes($child); + } else $child.classList.add("bx-gone"); + for (let $child2 of Array.from(this.$tabs.children)) + $child2.classList.remove("bx-active"); + $svg.classList.add("bx-active"); } renderTab(settingTab) { let $svg = createSvgIcon(settingTab.icon); - return $svg.dataset.group = settingTab.group, $svg.tabIndex = 0, $svg.addEventListener("click", (e) => { - for (let $child of Array.from(this.$settings.children)) - if ($child.getAttribute("data-tab-group") === settingTab.group) { - if ($child.classList.remove("bx-gone"), getPref("ui_controller_friendly")) this.dialogManager.calculateSelectBoxes($child); - } else $child.classList.add("bx-gone"); - for (let $child of Array.from(this.$tabs.children)) - $child.classList.remove("bx-active"); - $svg.classList.add("bx-active"); - }), $svg; + return $svg.dataset.group = settingTab.group, $svg.tabIndex = 0, settingTab.lazyContent && ($svg.dataset.lazy = settingTab.lazyContent.toString()), $svg.addEventListener("click", this.onTabClicked.bind(this)), $svg; } onGlobalSettingChanged(e) { this.$btnReload.classList.add("bx-danger"), this.$noteGlobalReload.classList.add("bx-gone"), this.$btnGlobalReload.classList.remove("bx-gone"), this.$btnGlobalReload.classList.add("bx-danger"); @@ -3792,6 +4121,8 @@ class SettingsNavigationDialog extends NavigationDialog { if (pref) prefDefinition = getPrefDefinition(pref); if (prefDefinition && !this.isSupportedVariant(prefDefinition.requiredVariants)) return; let label = prefDefinition?.label || setting.label, note = prefDefinition?.note || setting.note, unsupportedNote = prefDefinition?.unsupportedNote || setting.unsupportedNote, experimental = prefDefinition?.experimental || setting.experimental; + 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), typeof prefDefinition.suggest.highest !== "undefined" && (this.suggestedSettings.highest[setting.pref] = prefDefinition.suggest.highest); } @@ -3813,8 +4144,60 @@ class SettingsNavigationDialog extends NavigationDialog { }); $tabContent.appendChild($row), !prefDefinition?.unsupported && setting.onCreated && setting.onCreated(setting, $control); } + renderTabContent(settingTab, items) { + let $tabContent = CE("div", { + class: "bx-gone", + "data-tab-group": settingTab.group + }); + for (let settingTabContent of items) { + if (!settingTabContent) continue; + if (!this.isSupportedVariant(settingTabContent.requiredVariants)) continue; + if (!this.renderFullSettings && settingTab.group === "global" && settingTabContent.group !== "general" && settingTabContent.group !== "footer") continue; + let label = settingTabContent.label; + if (label === t("better-xcloud")) { + if (label += " " + SCRIPT_VERSION, SCRIPT_VARIANT === "lite") label += " (Lite)"; + label = createButton({ + label, + url: "https://github.com/redphx/better-xcloud/releases", + style: 1024 | 8 | 32 + }); + } + if (label) { + let $title = CE("h2", { + _nearby: { + orientation: "horizontal" + } + }, CE("span", {}, label), settingTabContent.helpUrl && createButton({ + icon: BxIcon.QUESTION, + style: 4 | 32, + url: settingTabContent.helpUrl, + title: t("help") + })); + $tabContent.appendChild($title); + } + if (settingTabContent.unsupportedNote) { + let $note = CE("b", { class: "bx-note-unsupported" }, settingTabContent.unsupportedNote); + $tabContent.appendChild($note); + } + if (settingTabContent.unsupported) continue; + if (settingTabContent.content) { + $tabContent.appendChild(settingTabContent.content); + continue; + } + settingTabContent.items = settingTabContent.items || []; + for (let setting of settingTabContent.items) { + if (setting === !1) continue; + if (typeof setting === "function") { + setting.apply(this, [$tabContent]); + continue; + } + this.renderSettingRow(settingTab, $tabContent, settingTabContent, setting); + } + } + return $tabContent; + } setupDialog() { - let $tabs, $settings, $container = CE("div", { + let $tabs, $tabContents, $container = CE("div", { class: "bx-settings-dialog", _nearby: { orientation: "horizontal" @@ -3848,7 +4231,7 @@ class SettingsNavigationDialog extends NavigationDialog { onClick: (e) => { this.dialogManager.hide(); } - }))), $settings = CE("div", { + }))), $tabContents = CE("div", { class: "bx-settings-tab-contents", _nearby: { orientation: "vertical", @@ -3859,65 +4242,19 @@ class SettingsNavigationDialog extends NavigationDialog { } } })); - this.$container = $container, this.$tabs = $tabs, this.$settings = $settings, $container.addEventListener("click", (e) => { + this.$container = $container, this.$tabs = $tabs, this.$tabContents = $tabContents, $container.addEventListener("click", (e) => { if (e.target === $container) e.preventDefault(), e.stopPropagation(), this.hide(); }); - for (let settingTab of this.SETTINGS_UI) { + let settingTabGroup; + for (settingTabGroup in this.SETTINGS_UI) { + let settingTab = this.SETTINGS_UI[settingTabGroup]; if (!settingTab) continue; if (!this.isSupportedVariant(settingTab.requiredVariants)) continue; if (settingTab.group !== "global" && !this.renderFullSettings) continue; let $svg = this.renderTab(settingTab); - $tabs.appendChild($svg); - let $tabContent = CE("div", { - class: "bx-gone", - "data-tab-group": settingTab.group - }); - for (let settingTabContent of settingTab.items) { - if (settingTabContent === !1) continue; - if (!this.isSupportedVariant(settingTabContent.requiredVariants)) continue; - if (!this.renderFullSettings && settingTab.group === "global" && settingTabContent.group !== "general" && settingTabContent.group !== "footer") continue; - let label = settingTabContent.label; - if (label === t("better-xcloud")) { - if (label += " " + SCRIPT_VERSION, SCRIPT_VARIANT === "lite") label += " (Lite)"; - label = createButton({ - label, - url: "https://github.com/redphx/better-xcloud/releases", - style: 1024 | 8 | 32 - }); - } - if (label) { - let $title = CE("h2", { - _nearby: { - orientation: "horizontal" - } - }, CE("span", {}, label), settingTabContent.helpUrl && createButton({ - icon: BxIcon.QUESTION, - style: 4 | 32, - url: settingTabContent.helpUrl, - title: t("help") - })); - $tabContent.appendChild($title); - } - if (settingTabContent.unsupportedNote) { - let $note = CE("b", { class: "bx-note-unsupported" }, settingTabContent.unsupportedNote); - $tabContent.appendChild($note); - } - if (settingTabContent.unsupported) continue; - if (settingTabContent.content) { - $tabContent.appendChild(settingTabContent.content); - continue; - } - settingTabContent.items = settingTabContent.items || []; - for (let setting of settingTabContent.items) { - if (setting === !1) continue; - if (typeof setting === "function") { - setting.apply(this, [$tabContent]); - continue; - } - this.renderSettingRow(settingTab, $tabContent, settingTabContent, setting); - } - } - $settings.appendChild($tabContent); + if ($tabs.appendChild($svg), typeof settingTab.items === "function") continue; + let $tabContent = this.renderTabContent.call(this, settingTab, settingTab.items); + $tabContents.appendChild($tabContent); } $tabs.firstElementChild.dispatchEvent(new Event("click")); } @@ -3933,7 +4270,7 @@ class SettingsNavigationDialog extends NavigationDialog { return $currentTab && $currentTab.focus(), !0; } focusVisibleSetting(type = "first") { - let controls = Array.from(this.$settings.querySelectorAll("div[data-tab-group]:not(.bx-gone) > *")); + let controls = Array.from(this.$tabContents.querySelectorAll("div[data-tab-group]:not(.bx-gone) > *")); if (!controls.length) return !1; if (type === "last") controls.reverse(); for (let $control of controls) { @@ -3954,7 +4291,7 @@ class SettingsNavigationDialog extends NavigationDialog { return !1; } jumpToSettingGroup(direction) { - let $tabContent = this.$settings.querySelector("div[data-tab-group]:not(.bx-gone)"); + let $tabContent = this.$tabContents.querySelector("div[data-tab-group]:not(.bx-gone)"); if (!$tabContent) return !1; let $header, $focusing = document.activeElement; if (!$focusing || !$tabContent.contains($focusing)) $header = $tabContent.querySelector("h2"); @@ -4085,52 +4422,58 @@ function getPreferredServerRegion(shortName = !1) { return null; } class HeaderSection { - static #$remotePlayBtn = createButton({ - classes: ["bx-header-remote-play-button", "bx-gone"], - icon: BxIcon.REMOTE_PLAY, - title: t("remote-play"), - style: 4 | 32 | 512, - onClick: (e) => { - RemotePlayManager.getInstance().togglePopup(); - } - }); - static #$settingsBtn = createButton({ - classes: ["bx-header-settings-button"], - label: "???", - style: 8 | 16 | 32 | 128, - onClick: (e) => { - SettingsNavigationDialog.getInstance().show(); - } - }); - static #$buttonsWrapper = CE("div", {}, getPref("xhome_enabled") ? HeaderSection.#$remotePlayBtn : null, HeaderSection.#$settingsBtn); - static #observer; - static #timeout; - static #injectSettingsButton($parent) { - if (!$parent) return; - let PREF_LATEST_VERSION = getPref("version_latest"), $btnSettings = HeaderSection.#$settingsBtn; - if (isElementVisible(HeaderSection.#$buttonsWrapper)) return; - if ($btnSettings.querySelector("span").textContent = getPreferredServerRegion(!0) || t("better-xcloud"), !SCRIPT_VERSION.includes("beta") && PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) $btnSettings.setAttribute("data-update-available", "true"); - $parent.appendChild(HeaderSection.#$buttonsWrapper); + static instance; + static getInstance = () => HeaderSection.instance ?? (HeaderSection.instance = new HeaderSection); + LOG_TAG = "HeaderSection"; + $btnRemotePlay; + $btnSettings; + $buttonsWrapper; + observer; + timeoutId; + constructor() { + BxLogger.info(this.LOG_TAG, "constructor()"), this.$btnRemotePlay = createButton({ + classes: ["bx-header-remote-play-button", "bx-gone"], + icon: BxIcon.REMOTE_PLAY, + title: t("remote-play"), + style: 4 | 32 | 512, + onClick: (e) => RemotePlayManager.getInstance().togglePopup() + }), this.$btnSettings = createButton({ + classes: ["bx-header-settings-button"], + label: "???", + style: 8 | 16 | 32 | 128, + onClick: (e) => SettingsNavigationDialog.getInstance().show() + }), this.$buttonsWrapper = CE("div", {}, getPref("xhome_enabled") ? this.$btnRemotePlay : null, this.$btnSettings); } - static checkHeader() { + injectSettingsButton($parent) { + if (!$parent) return; + let PREF_LATEST_VERSION = getPref("version_latest"), $btnSettings = this.$btnSettings; + if (isElementVisible(this.$buttonsWrapper)) return; + if ($btnSettings.querySelector("span").textContent = getPreferredServerRegion(!0) || t("better-xcloud"), !SCRIPT_VERSION.includes("beta") && PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) $btnSettings.setAttribute("data-update-available", "true"); + $parent.appendChild(this.$buttonsWrapper); + } + 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); + $target && this.injectSettingsButton($target); } - static showRemotePlayButton() { - HeaderSection.#$remotePlayBtn.classList.remove("bx-gone"); - } - static watchHeader() { + watchHeader() { let $root = document.querySelector("#PageContent header") || document.querySelector("#root"); if (!$root) return; - HeaderSection.#timeout && clearTimeout(HeaderSection.#timeout), HeaderSection.#timeout = null, HeaderSection.#observer && HeaderSection.#observer.disconnect(), HeaderSection.#observer = new MutationObserver((mutationList) => { - HeaderSection.#timeout && clearTimeout(HeaderSection.#timeout), HeaderSection.#timeout = window.setTimeout(HeaderSection.checkHeader, 2000); - }), HeaderSection.#observer.observe($root, { subtree: !0, childList: !0 }), HeaderSection.checkHeader(); + this.timeoutId && clearTimeout(this.timeoutId), this.timeoutId = null, this.observer && this.observer.disconnect(), this.observer = new MutationObserver((mutationList) => { + this.timeoutId && clearTimeout(this.timeoutId), this.timeoutId = window.setTimeout(this.checkHeader.bind(this), 2000); + }), this.observer.observe($root, { subtree: !0, childList: !0 }), this.checkHeader(); + } + showRemotePlayButton() { + this.$btnRemotePlay.classList.remove("bx-gone"); + } + static watchHeader() { + HeaderSection.getInstance().watchHeader(); } } class RemotePlayNavigationDialog extends NavigationDialog { static instance; static getInstance = () => RemotePlayNavigationDialog.instance ?? (RemotePlayNavigationDialog.instance = new RemotePlayNavigationDialog); + LOG_TAG = "RemotePlayNavigationDialog"; STATE_LABELS = { On: t("powered-on"), Off: t("powered-off"), @@ -4140,7 +4483,7 @@ class RemotePlayNavigationDialog extends NavigationDialog { $container; constructor() { super(); - this.setupDialog(); + BxLogger.info(this.LOG_TAG, "constructor()"), this.setupDialog(); } setupDialog() { let $fragment = CE("div", { class: "bx-remote-play-container" }), $settingNote = CE("p", {}), currentResolution = getPref("xhome_resolution"), $resolutions = CE("select", {}, CE("option", { value: "1080p" }, "1080p"), CE("option", { value: "720p" }, "720p")); @@ -4192,20 +4535,23 @@ class RemotePlayNavigationDialog extends NavigationDialog { $btnConnect && $btnConnect.focus(); } } -var LOG_TAG2 = "RemotePlay"; class RemotePlayManager { static instance; static getInstance = () => RemotePlayManager.instance ?? (RemotePlayManager.instance = new RemotePlayManager); + LOG_TAG = "RemotePlayManager"; isInitialized = !1; XCLOUD_TOKEN; XHOME_TOKEN; consoles; regions = []; + constructor() { + BxLogger.info(this.LOG_TAG, "constructor()"); + } initialize() { if (this.isInitialized) return; this.isInitialized = !0, this.getXhomeToken(() => { this.getConsolesList(() => { - BxLogger.info(LOG_TAG2, "Consoles", this.consoles), STATES.supportedRegion && HeaderSection.showRemotePlayButton(), BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY); + BxLogger.info(this.LOG_TAG, "Consoles", this.consoles), STATES.supportedRegion && HeaderSection.getInstance().showRemotePlayButton(), BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY); }); }); } @@ -4366,54 +4712,10 @@ class LoadingScreen { } } class GuideMenu { - static #BUTTONS = { - scriptSettings: createButton({ - label: t("better-xcloud"), - style: 64 | 32 | 1, - onClick: (e) => { - window.addEventListener(BxEvent.XCLOUD_DIALOG_DISMISSED, (e2) => { - setTimeout(() => SettingsNavigationDialog.getInstance().show(), 50); - }, { once: !0 }), GuideMenu.#closeGuideMenu(); - } - }), - closeApp: AppInterface && createButton({ - icon: BxIcon.POWER, - label: t("close-app"), - title: t("close-app"), - style: 64 | 32 | 2, - onClick: (e) => { - AppInterface.closeApp(); - }, - attributes: { - "data-state": "normal" - } - }), - reloadPage: createButton({ - icon: BxIcon.REFRESH, - label: t("reload-page"), - title: t("reload-page"), - style: 64 | 32, - onClick: (e) => { - if (STATES.isPlaying) confirm(t("confirm-reload-stream")) && window.location.reload(); - else window.location.reload(); - GuideMenu.#closeGuideMenu(); - } - }), - backToHome: createButton({ - icon: BxIcon.HOME, - label: t("back-to-home"), - title: t("back-to-home"), - style: 64 | 32, - onClick: (e) => { - confirm(t("back-to-home-confirm")) && (window.location.href = window.location.href.substring(0, 31)), GuideMenu.#closeGuideMenu(); - }, - attributes: { - "data-state": "playing" - } - }) - }; - static #$renderedButtons; - static #closeGuideMenu() { + static instance; + static getInstance = () => GuideMenu.instance ?? (GuideMenu.instance = new GuideMenu); + $renderedButtons; + closeGuideMenu() { if (window.BX_EXPOSED.dialogRoutes) { window.BX_EXPOSED.dialogRoutes.closeAll(); return; @@ -4421,19 +4723,63 @@ class GuideMenu { let $btnClose = document.querySelector("#gamepass-dialog-root button[class^=Header-module__closeButton]"); $btnClose && $btnClose.click(); } - static #renderButtons() { - if (GuideMenu.#$renderedButtons) return GuideMenu.#$renderedButtons; - let $div = CE("div", { - class: "bx-guide-home-buttons" - }), buttons = [ - GuideMenu.#BUTTONS.scriptSettings, + renderButtons() { + if (this.$renderedButtons) return this.$renderedButtons; + let buttons = { + scriptSettings: createButton({ + label: t("better-xcloud"), + style: 64 | 32 | 1, + onClick: (() => { + window.addEventListener(BxEvent.XCLOUD_DIALOG_DISMISSED, (e) => { + setTimeout(() => SettingsNavigationDialog.getInstance().show(), 50); + }, { once: !0 }), this.closeGuideMenu(); + }).bind(this) + }), + closeApp: AppInterface && createButton({ + icon: BxIcon.POWER, + label: t("close-app"), + title: t("close-app"), + style: 64 | 32 | 2, + onClick: (e) => { + AppInterface.closeApp(); + }, + attributes: { + "data-state": "normal" + } + }), + reloadPage: createButton({ + icon: BxIcon.REFRESH, + label: t("reload-page"), + title: t("reload-page"), + style: 64 | 32, + onClick: (() => { + if (this.closeGuideMenu(), 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: 64 | 32, + onClick: (() => { + this.closeGuideMenu(), confirm(t("back-to-home-confirm")) && (window.location.href = window.location.href.substring(0, 31)); + }).bind(this), + attributes: { + "data-state": "playing" + } + }) + }, buttonsLayout = [ + buttons.scriptSettings, [ - GuideMenu.#BUTTONS.backToHome, - GuideMenu.#BUTTONS.reloadPage, - GuideMenu.#BUTTONS.closeApp + buttons.backToHome, + buttons.reloadPage, + buttons.closeApp ] - ]; - for (let $button of buttons) { + ], $div = CE("div", { + class: "bx-guide-home-buttons" + }); + for (let $button of buttonsLayout) { if (!$button) continue; if ($button instanceof HTMLElement) $div.appendChild($button); else if (Array.isArray($button)) { @@ -4443,9 +4789,9 @@ class GuideMenu { $div.appendChild($wrapper); } } - return GuideMenu.#$renderedButtons = $div, $div; + return this.$renderedButtons = $div, $div; } - static #injectHome($root, isPlaying = !1) { + injectHome($root, isPlaying = !1) { let $target = null; if (isPlaying) { $target = $root.querySelector("a[class*=QuitGameButton]"); @@ -4456,19 +4802,19 @@ class GuideMenu { if ($dividers) $target = $dividers[$dividers.length - 1]; } if (!$target) return !1; - let $buttons = GuideMenu.#renderButtons(); + let $buttons = this.renderButtons(); $buttons.dataset.isPlaying = isPlaying.toString(), $target.insertAdjacentElement("afterend", $buttons); } - static async#onShown(e) { + async onShown(e) { if (e.where === "home") { let $root = document.querySelector("#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]"); - $root && GuideMenu.#injectHome($root, STATES.isPlaying); + $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) { + observe($addedElm) { let className = $addedElm.className; if (!className.startsWith("NavigationAnimation") && !className.startsWith("DialogRoutes") && !className.startsWith("Dialog-module__container")) return; let $selectedTab = $addedElm.querySelector("div[class^=NavigationMenu] button[aria-selected=true"); @@ -4483,6 +4829,7 @@ class GuideMenu { class StreamBadges { static instance; static getInstance = () => StreamBadges.instance ?? (StreamBadges.instance = new StreamBadges); + LOG_TAG = "StreamBadges"; serverInfo = {}; badges = { playtime: { @@ -4524,6 +4871,9 @@ class StreamBadges { $container; intervalId; REFRESH_INTERVAL = 3000; + constructor() { + BxLogger.info(this.LOG_TAG, "constructor()"); + } setRegion(region) { this.serverInfo.server = { region @@ -4688,6 +5038,7 @@ class XcloudInterceptor { return STATES.gsToken = obj.gsToken, response.json = () => Promise.resolve(obj), response; } static async handlePlay(request, init) { + BxEvent.dispatch(window, BxEvent.STREAM_LOADING); let PREF_STREAM_TARGET_RESOLUTION = getPref("stream_target_resolution"), PREF_STREAM_PREFERRED_LOCALE = getPref("stream_preferred_locale"), url = typeof request === "string" ? request : request.url, parsedUrl = new URL(url), badgeRegion = parsedUrl.host.split(".", 1)[0]; for (let regionName in STATES.serverRegions) { let region = STATES.serverRegions[regionName]; @@ -4720,6 +5071,7 @@ class XcloudInterceptor { if (request.method !== "GET") return NATIVE_FETCH(request, init); let response = await NATIVE_FETCH(request, init), text = await response.clone().text(); if (!text.length) return response; + BxEvent.dispatch(window, BxEvent.STREAM_STARTING); let obj = JSON.parse(text), overrides = JSON.parse(obj.clientStreamingConfigOverrides || "{}") || {}; overrides.inputConfiguration = overrides.inputConfiguration || {}, overrides.inputConfiguration.enableVibration = !0; let overrideMkb = null; @@ -4751,9 +5103,7 @@ function clearDbLogs(dbName, table) { let db = e.target.result; try { let objectStoreRequest = db.transaction(table, "readwrite").objectStore(table).clear(); - objectStoreRequest.onsuccess = function() { - console.log(`[Better xCloud] Cleared ${dbName}.${table}`); - }; + objectStoreRequest.onsuccess = () => BxLogger.info("clearDbLogs", `Cleared ${dbName}.${table}`); } catch (ex) {} }; } @@ -4802,7 +5152,8 @@ function interceptHttpRequests() { "https://arc.msn.com", "https://browser.events.data.microsoft.com", "https://dc.services.visualstudio.com", - "https://2c06dea3f26c40c69b8456d319791fd0@o427368.ingest.sentry.io" + "https://2c06dea3f26c40c69b8456d319791fd0@o427368.ingest.sentry.io", + "https://mscom.demdex.net" ]); if (getPref("block_social_features")) BLOCKED_URLS = BLOCKED_URLS.concat([ "https://peoplehub.xboxlive.com/users/me/people/social", @@ -4820,25 +5171,36 @@ function interceptHttpRequests() { } return nativeXhrSend.apply(this, arguments); }; - let gamepassAllGames = []; + let gamepassAllGames = [], 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.BX_FETCH = window.fetch = async (request, init) => { let url = typeof request === "string" ? request : request.url; - for (let blocked of BLOCKED_URLS) { - if (!url.startsWith(blocked)) continue; - return new Response('{"acc":1,"webResult":{}}', { - status: 200, - statusText: "200 OK" - }); - } - if (url.endsWith("/play")) BxEvent.dispatch(window, BxEvent.STREAM_LOADING); - if (url.endsWith("/configuration")) BxEvent.dispatch(window, BxEvent.STREAM_STARTING); + for (let blocked of BLOCKED_URLS) + if (url.startsWith(blocked)) return new Response('{"acc":1,"webResult":{}}', { + status: 200, + statusText: "200 OK" + }); + let domain = new URL(url).hostname; + if (IGNORED_DOMAINS.includes(domain)) return NATIVE_FETCH(request, init); if (url.startsWith("https://emerald.xboxservices.com/xboxcomfd/experimentation")) try { let response = await NATIVE_FETCH(request, init), json = await response.json(); if (json && json.exp && json.exp.treatments) for (let key in FeatureGates) json.exp.treatments[key] = FeatureGates[key]; return response.json = () => Promise.resolve(json), response; } catch (e) { - console.log(e); + return console.log(e), NATIVE_FETCH(request, init); } if (STATES.userAgent.capabilities.touch && url.includes("catalog.gamepass.com/sigls/")) { let response = await NATIVE_FETCH(request, init), obj = await response.clone().json(); @@ -5511,7 +5873,7 @@ class RootDialogObserver { if (AppInterface && $addedElm.className.startsWith("SlideSheet-module__container")) { let $gameCardMenu = $addedElm.querySelector("div[class^=MruContextMenu],div[class^=GameCardContextMenu]"); if ($gameCardMenu) return RootDialogObserver.handleGameCardMenu($gameCardMenu), !0; - } else if ($root.querySelector("div[class*=GuideDialog]")) return GuideMenu.observe($addedElm), !0; + } else if ($root.querySelector("div[class*=GuideDialog]")) return GuideMenu.getInstance().observe($addedElm), !0; return !1; } static observe($root) { @@ -5624,6 +5986,6 @@ window.addEventListener("pagehide", (e) => { function main() { if (getPref("game_msfs2020_force_native_mkb")) BX_FLAGS.ForceNativeMkbTitles.push("9PMQDM08SNK9"); if (patchRtcPeerConnection(), patchRtcCodecs(), interceptHttpRequests(), patchVideoApi(), patchCanvasContext(), getPref("audio_enable_volume_control") && patchAudioContext(), getPref("block_tracking")) patchMeControl(), disableAdobeAudienceManager(); - if (RootDialogObserver.waitForRootDialog(), addCss(), Toast.setup(), GuideMenu.addEventListeners(), StreamStatsCollector.setupEvents(), StreamBadges.setupEvents(), StreamStats.setupEvents(), getPref("controller_show_connection_status")) window.addEventListener("gamepadconnected", (e) => showGamepadToast(e.gamepad)), window.addEventListener("gamepaddisconnected", (e) => showGamepadToast(e.gamepad)); + if (RootDialogObserver.waitForRootDialog(), addCss(), GuideMenu.getInstance().addEventListeners(), StreamStatsCollector.setupEvents(), StreamBadges.setupEvents(), StreamStats.setupEvents(), getPref("controller_show_connection_status")) window.addEventListener("gamepadconnected", (e) => showGamepadToast(e.gamepad)), window.addEventListener("gamepaddisconnected", (e) => showGamepadToast(e.gamepad)); } main(); diff --git a/src/index.ts b/src/index.ts index 6adb998..643b905 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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('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('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(); diff --git a/src/modules/controller-shortcut.ts b/src/modules/controller-shortcut.ts index 70c1cf6..13acace 100644 --- a/src/modules/controller-shortcut.ts +++ b/src/modules/controller-shortcut.ts @@ -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) { diff --git a/src/modules/dialog.ts b/src/modules/dialog.ts index 1920822..96b8d14 100644 --- a/src/modules/dialog.ts +++ b/src/modules/dialog.ts @@ -30,7 +30,7 @@ export class Dialog { } = options; // Create dialog overlay - const $overlay = document.querySelector('.bx-dialog-overlay') as HTMLElement; + const $overlay = document.querySelector('.bx-dialog-overlay'); if (!$overlay) { this.$overlay = CE('div', {'class': 'bx-dialog-overlay bx-gone'}); diff --git a/src/modules/game-bar/action-screenshot.ts b/src/modules/game-bar/action-screenshot.ts index 4275618..073e66f 100644 --- a/src/modules/game-bar/action-screenshot.ts +++ b/src/modules/game-bar/action-screenshot.ts @@ -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(); } } diff --git a/src/modules/game-bar/action-true-achievements.ts b/src/modules/game-bar/action-true-achievements.ts index bd33bbe..690dc10 100644 --- a/src/modules/game-bar/action-true-achievements.ts +++ b/src/modules/game-bar/action-true-achievements.ts @@ -18,6 +18,6 @@ export class TrueAchievementsAction extends BaseGameBarAction { onClick(e: Event) { super.onClick(e); - TrueAchievements.open(false); + TrueAchievements.getInstance().open(false); } } diff --git a/src/modules/game-bar/game-bar.ts b/src/modules/game-bar/game-bar.ts index 04f853b..ba0037d 100644 --- a/src/modules/game-bar/game-bar.ts +++ b/src/modules/game-bar/game-bar.ts @@ -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; diff --git a/src/modules/mkb/mkb-handler.ts b/src/modules/mkb/mkb-handler.ts index 45c2b70..6a36984 100644 --- a/src/modules/mkb/mkb-handler.ts +++ b/src/modules/mkb/mkb-handler.ts @@ -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 => { 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(); } }); diff --git a/src/modules/mkb/mkb-preset.ts b/src/modules/mkb/mkb-preset.ts index fde9e84..778dd1f 100644 --- a/src/modules/mkb/mkb-preset.ts +++ b/src/modules/mkb/mkb-preset.ts @@ -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; } } diff --git a/src/modules/mkb/mkb-remapper.ts b/src/modules/mkb/mkb-remapper.ts index 868d158..a4b1c75 100644 --- a/src/modules/mkb/mkb-remapper.ts +++ b/src/modules/mkb/mkb-remapper.ts @@ -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('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('select', {tabindex: -1}); - this.#$.presetsSelect!.addEventListener('change', e => { - this.#switchPreset(parseInt((e.target as HTMLSelectElement).value)); + this.$presetsSelect = CE('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; } } diff --git a/src/modules/mkb/native-mkb-handler.ts b/src/modules/mkb/native-mkb-handler.ts index f7cefc1..2a2d6c4 100644 --- a/src/modules/mkb/native-mkb-handler.ts +++ b/src/modules/mkb/native-mkb-handler.ts @@ -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(); diff --git a/src/modules/mkb/pointer-client.ts b/src/modules/mkb/pointer-client.ts index 495eb19..30b0202 100644 --- a/src/modules/mkb/pointer-client.ts +++ b/src/modules/mkb/pointer-client.ts @@ -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); }); diff --git a/src/modules/patcher.ts b/src/modules/patcher.ts index 0917417..4558c76 100644 --- a/src/modules/patcher.ts +++ b/src/modules/patcher.ts @@ -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('meta[name=gamepass-app-version]'))?.content; const patches = JSON.stringify(ALL_PATCHES); // Calculate signature diff --git a/src/modules/remote-play-manager.ts b/src/modules/remote-play-manager.ts index 4fb1e22..163f2b3 100644 --- a/src/modules/remote-play-manager.ts +++ b/src/modules/remote-play-manager.ts @@ -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; private regions: Array = []; + 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); }); }); diff --git a/src/modules/shortcuts/shortcut-sound.ts b/src/modules/shortcuts/shortcut-sound.ts index 2b94888..1a908c9 100644 --- a/src/modules/shortcuts/shortcut-sound.ts +++ b/src/modules/shortcuts/shortcut-sound.ts @@ -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('div[data-testid=media-container] audio') ?? document.querySelector('div[data-testid=media-container] video'); if ($media) { $media.muted = !$media.muted; diff --git a/src/modules/stream-player.ts b/src/modules/stream-player.ts index 0aed8e7..8369c77 100644 --- a/src/modules/stream-player.ts +++ b/src/modules/stream-player.ts @@ -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 = ''; diff --git a/src/modules/stream/stream-badges.ts b/src/modules/stream/stream-badges.ts index 5924eca..ac341c5 100644 --- a/src/modules/stream/stream-badges.ts +++ b/src/modules/stream/stream-badges.ts @@ -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, diff --git a/src/modules/stream/stream-settings-utils.ts b/src/modules/stream/stream-settings-utils.ts index 31e5967..840ba97 100644 --- a/src/modules/stream/stream-settings-utils.ts +++ b/src/modules/stream/stream-settings-utils.ts @@ -18,7 +18,7 @@ export function onChangeVideoPlayerType() { let isDisabled = false; - const $optCas = $videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement; + const $optCas = $videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`); if (playerType === StreamPlayerType.WEBGL2) { $optCas && ($optCas.disabled = false); diff --git a/src/modules/stream/stream-stats.ts b/src/modules/stream/stream-stats.ts index 2702f45..bf2bc67 100644 --- a/src/modules/stream/stream-stats.ts +++ b/src/modules/stream/stream-stats.ts @@ -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(); } diff --git a/src/modules/stream/stream-ui.ts b/src/modules/stream/stream-ui.ts index 2993b92..a8ab54a 100644 --- a/src/modules/stream/stream-ui.ts +++ b/src/modules/stream/stream-ui.ts @@ -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('#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('button'); if (!$button) { return null; } $button.setAttribute('title', label); - const $orgSvg = $button.querySelector('svg') as SVGElement; + const $orgSvg = $button.querySelector('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('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('div[class^=HUDButton]'); if (!$orgButton) { return; } const hideGripHandle = () => { // Grip handle - const $gripHandle = document.querySelector('#StreamHud button[class^=GripHandle]') as HTMLElement; + const $gripHandle = document.querySelector('#StreamHud button[class^=GripHandle]'); if ($gripHandle && $gripHandle.ariaExpanded === 'true') { $gripHandle.dispatchEvent(new PointerEvent('pointerdown')); $gripHandle.click(); diff --git a/src/modules/ui/dialog/navigation-dialog.ts b/src/modules/ui/dialog/navigation-dialog.ts index 3e76b04..8de771a 100644 --- a/src/modules/ui/dialog/navigation-dialog.ts +++ b/src/modules/ui/dialog/navigation-dialog.ts @@ -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('.bx-select-value')!; width += 20; // Add checkbox's width } else { - $label = $parent.querySelector('div') as HTMLElement; + $label = $parent.querySelector('div')!; } // Set min-width diff --git a/src/modules/ui/dialog/remote-play-dialog.ts b/src/modules/ui/dialog/remote-play-dialog.ts index cb0d6f7..8b1aeb1 100644 --- a/src/modules/ui/dialog/remote-play-dialog.ts +++ b/src/modules/ui/dialog/remote-play-dialog.ts @@ -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.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('.bx-remote-play-device-wrapper button'); $btnConnect && $btnConnect.focus(); } } diff --git a/src/modules/ui/dialog/settings-dialog.ts b/src/modules/ui/dialog/settings-dialog.ts index 7bed895..a75632f 100644 --- a/src/modules/ui/dialog/settings-dialog.ts +++ b/src/modules/ui/dialog/settings-dialog.ts @@ -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 void) | false>; requiredVariants?: BuildVariant | Array; }; type SettingTab = { icon: SVGElement; - group: 'global'; - items: Array; + group: SettingTabGroup, + items: Array | (() => Array); requiredVariants?: BuildVariant | Array; + 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('meta[name=gamepass-app-version]')!.content; + const appDate = new Date(document.querySelector('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('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 = [{ + private readonly TAB_VIRTUAL_CONTROLLER_ITEMS: (() => Array) = () => [{ 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 = [{ @@ -535,7 +541,7 @@ export class SettingsNavigationDialog extends NavigationDialog { }] : [], }]; - private readonly TAB_SHORTCUTS_ITEMS: Array = [{ + private readonly TAB_SHORTCUTS_ITEMS: (() => Array) = () => [{ requiredVariants: 'full', group: 'controller-shortcuts', label: t('controller-shortcuts'), @@ -576,56 +582,59 @@ export class SettingsNavigationDialog extends NavigationDialog { ], }]; - private readonly SETTINGS_UI: Array = [ - { - icon: BxIcon.HOME, + private readonly SETTINGS_UI: Record = { + 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(`#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(`#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): 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('.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('[data-tab-group] > *'); const siblingProperty = direction === 'next' ? 'nextSibling' : 'previousSibling'; let $tmp = $parent; diff --git a/src/modules/ui/fullscreen-text.ts b/src/modules/ui/fullscreen-text.ts index fa7bcc3..a1f1eb2 100644 --- a/src/modules/ui/fullscreen-text.ts +++ b/src/modules/ui/fullscreen-text.ts @@ -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', }); diff --git a/src/modules/ui/guide-menu.ts b/src/modules/ui/guide-menu.ts index 9d337a7..0fbb8d2 100644 --- a/src/modules/ui/guide-menu.ts +++ b/src/modules/ui/guide-menu.ts @@ -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('#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('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('#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; } } diff --git a/src/modules/ui/header.ts b/src/modules/ui/header.ts index 97f3a67..3183742 100644 --- a/src/modules/ui/header.ts +++ b/src/modules/ui/header.ts @@ -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(); } } diff --git a/src/types/preferences.d.ts b/src/types/preferences.d.ts index 104c30b..c2d896d 100644 --- a/src/types/preferences.d.ts +++ b/src/types/preferences.d.ts @@ -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; diff --git a/src/types/setting-definition.d.ts b/src/types/setting-definition.d.ts index eb4bb58..dae1e0d 100644 --- a/src/types/setting-definition.d.ts +++ b/src/types/setting-definition.d.ts @@ -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, ready: (setting: SettingDefinition) => void; type: SettingElementType, diff --git a/src/utils/bx-logger.ts b/src/utils/bx-logger.ts index c24f739..59f9cdd 100644 --- a/src/utils/bx-logger.ts +++ b/src/utils/bx-logger.ts @@ -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); } } diff --git a/src/utils/html.ts b/src/utils/html.ts index 9840446..2d2800b 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -56,6 +56,8 @@ function createElement(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(options: BxButton): T { let $btn; if (options.url) { - $btn = CE('a', {'class': 'bx-button'}) as HTMLAnchorElement; + $btn = CE('a', {'class': 'bx-button'}); $btn.href = options.url; $btn.target = '_blank'; } else { - $btn = CE('button', {'class': 'bx-button', type: 'button'}) as HTMLButtonElement; + $btn = CE('button', {'class': 'bx-button', type: 'button'}); } const style = (options.style || 0) as number; diff --git a/src/utils/local-db.ts b/src/utils/local-db.ts deleted file mode 100644 index 65f48e0..0000000 --- a/src/utils/local-db.ts +++ /dev/null @@ -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((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 { - 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 { - // @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(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 { - 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 { - 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(resolve => { - this.#add(table, preset) - .then(([table, id]) => { - preset.id = id; - setPref(PrefKey.MKB_DEFAULT_PRESET_ID, id); - - resolve({[id]: preset}); - }); - }); - }); - } -} diff --git a/src/utils/local-db/local-db.ts b/src/utils/local-db/local-db.ts new file mode 100644 index 0000000..36a9567 --- /dev/null +++ b/src/utils/local-db/local-db.ts @@ -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((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 { + 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 { + // @ts-ignore + return this.call(table.get, ...arguments); + } + + protected getAll(table: IDBObjectStore): Promise<[IDBObjectStore, any]> { + // @ts-ignore + return this.call(table.getAll, ...arguments); + } +} diff --git a/src/utils/local-db/mkb-presets-db.ts b/src/utils/local-db/mkb-presets-db.ts new file mode 100644 index 0000000..2a303ec --- /dev/null +++ b/src/utils/local-db/mkb-presets-db.ts @@ -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(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 { + return this.presetsTable() + .then(table => this.get(table, id)) + .then(([table, preset]) => new Promise(resolve => resolve(preset))); + } + + getPresets(): Promise { + 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(resolve => { + this.add(table, preset) + .then(([table, id]) => { + preset.id = id; + setPref(PrefKey.MKB_DEFAULT_PRESET_ID, id); + + resolve({[id]: preset}); + }); + }); + }); + } +} diff --git a/src/utils/network.ts b/src/utils/network.ts index 9c5a420..c9f01cf 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -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 => { 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); } } diff --git a/src/utils/root-dialog-observer.ts b/src/utils/root-dialog-observer.ts index 2772649..f66c0c8 100644 --- a/src/utils/root-dialog-observer.ts +++ b/src/utils/root-dialog-observer.ts @@ -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; } diff --git a/src/utils/screenshot-manager.ts b/src/utils/screenshot-manager.ts new file mode 100644 index 0000000..699112e --- /dev/null +++ b/src/utils/screenshot-manager.ts @@ -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('a'); + + this.$canvas = CE('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'); + } +} diff --git a/src/utils/screenshot.ts b/src/utils/screenshot.ts deleted file mode 100644 index d9f59cd..0000000 --- a/src/utils/screenshot.ts +++ /dev/null @@ -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('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('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'); - } -} diff --git a/src/utils/settings-storages/global-settings-storage.ts b/src/utils/settings-storages/global-settings-storage.ts index 86528b3..48149fb 100644 --- a/src/utils/settings-storages/global-settings-storage.ts +++ b/src/utils/settings-storages/global-settings-storage.ts @@ -339,10 +339,10 @@ export class GlobalSettingsStorage extends BaseSettingsStorage { requiredVariants: 'full', label: t('enable-local-co-op-support'), default: false, - note: CE('a', { - href: 'https://github.com/redphx/better-xcloud/discussions/275', - target: '_blank', - }, t('enable-local-co-op-support-note')), + note: () => CE('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('a', { + href: url, + target: '_blank', + }, '⚠️ ' + note); }, }, diff --git a/src/utils/stream-stats-collector.ts b/src/utils/stream-stats-collector.ts index feb9ab8..4e73eee 100644 --- a/src/utils/stream-stats-collector.ts +++ b/src/utils/stream-stats-collector.ts @@ -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) { diff --git a/src/utils/toast.ts b/src/utils/toast.ts index da8b742..20dcfe6 100644 --- a/src/utils/toast.ts +++ b/src/utils/toast.ts @@ -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 = {}) { + 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 = {}) { 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 = {}) { + 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(); } } diff --git a/src/utils/true-achievements.ts b/src/utils/true-achievements.ts index 6c10d94..6b43362 100644 --- a/src/utils/true-achievements.ts +++ b/src/utils/true-achievements.ts @@ -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({ + 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({ + label: t('true-achievements'), + title: t('true-achievements'), + icon: BxIcon.TRUE_ACHIEVEMENTS, + style: ButtonStyle.FOCUSABLE, + onClick: this.onClick.bind(this), + }); + + this.$hiddenLink = CE('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('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('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('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(); } } diff --git a/src/utils/xcloud-api.ts b/src/utils/xcloud-api.ts index dc93e47..7a15dc9 100644 --- a/src/utils/xcloud-api.ts +++ b/src/utils/xcloud-api.ts @@ -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 { if (id in this.CACHE_TITLES) { return this.CACHE_TITLES[id]; diff --git a/src/utils/xcloud-interceptor.ts b/src/utils/xcloud-interceptor.ts index 552de7c..7305537 100644 --- a/src/utils/xcloud-interceptor.ts +++ b/src/utils/xcloud-interceptor.ts @@ -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 || '{}') || {}; diff --git a/src/utils/xhome-interceptor.ts b/src/utils/xhome-interceptor.ts index 8a9ed3b..35f57ed 100644 --- a/src/utils/xhome-interceptor.ts +++ b/src/utils/xhome-interceptor.ts @@ -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(); - 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(); + 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 = { 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); }