From be0cbff3449e01c763247b5a6b4472502806da77 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Tue, 23 Apr 2024 17:55:52 +0700 Subject: [PATCH] Port the rest of the code --- src/index.ts | 199 +++++++- src/modules/bx-event.ts | 4 +- src/modules/loading-screen.ts | 183 +++++++ src/modules/mkb/mkb-handler.ts | 9 +- src/modules/mkb/mouse-cursor-hider.ts | 34 ++ src/modules/patcher.ts | 586 +++++++++++++++++++++ src/modules/preferences.ts | 334 ++++++------ src/modules/remote-play.ts | 353 +++++++++++++ src/modules/screenshot.ts | 96 ++++ src/modules/{ => stream}/stream-badges.ts | 8 +- src/modules/{ => stream}/stream-stats.ts | 30 +- src/modules/stream/stream-ui.ts | 344 +++++++++++++ src/modules/touch-controller.ts | 300 +++++++++++ src/modules/translation.ts | 2 +- src/modules/ui/global-settings.ts | 347 +++++++++++++ src/modules/ui/header.ts | 83 +++ src/modules/ui/ui.ts | 483 ++++++++++++++++++ src/modules/vibration-manager.ts | 150 ++++++ src/types/index.d.ts | 60 ++- src/types/preferences.d.ts | 2 +- src/types/stream-stats.d.ts | 18 + src/types/titles-info.d.ts | 25 + src/utils/gamepad.ts | 33 ++ src/utils/history.ts | 40 ++ src/utils/monkey-patches.ts | 157 ++++++ src/utils/network.ts | 590 ++++++++++++++++++++++ src/utils/region.ts | 30 ++ src/utils/titles-info.ts | 30 +- src/utils/toast.ts | 2 +- src/utils/utils.ts | 40 ++ 30 files changed, 4338 insertions(+), 234 deletions(-) create mode 100644 src/modules/loading-screen.ts create mode 100644 src/modules/mkb/mouse-cursor-hider.ts create mode 100644 src/modules/patcher.ts create mode 100644 src/modules/remote-play.ts create mode 100644 src/modules/screenshot.ts rename src/modules/{ => stream}/stream-badges.ts (97%) rename src/modules/{ => stream}/stream-stats.ts (92%) create mode 100644 src/modules/stream/stream-ui.ts create mode 100644 src/modules/touch-controller.ts create mode 100644 src/modules/ui/global-settings.ts create mode 100644 src/modules/ui/header.ts create mode 100644 src/modules/ui/ui.ts create mode 100644 src/modules/vibration-manager.ts create mode 100644 src/types/stream-stats.d.ts create mode 100644 src/types/titles-info.d.ts create mode 100644 src/utils/gamepad.ts create mode 100644 src/utils/history.ts create mode 100644 src/utils/monkey-patches.ts create mode 100644 src/utils/network.ts create mode 100644 src/utils/region.ts create mode 100644 src/utils/utils.ts diff --git a/src/index.ts b/src/index.ts index c784982..e8f675a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,18 +16,46 @@ import { BxEvent } from "./modules/bx-event"; import { BX_FLAGS } from "./modules/bx-flags"; -import { CE, CTN, createButton, createSvgIcon, Icon } from "./utils/html"; import { BxExposed } from "./modules/bx-exposed"; -import { t } from "./modules/translation"; -import { Dialog } from "./modules/dialog"; +import { getLocale, t } from "./modules/translation"; +import { CE } from "./utils/html"; +import { showGamepadToast } from "./utils/gamepad"; +import { MkbHandler } from "./modules/mkb/mkb-handler"; +import { StreamBadges } from "./modules/stream/stream-badges"; +import { StreamStats } from "./modules/stream/stream-stats"; +import { addCss } from "./utils/css"; +import { Toast } from "./utils/toast"; +import { setupBxUi, updateVideoPlayerCss } from "./modules/ui/ui"; +import { PrefKey, Preferences, getPref } from "./modules/preferences"; +import { LoadingScreen } from "./modules/loading-screen"; +import { MouseCursorHider } from "./modules/mkb/mouse-cursor-hider"; +import { TouchController } from "./modules/touch-controller"; +import { watchHeader } from "./modules/ui/header"; +import { checkForUpdate, disablePwa } from "./utils/utils"; +import { Patcher } from "./modules/patcher"; +import { RemotePlay } from "./modules/remote-play"; +import { onHistoryChanged, patchHistoryMethod } from "./utils/history"; +import { VibrationManager } from "./modules/vibration-manager"; +import { PreloadedState } from "./utils/titles-info"; +import { patchAudioContext, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "./utils/monkey-patches"; +import { interceptHttpRequests } from "./utils/network"; const SCRIPT_VERSION = '3.5.3'; const SCRIPT_HOME = 'https://github.com/redphx/better-xcloud'; +const NATIVE_FETCH = window.fetch; + +let LOCALE = getLocale(); + const AppInterface = window.AppInterface; -const States: BxStates = { +let States: BxStates = { isPlaying: false, appContext: {}, + serverRegions: {}, + hasTouchSupport: ('ontouchstart' in window || navigator.maxTouchPoints > 0), + + currentStream: {}, + remotePlay: {}, }; @@ -99,4 +127,165 @@ window.addEventListener('load', e => { window.BX_EXPOSED = BxExposed; -new Dialog({}) +// Hide Settings UI when navigate to another page +// @ts-ignore +window.addEventListener(BxEvent.POPSTATE, onHistoryChanged); +window.addEventListener('popstate', onHistoryChanged); + +// Make pushState/replaceState methods dispatch BxEvent.POPSTATE event +window.history.pushState = patchHistoryMethod('pushState'); +window.history.replaceState = patchHistoryMethod('replaceState'); + +window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => { + // Start rendering UI + if (document.querySelector('div[class^=UnsupportedMarketPage]')) { + setTimeout(watchHeader, 2000); + } else { + watchHeader(); + } +}); + +window.addEventListener(BxEvent.STREAM_LOADING, e => { + // Get title ID for screenshot's name + if (window.location.pathname.includes('/launch/')) { + const matches = /\/launch\/(?[^\/]+)\/(?\w+)/.exec(window.location.pathname); + if (matches?.groups) { + States.currentStream.titleId = matches.groups.title_id; + States.currentStream.productId = matches.groups.product_id; + } + } else { + States.currentStream.titleId = 'remote-play'; + States.currentStream.productId = ''; + } + + // Setup UI + setupBxUi(); + + // Setup loading screen + getPref(PrefKey.UI_LOADING_SCREEN_GAME_ART) && LoadingScreen.setup(); +}); + + +window.addEventListener(BxEvent.STREAM_STARTING, e => { + // Hide loading screen + LoadingScreen.hide(); + + // Start hiding cursor + if (!getPref(PrefKey.MKB_ENABLED) && getPref(PrefKey.MKB_HIDE_IDLE_CURSOR)) { + MouseCursorHider.start(); + MouseCursorHider.hide(); + } +}); + +window.addEventListener(BxEvent.STREAM_PLAYING, e => { + const $video = (e as any).$video; + States.currentStream.$video = $video; + + States.isPlaying = true; + injectStreamMenuButtons(); + /* + if (getPref(Preferences.CONTROLLER_ENABLE_SHORTCUTS)) { + GamepadHandler.startPolling(); + } + */ + + const PREF_SCREENSHOT_BUTTON_POSITION = getPref(PrefKey.SCREENSHOT_BUTTON_POSITION); + States.currentStream.$screenshotCanvas!.width = $video.videoWidth; + States.currentStream.$screenshotCanvas!.height = $video.videoHeight; + updateVideoPlayerCss(); + + // Setup screenshot button + if (PREF_SCREENSHOT_BUTTON_POSITION !== 'none') { + const $btn = document.querySelector('.bx-screenshot-button')! as HTMLElement; + $btn.style.display = 'block'; + + if (PREF_SCREENSHOT_BUTTON_POSITION === 'bottom-right') { + $btn.style.right = '0'; + } else { + $btn.style.left = '0'; + } + } +}); + +window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => { + BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); +}); + +window.addEventListener(BxEvent.STREAM_STOPPED, e => { + if (!States.isPlaying) { + return; + } + + States.isPlaying = false; + + // Stop MKB listeners + getPref(PrefKey.MKB_ENABLED) && MkbHandler.INSTANCE.destroy(); + + const $quickBar = document.querySelector('.bx-quick-settings-bar'); + if ($quickBar) { + $quickBar.classList.add('bx-gone'); + } + + States.currentStream.audioGainNode = null; + States.currentStream.$video = null; + StreamStats.onStoppedPlaying(); + + const $screenshotBtn = document.querySelector('.bx-screenshot-button'); + if ($screenshotBtn) { + $screenshotBtn.removeAttribute('style'); + } + + MouseCursorHider.stop(); + TouchController.reset(); +}); + + +function main() { + // Monkey patches + patchRtcPeerConnection(); + patchRtcCodecs(); + interceptHttpRequests(); + patchVideoApi(); + + if (getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL)) { + patchAudioContext(); + } + + PreloadedState.override(); + + VibrationManager.initialSetup(); + + // Check for Update + BX_FLAGS.CheckForUpdate && checkForUpdate(); + + // Setup UI + addCss(); + Toast.setup(); + BX_FLAGS.PreloadUi && setupBxUi(); + + StreamBadges.setupEvents(); + StreamStats.setupEvents(); + MkbHandler.setupEvents(); + + Patcher.initialize(); + + disablePwa(); + + // Show a toast when connecting/disconecting controller + window.addEventListener('gamepadconnected', e => showGamepadToast(e.gamepad)); + window.addEventListener('gamepaddisconnected', e => showGamepadToast(e.gamepad)); + + // Preload Remote Play + if (getPref(PrefKey.REMOTE_PLAY_ENABLED)) { + RemotePlay.detect(); + } + + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') { + TouchController.setup(); + } +} + +main(); +function injectStreamMenuButtons() { + throw new Error("Function not implemented."); +} diff --git a/src/modules/bx-event.ts b/src/modules/bx-event.ts index 7cb01a8..8c5e040 100644 --- a/src/modules/bx-event.ts +++ b/src/modules/bx-event.ts @@ -20,11 +20,13 @@ export enum BxEvent { REMOTE_PLAY_READY = 'bx-remote-play-ready', REMOTE_PLAY_FAILED = 'bx-remote-play-failed', + XCLOUD_SERVERS_READY = 'bx-servers-ready', + DATA_CHANNEL_CREATED = 'bx-data-channel-created', } export namespace BxEvent { - export function dispatch(target: HTMLElement | Window, eventName: string, data: any) { + export function dispatch(target: HTMLElement | Window, eventName: string, data?: any) { if (!eventName) { alert('BxEvent.dispatch(): eventName is null'); return; diff --git a/src/modules/loading-screen.ts b/src/modules/loading-screen.ts new file mode 100644 index 0000000..bdc77a6 --- /dev/null +++ b/src/modules/loading-screen.ts @@ -0,0 +1,183 @@ +import { CE } from "../utils/html"; +import { getPreferredServerRegion } from "../utils/region"; +import { TitlesInfo } from "../utils/titles-info"; +import { PrefKey, Preferences, getPref } from "./preferences"; +import { t } from "./translation"; + +export class LoadingScreen { + static #$bgStyle: HTMLElement; + static #$waitTimeBox: HTMLElement; + + static #waitTimeInterval?: number | null = null; + static #orgWebTitle: string; + + static #secondsToString(seconds: number) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + + const mDisplay = m > 0 ? `${m}m`: ''; + const sDisplay = `${s}s`.padStart(s >=0 ? 3 : 4, '0'); + return mDisplay + sDisplay; + } + + static setup() { + // Get titleId from location + const match = window.location.pathname.match(/\/launch\/[^\/]+\/([\w\d]+)/); + if (!match) { + return; + } + + if (!LoadingScreen.#$bgStyle) { + const $bgStyle = CE('style'); + document.documentElement.appendChild($bgStyle); + LoadingScreen.#$bgStyle = $bgStyle; + } + + const titleId = match[1]; + const titleInfo = TitlesInfo.get(titleId); + if (titleInfo && titleInfo.imageHero) { + LoadingScreen.#setBackground(titleInfo.imageHero); + } else { + TitlesInfo.requestCatalogInfo(titleId, (info: TitleInfo) => { + info && info.imageHero && LoadingScreen.#setBackground(info.imageHero); + }); + } + + if (getPref(PrefKey.UI_LOADING_SCREEN_ROCKET) === 'hide') { + LoadingScreen.#hideRocket(); + } + } + + static #hideRocket() { + let $bgStyle = LoadingScreen.#$bgStyle; + + const css = ` +#game-stream div[class*=RocketAnimation-module__container] > svg { + display: none; +} +`; + $bgStyle.textContent += css; + } + + static #setBackground(imageUrl: string) { + // Setup style tag + let $bgStyle = LoadingScreen.#$bgStyle; + + // Limit max width to reduce image size + imageUrl = imageUrl + '?w=1920'; + + const css = ` +#game-stream { + background-image: linear-gradient(#00000033, #000000e6), url(${imageUrl}) !important; + background-color: transparent !important; + background-position: center center !important; + background-repeat: no-repeat !important; + background-size: cover !important; +} + +#game-stream rect[width="800"] { + transition: opacity 0.3s ease-in-out !important; +} +`; + $bgStyle.textContent += css; + + const bg = new Image(); + bg.onload = e => { + $bgStyle.textContent += ` +#game-stream rect[width="800"] { + opacity: 0 !important; +} +`; + }; + bg.src = imageUrl; + } + + static setupWaitTime(waitTime: number) { + // Hide rocket when queing + if (getPref(PrefKey.UI_LOADING_SCREEN_ROCKET) === 'hide-queue') { + LoadingScreen.#hideRocket(); + } + + let secondsLeft = waitTime; + let $countDown; + let $estimated; + + LoadingScreen.#orgWebTitle = document.title; + + const endDate = new Date(); + const timeZoneOffsetSeconds = endDate.getTimezoneOffset() * 60; + endDate.setSeconds(endDate.getSeconds() + waitTime - timeZoneOffsetSeconds); + + let endDateStr = endDate.toISOString().slice(0, 19); + endDateStr = endDateStr.substring(0, 10) + ' ' + endDateStr.substring(11, 19); + endDateStr += ` (${LoadingScreen.#secondsToString(waitTime)})`; + + let estimatedWaitTime = LoadingScreen.#secondsToString(waitTime); + + let $waitTimeBox = LoadingScreen.#$waitTimeBox; + if (!$waitTimeBox) { + $waitTimeBox = CE('div', {'class': 'bx-wait-time-box'}, + CE('label', {}, t('server')), + CE('span', {}, getPreferredServerRegion()), + CE('label', {}, t('wait-time-estimated')), + $estimated = CE('span', {}), + CE('label', {}, t('wait-time-countdown')), + $countDown = CE('span', {}), + ); + + document.documentElement.appendChild($waitTimeBox); + LoadingScreen.#$waitTimeBox = $waitTimeBox; + } else { + $waitTimeBox.classList.remove('bx-gone'); + $estimated = $waitTimeBox.querySelector('.bx-wait-time-estimated')!; + $countDown = $waitTimeBox.querySelector('.bx-wait-time-countdown')!; + } + + $estimated.textContent = endDateStr; + $countDown.textContent = LoadingScreen.#secondsToString(secondsLeft); + document.title = `[${$countDown.textContent}] ${LoadingScreen.#orgWebTitle}`; + + LoadingScreen.#waitTimeInterval = setInterval(() => { + secondsLeft--; + $countDown.textContent = LoadingScreen.#secondsToString(secondsLeft); + document.title = `[${$countDown.textContent}] ${LoadingScreen.#orgWebTitle}`; + + if (secondsLeft <= 0) { + LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval); + LoadingScreen.#waitTimeInterval = null; + } + }, 1000); + } + + static hide() { + LoadingScreen.#orgWebTitle && (document.title = LoadingScreen.#orgWebTitle); + LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('bx-gone'); + + if (getPref(PrefKey.UI_LOADING_SCREEN_GAME_ART) && LoadingScreen.#$bgStyle) { + const $rocketBg = document.querySelector('#game-stream rect[width="800"]'); + $rocketBg && $rocketBg.addEventListener('transitionend', e => { + LoadingScreen.#$bgStyle.textContent += ` +#game-stream { + background: #000 !important; +} +`; + }); + + LoadingScreen.#$bgStyle.textContent += ` +#game-stream rect[width="800"] { + opacity: 1 !important; +} +`; + } + + LoadingScreen.reset(); + } + + static reset() { + LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('bx-gone'); + LoadingScreen.#$bgStyle && (LoadingScreen.#$bgStyle.textContent = ''); + + LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval); + LoadingScreen.#waitTimeInterval = null; + } +} diff --git a/src/modules/mkb/mkb-handler.ts b/src/modules/mkb/mkb-handler.ts index a12a1bf..662f18f 100644 --- a/src/modules/mkb/mkb-handler.ts +++ b/src/modules/mkb/mkb-handler.ts @@ -8,6 +8,7 @@ import { t } from "../translation"; import { LocalDb } from "../../utils/local-db"; import { KeyHelper } from "./key-helper"; import type { MkbStoredPreset } from "../../types/mkb"; +import { showStreamSettings } from "../stream/stream-ui"; /* This class uses some code from Yuzu emulator to handle mouse's movements @@ -25,10 +26,10 @@ export class MkbHandler { #CURRENT_PRESET_DATA = MkbPreset.convert(MkbPreset.DEFAULT_PRESET); - static get DEFAULT_PANNING_SENSITIVITY() { return 0.0010; } - static get DEFAULT_STICK_SENSITIVITY() { return 0.0006; } - static get DEFAULT_DEADZONE_COUNTERWEIGHT() { return 0.01; } - static get MAXIMUM_STICK_RANGE() { return 1.1; } + static readonly DEFAULT_PANNING_SENSITIVITY = 0.0010; + static readonly DEFAULT_STICK_SENSITIVITY = 0.0006; + static readonly DEFAULT_DEADZONE_COUNTERWEIGHT = 0.01; + static readonly MAXIMUM_STICK_RANGE = 1.1; static VIRTUAL_GAMEPAD_ID = 'Xbox 360 Controller'; diff --git a/src/modules/mkb/mouse-cursor-hider.ts b/src/modules/mkb/mouse-cursor-hider.ts new file mode 100644 index 0000000..3e05933 --- /dev/null +++ b/src/modules/mkb/mouse-cursor-hider.ts @@ -0,0 +1,34 @@ +export class MouseCursorHider { + static #timeout: number | null; + static #cursorVisible = true; + + static show() { + document.body && (document.body.style.cursor = 'unset'); + MouseCursorHider.#cursorVisible = true; + } + + static hide() { + document.body && (document.body.style.cursor = 'none'); + MouseCursorHider.#timeout = null; + MouseCursorHider.#cursorVisible = false; + } + + static onMouseMove(e: MouseEvent) { + // Toggle cursor + !MouseCursorHider.#cursorVisible && MouseCursorHider.show(); + // Setup timeout + MouseCursorHider.#timeout && clearTimeout(MouseCursorHider.#timeout); + MouseCursorHider.#timeout = setTimeout(MouseCursorHider.hide, 3000); + } + + static start() { + MouseCursorHider.show(); + document.addEventListener('mousemove', MouseCursorHider.onMouseMove); + } + + static stop() { + MouseCursorHider.#timeout && clearTimeout(MouseCursorHider.#timeout); + document.removeEventListener('mousemove', MouseCursorHider.onMouseMove); + MouseCursorHider.show(); + } +} diff --git a/src/modules/patcher.ts b/src/modules/patcher.ts new file mode 100644 index 0000000..6b29d43 --- /dev/null +++ b/src/modules/patcher.ts @@ -0,0 +1,586 @@ +import { BX_FLAGS } from "./bx-flags"; +import { getPref, Preferences, PrefKey } from "./preferences"; +import { VibrationManager } from "./vibration-manager"; + +const PATCHES = { + // Disable ApplicationInsights.track() function + disableAiTrack(str: string) { + const text = '.track=function('; + const index = str.indexOf(text); + if (index === -1) { + return false; + } + + if (str.substring(0, index + 200).includes('"AppInsightsCore')) { + return false; + } + + return str.substring(0, index) + '.track=function(e){},!!function(' + str.substring(index + text.length); + }, + + // Set disableTelemetry() to true + disableTelemetry(str: string) { + const text = '.disableTelemetry=function(){return!1}'; + if (!str.includes(text)) { + return false; + } + + return str.replace(text, '.disableTelemetry=function(){return!0}'); + }, + + disableTelemetryProvider(str: string) { + const text = 'this.enableLightweightTelemetry=!'; + if (!str.includes(text)) { + return false; + } + + const newCode = [ + 'this.trackEvent', + 'this.trackPageView', + 'this.trackHttpCompleted', + 'this.trackHttpFailed', + 'this.trackError', + 'this.trackErrorLike', + 'this.onTrackEvent', + '()=>{}', + ].join('='); + + return str.replace(text, newCode + ';' + text); + }, + + // Disable IndexDB logging + disableIndexDbLogging(str: string) { + const text = 'async addLog(e,t=1e4){'; + if (!str.includes(text)) { + return false; + } + + return str.replace(text, text + 'return;'); + }, + + // Set TV layout + tvLayout(str: string) { + const text = '?"tv":"default"'; + if (!str.includes(text)) { + return false; + } + + return str.replace(text, '?"tv":"tv"'); + }, + + // Replace "/direct-connect" with "/play" + remotePlayDirectConnectUrl(str: string) { + const index = str.indexOf('/direct-connect'); + if (index === -1) { + return false; + } + + return str.replace(str.substring(index - 9, index + 15), 'https://www.xbox.com/play'); + }, + + remotePlayKeepAlive(str: string) { + if (!str.includes('onServerDisconnectMessage(e){')) { + return false; + } + + str = str.replace('onServerDisconnectMessage(e){', `onServerDisconnectMessage(e) { + const msg = JSON.parse(e); + if (msg.reason === 'WarningForBeingIdle' && !window.location.pathname.includes('/launch/')) { + try { + this.sendKeepAlive(); + return; + } catch (ex) { console.log(ex); } + } + `); + + return str; + }, + + // Enable Remote Play feature + remotePlayConnectMode(str: string) { + const text = 'connectMode:"cloud-connect"'; + if (!str.includes(text)) { + return false; + } + + return str.replace(text, `connectMode:window.BX_REMOTE_PLAY_CONFIG?"xhome-connect":"cloud-connect",remotePlayServerId:(window.BX_REMOTE_PLAY_CONFIG&&window.BX_REMOTE_PLAY_CONFIG.serverId)||''`); + }, + + // Fix the Guide/Nexus button not working in Remote Play + remotePlayGuideWorkaround(str: string) { + const text = 'nexusButtonHandler:this.featureGates.EnableClientGuideInStream'; + if (!str.includes(text)) { + return false; + } + + return str.replace(text, `nexusButtonHandler: !window.BX_REMOTE_PLAY_CONFIG && this.featureGates.EnableClientGuideInStream`); + }, + + // Disable trackEvent() function + disableTrackEvent(str: string) { + const text = 'this.trackEvent='; + if (!str.includes(text)) { + return false; + } + + return str.replace(text, 'this.trackEvent=e=>{},this.uwuwu='); + }, + + // Block WebRTC stats collector + blockWebRtcStatsCollector(str: string) { + const text = 'this.shouldCollectStats=!0'; + if (!str.includes(text)) { + return false; + } + + return str.replace(text, 'this.shouldCollectStats=!1'); + }, + + blockGamepadStatsCollector(str: string) { + const text = 'this.inputPollingIntervalStats.addValue'; + if (!str.includes(text)) { + return false; + } + + str = str.replace('this.inputPollingIntervalStats.addValue', ''); + str = str.replace('this.inputPollingDurationStats.addValue', ''); + return str; + }, + + enableXcloudLogger(str: string) { + const text = 'this.telemetryProvider=e}log(e,t,i){'; + if (!str.includes(text)) { + return false; + } + + str = str.replaceAll(text, text + 'console.log(Array.from(arguments));'); + return str; + }, + + enableConsoleLogging(str: string) { + const text = 'static isConsoleLoggingAllowed(){'; + if (!str.includes(text)) { + return false; + } + + str = str.replaceAll(text, text + 'return true;'); + return str; + }, + + // Control controller vibration + playVibration(str: string) { + const text = '}playVibration(e){'; + if (!str.includes(text)) { + return false; + } + + const newCode = ` +if (!window.BX_ENABLE_CONTROLLER_VIBRATION) { +return void(0); +} +if (window.BX_VIBRATION_INTENSITY && window.BX_VIBRATION_INTENSITY < 1) { +e.leftMotorPercent = e.leftMotorPercent * window.BX_VIBRATION_INTENSITY; +e.rightMotorPercent = e.rightMotorPercent * window.BX_VIBRATION_INTENSITY; +e.leftTriggerMotorPercent = e.leftTriggerMotorPercent * window.BX_VIBRATION_INTENSITY; +e.rightTriggerMotorPercent = e.rightTriggerMotorPercent * window.BX_VIBRATION_INTENSITY; +} +`; + + VibrationManager.updateGlobalVars(); + str = str.replaceAll(text, text + newCode); + return str; + }, + + // Override website's settings + overrideSettings(str: string) { + const index = str.indexOf(',EnableStreamGate:'); + if (index === -1) { + return false; + } + + // Find the next "}," + const endIndex = str.indexOf('},', index); + + const newSettings = [ + // 'EnableStreamGate: false', + 'PwaPrompt: false', + ]; + + const newCode = newSettings.join(','); + + str = str.substring(0, endIndex) + ',' + newCode + str.substring(endIndex); + return str; + }, + + disableGamepadDisconnectedScreen(str: string) { + const index = str.indexOf('"GamepadDisconnected_Title",'); + if (index === -1) { + return false; + } + + const constIndex = str.indexOf('const', index - 30); + str = str.substring(0, constIndex) + 'e.onClose();return null;' + str.substring(constIndex); + return str; + }, + + patchUpdateInputConfigurationAsync(str: string) { + const text = 'async updateInputConfigurationAsync(e){'; + if (!str.includes(text)) { + return false; + } + + const newCode = 'e.enableTouchInput = true;'; + + str = str.replace(text, text + newCode); + return str; + }, + + // Add patches that are only needed when start playing + loadingEndingChunks(str: string) { + const text = 'Symbol("ChatSocketPlugin")'; + if (!str.includes(text)) { + return false; + } + + console.log('[Better xCloud] Remaining patches:', PATCH_ORDERS); + PATCH_ORDERS = PATCH_ORDERS.concat(PLAYING_PATCH_ORDERS); + Patcher.cleanupPatches(); + + return str; + }, + + // Disable StreamGate + disableStreamGate(str: string) { + const index = str.indexOf('case"partially-ready":'); + if (index === -1) { + return false; + } + + const bracketIndex = str.indexOf('=>{', index - 150) + 3; + + str = str.substring(0, bracketIndex) + 'return 0;' + str.substring(bracketIndex); + return str; + }, + + exposeTouchLayoutManager(str: string) { + const text = 'this._perScopeLayoutsStream=new'; + if (!str.includes(text)) { + return false; + } + + str = str.replace(text, 'window.BX_EXPOSED["touch_layout_manager"] = this,' + text); + return str; + }, + + supportLocalCoOp(str: string) { + const text = 'this.gamepadMappingsToSend=[],'; + if (!str.includes(text)) { + return false; + } + + let patchstr = ` +let match; +let onGamepadChangedStr = this.onGamepadChanged.toString(); + +onGamepadChangedStr = onGamepadChangedStr.replaceAll('0', 'arguments[1]'); +eval(\`this.onGamepadChanged = function \${onGamepadChangedStr}\`); + +let onGamepadInputStr = this.onGamepadInput.toString(); + +match = onGamepadInputStr.match(/(\w+\.GamepadIndex)/); +if (match) { +const gamepadIndexVar = match[0]; +onGamepadInputStr = onGamepadInputStr.replace('this.gamepadStates.get(', \`this.gamepadStates.get(\${gamepadIndexVar},\`); +eval(\`this.onGamepadInput = function \${onGamepadInputStr}\`); +console.log('[Better xCloud] ✅ Successfully patched local co-op support'); +} else { +console.log('[Better xCloud] ❌ Unable to patch local co-op support'); +} +`; + + const newCode = `true; ${patchstr}; true,`; + + str = str.replace(text, text + newCode); + return str; + }, + + forceFortniteConsole(str: string) { + const text = 'sendTouchInputEnabledMessage(e){'; + if (!str.includes(text)) { + return false; + } + + const newCode = `window.location.pathname.includes('/launch/fortnite/') && (e = false);`; + + str = str.replace(text, text + newCode); + return str; + }, + + disableTakRenderer(str: string) { + const text = 'const{TakRenderer:'; + if (!str.includes(text)) { + return false; + } + + let newCode = ''; + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off') { + newCode = 'return;'; + } else { + newCode = ` +const gamepads = window.navigator.getGamepads(); +let gamepadFound = false; + +for (let gamepad of gamepads) { +if (gamepad && gamepad.connected) { + gamepadFound = true; + break; +} +} + +if (gamepadFound) { +return; +} +`; + } + + str = str.replace(text, newCode + text); + return str; + }, + + streamCombineSources(str: string) { + const text = 'this.useCombinedAudioVideoStream=!!this.deviceInformation.isTizen'; + if (!str.includes(text)) { + return false; + } + + str = str.replace(text, 'this.useCombinedAudioVideoStream=true'); + return str; + }, + + patchStreamHud(str: string) { + const text = 'let{onCollapse'; + if (!str.includes(text)) { + return false; + } + + // Restore the "..." button + str = str.replace(text, 'e.guideUI = null;' + text); + + // Remove the TAK Edit button when the touch controller is disabled + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off') { + str = str.replace(text, 'e.canShowTakHUD = false;' + text); + } + return str; + }, + + broadcastPollingMode(str: string) { + const text = '.setPollingMode=e=>{'; + if (!str.includes(text)) { + return false; + } + + const newCode = ` +window.BX_EXPOSED.onPollingModeChanged && window.BX_EXPOSED.onPollingModeChanged(e); +`; + str = str.replace(text, text + newCode); + return str; + }, +}; + +let PATCH_ORDERS = [ + getPref(PrefKey.BLOCK_TRACKING) && [ + 'disableAiTrack', + 'disableTelemetry', + ], + + ['disableStreamGate'], + + ['broadcastPollingMode'], + + getPref(PrefKey.UI_LAYOUT) === 'tv' && ['tvLayout'], + + BX_FLAGS.EnableXcloudLogging && [ + 'enableConsoleLogging', + 'enableXcloudLogger', + ], + + getPref(PrefKey.LOCAL_CO_OP_ENABLED) && ['supportLocalCoOp'], + + getPref(PrefKey.BLOCK_TRACKING) && [ + 'blockWebRtcStatsCollector', + 'disableIndexDbLogging', + ], + + getPref(PrefKey.BLOCK_TRACKING) && [ + 'disableTelemetryProvider', + 'disableTrackEvent', + ], + + getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayKeepAlive'], + getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayDirectConnectUrl'], + + [ + 'overrideSettings', + ], + + getPref(PrefKey.REMOTE_PLAY_ENABLED) && States.hasTouchSupport && ['patchUpdateInputConfigurationAsync'], + + getPref(PrefKey.GAME_FORTNITE_FORCE_CONSOLE) && ['forceFortniteConsole'], +]; + + +// Only when playing +const PLAYING_PATCH_ORDERS = [ + getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayConnectMode'], + getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayGuideWorkaround'], + + ['patchStreamHud'], + + ['playVibration'], + States.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && ['exposeTouchLayoutManager'], + States.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && ['disableTakRenderer'], + + BX_FLAGS.EnableXcloudLogging && ['enableConsoleLogging'], + + getPref(PrefKey.BLOCK_TRACKING) && ['blockGamepadStatsCollector'], + + [ + 'disableGamepadDisconnectedScreen', + ], + + getPref(PrefKey.STREAM_COMBINE_SOURCES) && ['streamCombineSources'], +]; + +export class Patcher { + static #patchFunctionBind() { + const nativeBind = Function.prototype.bind; + Function.prototype.bind = function() { + let valid = false; + if (this.name.length <= 2 && arguments.length === 2 && arguments[0] === null) { + if (arguments[1] === 0 || (typeof arguments[1] === 'function')) { + valid = true; + } + } + + if (!valid) { + // @ts-ignore + return nativeBind.apply(this, arguments); + } + + if (typeof arguments[1] === 'function') { + console.log('[Better xCloud] Restored Function.prototype.bind()'); + Function.prototype.bind = nativeBind; + } + + const orgFunc = this; + const newFunc = (a: any, item: any) => { + if (Patcher.length() === 0) { + orgFunc(a, item); + return; + } + + Patcher.patch(item); + orgFunc(a, item); + } + + // @ts-ignore + return nativeBind.apply(newFunc, arguments); + }; + } + + static length() { return PATCH_ORDERS.length; }; + + static patch(item: any) { + // console.log('patch', '-----'); + let patchName; + let appliedPatches; + + for (let id in item[1]) { + if (PATCH_ORDERS.length <= 0) { + return; + } + + appliedPatches = []; + const func = item[1][id]; + let str = func.toString(); + + for (let groupIndex = 0; groupIndex < PATCH_ORDERS.length; groupIndex++) { + const group = PATCH_ORDERS[groupIndex]; + let modified = false; + + for (let patchIndex = 0; patchIndex < group.length; patchIndex++) { + const patchName = group[patchIndex] as keyof typeof PATCHES; + if (appliedPatches.indexOf(patchName) > -1) { + continue; + } + + const patchedstr = PATCHES[patchName].call(null, str); + if (!patchedstr) { + // Only stop if the first patch is failed + if (patchIndex === 0) { + break; + } else { + continue; + } + } + + modified = true; + str = patchedstr; + + console.log(`[Better xCloud] Applied "${patchName}" patch`); + appliedPatches.push(patchName); + + // Remove patch from group + group.splice(patchIndex, 1); + patchIndex--; + } + + // Apply patched functions + if (modified) { + item[1][id] = eval(str); + } + + // Remove empty group + if (!group.length) { + PATCH_ORDERS.splice(groupIndex, 1); + groupIndex--; + } + } + } + } + + // Remove disabled patches + static cleanupPatches() { + for (let groupIndex = PATCH_ORDERS.length - 1; groupIndex >= 0; groupIndex--) { + const group = PATCH_ORDERS[groupIndex]; + if (group === false) { + PATCH_ORDERS.splice(groupIndex, 1); + continue; + } + + for (let patchIndex = group.length - 1; patchIndex >= 0; patchIndex--) { + const patchName = group[patchIndex] as keyof typeof PATCHES; + if (!PATCHES[patchName]) { + // Remove disabled patch + group.splice(patchIndex, 1); + } + } + + // Remove empty group + if (!group.length) { + PATCH_ORDERS.splice(groupIndex, 1); + } + } + } + + static initialize() { + if (window.location.pathname.includes('/play/')) { + PATCH_ORDERS = PATCH_ORDERS.concat(PLAYING_PATCH_ORDERS); + } else { + PATCH_ORDERS.push(['loadingEndingChunks']); + } + + Patcher.cleanupPatches(); + Patcher.#patchFunctionBind(); + } +} diff --git a/src/modules/preferences.ts b/src/modules/preferences.ts index e90fd58..8a65577 100644 --- a/src/modules/preferences.ts +++ b/src/modules/preferences.ts @@ -2,11 +2,9 @@ import { CE } from "../utils/html"; import { t } from "./translation"; import { SettingElement, SettingElementType } from "./settings"; import { UserAgentProfile } from "../utils/user-agent"; -import { StreamStat } from "./stream-stats"; +import { StreamStat } from "./stream/stream-stats"; import type { PreferenceSetting, PreferenceSettings } from "../types/preferences"; -declare var HAS_TOUCH_SUPPORT: boolean; - export enum PrefKey { LAST_UPDATE_CHECK = 'version_last_check', LATEST_VERSION = 'version_latest', @@ -90,17 +88,18 @@ export enum PrefKey { export class Preferences { static SETTINGS: PreferenceSettings = { [PrefKey.LAST_UPDATE_CHECK]: { - 'default': 0, + default: 0, }, [PrefKey.LATEST_VERSION]: { - 'default': '', + default: '', }, [PrefKey.CURRENT_VERSION]: { - 'default': '', + default: '', }, [PrefKey.BETTER_XCLOUD_LOCALE]: { - 'default': localStorage.getItem('better_xcloud_locale') || 'en-US', - 'options': { + label: t('language'), + default: localStorage.getItem('better_xcloud_locale') || 'en-US', + options: { 'en-ID': 'Bahasa Indonesia', 'de-DE': 'Deutsch', 'en-US': 'English (United States)', @@ -119,12 +118,14 @@ export class Preferences { }, }, [PrefKey.SERVER_REGION]: { - 'default': 'default', + label: t('region'), + default: 'default', }, [PrefKey.STREAM_PREFERRED_LOCALE]: { - 'default': 'default', - 'options': { - 'default': t('default'), + label: t('preferred-game-language'), + default: 'default', + options: { + default: t('default'), 'ar-SA': 'العربية', 'cs-CZ': 'čeština', 'da-DK': 'dansk', @@ -155,18 +156,20 @@ export class Preferences { }, }, [PrefKey.STREAM_TARGET_RESOLUTION]: { - 'default': 'auto', - 'options': { - 'auto': t('default'), + label: t('target-resolution'), + default: 'auto', + options: { + auto: t('default'), '1080p': '1080p', '720p': '720p', }, }, [PrefKey.STREAM_CODEC_PROFILE]: { - 'default': 'default', - 'options': (() => { + label: t('visual-quality'), + default: 'default', + options: (() => { const options: {[index: string]: string} = { - 'default': t('default'), + default: t('default'), }; if (!('getCapabilities' in RTCRtpReceiver) || typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) { @@ -219,7 +222,7 @@ export class Preferences { return options; })(), - 'ready': () => { + ready: () => { const setting = Preferences.SETTINGS[PrefKey.STREAM_CODEC_PROFILE] const options: any = setting.options; const keys = Object.keys(options); @@ -234,43 +237,50 @@ export class Preferences { }, }, [PrefKey.PREFER_IPV6_SERVER]: { - 'default': false, + label: t('prefer-ipv6-server'), + default: false, }, [PrefKey.SCREENSHOT_BUTTON_POSITION]: { - 'default': 'bottom-left', - 'options': { + label: t('screenshot-button-position'), + default: 'bottom-left', + options: { 'bottom-left': t('bottom-left'), 'bottom-right': t('bottom-right'), 'none': t('disable'), }, }, [PrefKey.SCREENSHOT_APPLY_FILTERS]: { - 'default': false, + label: t('screenshot-apply-filters'), + default: false, }, [PrefKey.SKIP_SPLASH_VIDEO]: { - 'default': false, + label: t('skip-splash-video'), + default: false, }, [PrefKey.HIDE_DOTS_ICON]: { - 'default': false, + label: t('hide-system-menu-icon'), + default: false, }, [PrefKey.STREAM_COMBINE_SOURCES]: { - 'default': false, - 'experimental': true, - 'note': t('combine-audio-video-streams-summary'), + label: t('combine-audio-video-streams'), + default: false, + experimental: true, + note: t('combine-audio-video-streams-summary'), }, [PrefKey.STREAM_TOUCH_CONTROLLER]: { - 'default': 'all', - 'options': { - 'default': t('default'), - 'all': t('tc-all-games'), - 'off': t('off'), + label: t('tc-availability'), + default: 'all', + options: { + default: t('default'), + all: t('tc-all-games'), + off: t('off'), }, - 'unsupported': !HAS_TOUCH_SUPPORT, - 'ready': () => { + unsupported: !States.hasTouchSupport, + ready: () => { const setting = Preferences.SETTINGS[PrefKey.STREAM_TOUCH_CONTROLLER]; if (setting.unsupported) { setting.default = 'default'; @@ -278,88 +288,96 @@ export class Preferences { }, }, [PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: { - 'default': false, - 'unsupported': !HAS_TOUCH_SUPPORT, + label: t('tc-auto-off'), + default: false, + unsupported: !States.hasTouchSupport, }, [PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: { - 'default': 'default', - 'options': { - 'default': t('default'), - 'white': t('tc-all-white'), - 'muted': t('tc-muted-colors'), + label: t('tc-standard-layout-style'), + default: 'default', + options: { + default: t('default'), + white: t('tc-all-white'), + muted: t('tc-muted-colors'), }, - 'unsupported': !HAS_TOUCH_SUPPORT, + unsupported: !States.hasTouchSupport, }, [PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: { - 'default': 'default', - 'options': { - 'default': t('default'), - 'muted': t('tc-muted-colors'), + label: t('tc-custom-layout-style'), + default: 'default', + options: { + default: t('default'), + muted: t('tc-muted-colors'), }, - 'unsupported': !HAS_TOUCH_SUPPORT, + unsupported: !States.hasTouchSupport, }, [PrefKey.STREAM_SIMPLIFY_MENU]: { - 'default': false, + label: t('simplify-stream-menu'), + default: false, }, [PrefKey.MKB_HIDE_IDLE_CURSOR]: { - 'default': false, + label: t('hide-idle-cursor'), + default: false, }, [PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG]: { - 'default': false, + label: t('disable-post-stream-feedback-dialog'), + default: false, }, [PrefKey.LOCAL_CO_OP_ENABLED]: { - 'default': false, - 'note': CE('a', { - href: 'https://github.com/redphx/better-xcloud/discussions/275', - target: '_blank', - }, t('enable-local-co-op-support-note')), + 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')), }, /* [Preferences.LOCAL_CO_OP_SEPARATE_TOUCH_CONTROLLER]: { - 'default': false, + default: false, 'note': t('separate-touch-controller-note'), }, */ [PrefKey.CONTROLLER_ENABLE_SHORTCUTS]: { - 'default': false, + default: false, }, [PrefKey.CONTROLLER_ENABLE_VIBRATION]: { - 'default': true, + default: true, }, [PrefKey.CONTROLLER_DEVICE_VIBRATION]: { - 'default': 'off', - 'options': { - 'on': t('on'), - 'auto': t('device-vibration-not-using-gamepad'), - 'off': t('off'), + default: 'off', + options: { + on: t('on'), + auto: t('device-vibration-not-using-gamepad'), + off: t('off'), }, }, [PrefKey.CONTROLLER_VIBRATION_INTENSITY]: { - 'type': SettingElementType.NUMBER_STEPPER, - 'default': 100, - 'min': 0, - 'max': 100, - 'steps': 10, - 'params': { + type: SettingElementType.NUMBER_STEPPER, + default: 100, + min: 0, + max: 100, + steps: 10, + params: { suffix: '%', ticks: 10, }, }, [PrefKey.MKB_ENABLED]: { - 'default': false, - 'unsupported': ((): string | boolean => { + label: t('enable-mkb'), + default: false, + unsupported: ((): string | boolean => { const userAgent = ((window.navigator as any).orgUserAgent || window.navigator.userAgent || '').toLowerCase(); return userAgent.match(/(android|iphone|ipad)/) ? t('browser-unsupported-feature') : false; })(), - 'ready': () => { + ready: () => { const pref = Preferences.SETTINGS[PrefKey.MKB_ENABLED]; let note; @@ -380,52 +398,61 @@ export class Preferences { }, [PrefKey.MKB_DEFAULT_PRESET_ID]: { - 'default': 0, + default: 0, }, [PrefKey.MKB_ABSOLUTE_MOUSE]: { - 'default': false, + default: false, }, [PrefKey.REDUCE_ANIMATIONS]: { - 'default': false, + label: t('reduce-animations'), + default: false, }, [PrefKey.UI_LOADING_SCREEN_GAME_ART]: { - 'default': true, + label: t('show-game-art'), + default: true, }, [PrefKey.UI_LOADING_SCREEN_WAIT_TIME]: { - 'default': true, + label: t('show-wait-time'), + default: true, }, [PrefKey.UI_LOADING_SCREEN_ROCKET]: { - 'default': 'show', - 'options': { - 'show': t('rocket-always-show'), + label: t('rocket-animation'), + default: 'show', + options: { + show: t('rocket-always-show'), 'hide-queue': t('rocket-hide-queue'), - 'hide': t('rocket-always-hide'), + hide: t('rocket-always-hide'), }, }, [PrefKey.UI_LAYOUT]: { - 'default': 'default', - 'options': { - 'default': t('default'), - 'tv': t('smart-tv'), + label: t('layout'), + default: 'default', + options: { + default: t('default'), + tv: t('smart-tv'), }, }, [PrefKey.UI_SCROLLBAR_HIDE]: { - 'default': false, + label: t('hide-scrollbar'), + default: false, }, [PrefKey.BLOCK_SOCIAL_FEATURES]: { - 'default': false, + label: t('disable-social-features'), + default: false, }, [PrefKey.BLOCK_TRACKING]: { - 'default': false, + label: t('disable-xcloud-analytics'), + default: false, }, [PrefKey.USER_AGENT_PROFILE]: { - 'default': 'default', - 'options': { + label: t('user-agent-profile'), + default: 'default', + options: { [UserAgentProfile.DEFAULT]: t('default'), [UserAgentProfile.EDGE_WINDOWS]: 'Edge + Windows', [UserAgentProfile.SAFARI_MACOS]: 'Safari + macOS', @@ -434,74 +461,76 @@ export class Preferences { }, }, [PrefKey.USER_AGENT_CUSTOM]: { - 'default': '', + default: '', }, [PrefKey.VIDEO_CLARITY]: { - 'type': SettingElementType.NUMBER_STEPPER, - 'default': 0, - 'min': 0, - 'max': 5, - 'params': { + type: SettingElementType.NUMBER_STEPPER, + default: 0, + min: 0, + max: 5, + params: { hideSlider: true, }, }, [PrefKey.VIDEO_RATIO]: { - 'default': '16:9', - 'options': { + default: '16:9', + options: { '16:9': '16:9', '18:9': '18:9', '21:9': '21:9', '16:10': '16:10', '4:3': '4:3', - 'fill': t('stretch'), + fill: t('stretch'), //'cover': 'Cover', }, }, [PrefKey.VIDEO_SATURATION]: { - 'type': SettingElementType.NUMBER_STEPPER, - 'default': 100, - 'min': 50, - 'max': 150, - 'params': { + type: SettingElementType.NUMBER_STEPPER, + default: 100, + min: 50, + max: 150, + params: { suffix: '%', ticks: 25, }, }, [PrefKey.VIDEO_CONTRAST]: { - 'type': SettingElementType.NUMBER_STEPPER, - 'default': 100, - 'min': 50, - 'max': 150, - 'params': { + type: SettingElementType.NUMBER_STEPPER, + default: 100, + min: 50, + max: 150, + params: { suffix: '%', ticks: 25, }, }, [PrefKey.VIDEO_BRIGHTNESS]: { - 'type': SettingElementType.NUMBER_STEPPER, - 'default': 100, - 'min': 50, - 'max': 150, - 'params': { + type: SettingElementType.NUMBER_STEPPER, + default: 100, + min: 50, + max: 150, + params: { suffix: '%', ticks: 25, }, }, [PrefKey.AUDIO_MIC_ON_PLAYING]: { - 'default': false, + label: t('enable-mic-on-startup'), + default: false, }, [PrefKey.AUDIO_ENABLE_VOLUME_CONTROL]: { - 'default': false, - 'experimental': true, + label: t('enable-volume-control'), + default: false, + experimental: true, }, [PrefKey.AUDIO_VOLUME]: { - 'type': SettingElementType.NUMBER_STEPPER, - 'default': 100, - 'min': 0, - 'max': 600, - 'params': { + type: SettingElementType.NUMBER_STEPPER, + default: 100, + min: 0, + max: 600, + params: { suffix: '%', ticks: 100, }, @@ -509,8 +538,8 @@ export class Preferences { [PrefKey.STATS_ITEMS]: { - 'default': [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST], - 'multiple_options': { + default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST], + multipleOptions: { [StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`, [StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`, [StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`, @@ -518,70 +547,72 @@ export class Preferences { [StreamStat.PACKETS_LOST]: `${StreamStat.PACKETS_LOST.toUpperCase()}: ${t('stat-packets-lost')}`, [StreamStat.FRAMES_LOST]: `${StreamStat.FRAMES_LOST.toUpperCase()}: ${t('stat-frames-lost')}`, }, - 'params': { + params: { size: 6, }, }, [PrefKey.STATS_SHOW_WHEN_PLAYING]: { - 'default': false, + default: false, }, [PrefKey.STATS_QUICK_GLANCE]: { - 'default': true, + default: true, }, [PrefKey.STATS_POSITION]: { - 'default': 'top-right', - 'options': { + default: 'top-right', + options: { 'top-left': t('top-left'), 'top-center': t('top-center'), 'top-right': t('top-right'), }, }, [PrefKey.STATS_TEXT_SIZE]: { - 'default': '0.9rem', - 'options': { + default: '0.9rem', + options: { '0.9rem': t('small'), '1.0rem': t('normal'), '1.1rem': t('large'), }, }, [PrefKey.STATS_TRANSPARENT]: { - 'default': false, + default: false, }, [PrefKey.STATS_OPACITY]: { - 'type': SettingElementType.NUMBER_STEPPER, - 'default': 80, - 'min': 50, - 'max': 100, - 'params': { + type: SettingElementType.NUMBER_STEPPER, + default: 80, + min: 50, + max: 100, + params: { suffix: '%', ticks: 10, }, }, [PrefKey.STATS_CONDITIONAL_FORMATTING]: { - 'default': false, + default: false, }, [PrefKey.REMOTE_PLAY_ENABLED]: { - 'default': false, + label: t('enable-remote-play-feature'), + default: false, }, [PrefKey.REMOTE_PLAY_RESOLUTION]: { - 'default': '1080p', - 'options': { + default: '1080p', + options: { '1080p': '1080p', '720p': '720p', }, }, [PrefKey.GAME_FORTNITE_FORCE_CONSOLE]: { - 'default': false, - 'note': t('fortnite-allow-stw-mode'), + label: '🎮 ' + t('fortnite-force-console-version'), + default: false, + note: t('fortnite-allow-stw-mode'), }, // Deprecated /* [Preferences.DEPRECATED_CONTROLLER_SUPPORT_LOCAL_CO_OP]: { - 'default': false, + default: false, 'migrate': function(savedPrefs, value) { this.set(Preferences.LOCAL_CO_OP_ENABLED, value); savedPrefs[Preferences.LOCAL_CO_OP_ENABLED] = value; @@ -652,9 +683,9 @@ export class Preferences { if ('options' in config && !(value in config.options!)) { value = config.default; - } else if ('multiple_options' in config) { + } else if ('multipleOptions' in config) { if (value.length) { - const validOptions = Object.keys(config.multiple_options!); + const validOptions = Object.keys(config.multipleOptions!); value.forEach((item: any, idx: number) => { (validOptions.indexOf(item) === -1) && value.splice(idx, 1); }); @@ -707,7 +738,7 @@ export class Preferences { type = setting.type; } else if ('options' in setting) { type = SettingElementType.OPTIONS; - } else if ('multiple_options' in setting) { + } else if ('multipleOptions' in setting) { type = SettingElementType.MULTIPLE_OPTIONS; } else if (typeof setting.default === 'number') { type = SettingElementType.NUMBER; @@ -737,6 +768,7 @@ export class Preferences { } -const PREFS = new Preferences(); -export const getPref = PREFS.get.bind(PREFS); -export const setPref = PREFS.set.bind(PREFS); +const prefs = new Preferences(); +export const getPref = prefs.get.bind(prefs); +export const setPref = prefs.set.bind(prefs); +export const toPrefElement = prefs.toElement.bind(prefs); diff --git a/src/modules/remote-play.ts b/src/modules/remote-play.ts new file mode 100644 index 0000000..bf82d3f --- /dev/null +++ b/src/modules/remote-play.ts @@ -0,0 +1,353 @@ +import { CE, createButton, ButtonStyle, Icon } from "../utils/html"; +import { Toast } from "../utils/toast"; +import { BxEvent } from "./bx-event"; +import { getPref, Preferences, PrefKey, setPref } from "./preferences"; +import { t } from "./translation"; +import { localRedirect } from "./ui/ui"; + +enum RemotePlayConsoleState { + ON = 'On', + OFF = 'Off', + STANDBY = 'ConnectedStandby', + UNKNOWN = 'Unknown', +} + +type RemotePlayRegion = { + name: string; + baseUri: string; + isDefault: boolean; +}; + +type RemotePlayConsole = { + deviceName: string; + serverId: string; + powerState: RemotePlayConsoleState; + consoleType: string; + // playPath: string; + // outOfHomeWarning: string; + // wirelessWarning: string; + // isDevKit: string; +}; + +export class RemotePlay { + static XCLOUD_TOKEN: string; + static XHOME_TOKEN: string; + static #CONSOLES: Array; + static #REGIONS: Array; + + static readonly #STATE_LABELS: {[key in RemotePlayConsoleState]: string} = { + [RemotePlayConsoleState.ON]: t('powered-on'), + [RemotePlayConsoleState.OFF]: t('powered-off'), + [RemotePlayConsoleState.STANDBY]: t('standby'), + [RemotePlayConsoleState.UNKNOWN]: t('unknown'), + }; + + static readonly BASE_DEVICE_INFO = { + appInfo: { + env: { + clientAppId: window.location.host, + clientAppType: 'browser', + clientAppVersion: '21.1.98', + clientSdkVersion: '8.5.3', + httpEnvironment: 'prod', + sdkInstallId: '', + }, + }, + dev: { + displayInfo: { + dimensions: { + widthInPixels: 1920, + heightInPixels: 1080, + }, + pixelDensity: { + dpiX: 1, + dpiY: 1, + }, + }, + hw: { + make: 'Microsoft', + model: 'unknown', + sdktype: 'web', + }, + os: { + name: 'windows', + ver: '22631.2715', + platform: 'desktop', + }, + browser: { + browserName: 'chrome', + browserVersion: '119.0', + }, + }, + }; + + static #$content: HTMLElement; + static #$consoles: HTMLElement; + + static #initialize() { + if (RemotePlay.#$content) { + return; + } + + RemotePlay.#$content = CE('div', {}, t('getting-consoles-list')); + RemotePlay.#getXhomeToken(() => { + RemotePlay.#getConsolesList(() => { + console.log(RemotePlay.#CONSOLES); + RemotePlay.#renderConsoles(); + BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY); + }); + }); + } + + static #renderConsoles() { + const $fragment = CE('div', {'class': 'bx-remote-play-container'}); + + if (!RemotePlay.#CONSOLES || RemotePlay.#CONSOLES.length === 0) { + $fragment.appendChild(CE('span', {}, t('no-consoles-found'))); + RemotePlay.#$content = CE('div', {}, $fragment); + return; + } + + const $settingNote = CE('p', {}); + + const resolutions = [1080, 720]; + const currentResolution = getPref(PrefKey.REMOTE_PLAY_RESOLUTION); + const $resolutionGroup = CE('div', {}); + for (const resolution of resolutions) { + const value = `${resolution}p`; + const id = `bx_radio_xhome_resolution_${resolution}`; + + const $radio = CE('input', { + 'type': 'radio', + 'value': value, + 'id': id, + 'name': 'bx_radio_xhome_resolution', + }, value); + + $radio.addEventListener('change', e => { + const value = (e.target as HTMLInputElement).value; + + $settingNote.textContent = value === '1080p' ? '✅ ' + t('can-stream-xbox-360-games') : '❌ ' + t('cant-stream-xbox-360-games'); + setPref(PrefKey.REMOTE_PLAY_RESOLUTION, value); + }); + + const $label = CE('label', { + 'for': id, + 'class': 'bx-remote-play-resolution', + }, $radio, `${resolution}p`); + + $resolutionGroup.appendChild($label); + + if (currentResolution === value) { + $radio.checked = true; + $radio.dispatchEvent(new Event('change')); + } + } + + const $qualitySettings = CE('div', {'class': 'bx-remote-play-settings'}, + CE('div', {}, + CE('label', {}, t('target-resolution'), $settingNote), + $resolutionGroup, + ) + ); + + $fragment.appendChild($qualitySettings); + + // Render concoles list + for (let con of RemotePlay.#CONSOLES) { + const $child = CE('div', {'class': 'bx-remote-play-device-wrapper'}, + CE('div', {'class': 'bx-remote-play-device-info'}, + CE('div', {}, + CE('span', {'class': 'bx-remote-play-device-name'}, con.deviceName), + CE('span', {'class': 'bx-remote-play-console-type'}, con.consoleType.replace('Xbox', '')) + ), + CE('div', {'class': 'bx-remote-play-power-state'}, RemotePlay.#STATE_LABELS[con.powerState]), + ), + + // Connect button + createButton({ + classes: ['bx-remote-play-connect-button'], + label: t('console-connect'), + style: ButtonStyle.PRIMARY | ButtonStyle.FOCUSABLE, + onClick: e => { + RemotePlay.play(con.serverId); + }, + }), + ); + + $fragment.appendChild($child); + } + + // Add Help button + $fragment.appendChild(createButton({ + icon: Icon.QUESTION, + style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE, + url: 'https://better-xcloud.github.io/remote-play', + label: t('help'), + })); + + RemotePlay.#$content = CE('div', {}, $fragment); + } + + static #getXhomeToken(callback: any) { + if (RemotePlay.XHOME_TOKEN) { + callback(); + return; + } + + let GSSV_TOKEN; + try { + GSSV_TOKEN = JSON.parse(localStorage.getItem('xboxcom_xbl_user_info')!).tokens['http://gssv.xboxlive.com/'].token; + } catch (e) { + for (let i = 0; i < localStorage.length; i++){ + const key = localStorage.key(i)!; + if (!key.startsWith('Auth.User.')) { + continue; + } + + const json = JSON.parse(localStorage.getItem(key)!); + for (const token of json.tokens) { + if (!token.relyingParty.includes('gssv.xboxlive.com')) { + continue; + } + + GSSV_TOKEN = token.tokenData.token; + break; + } + + break; + } + } + + fetch('https://xhome.gssv-play-prod.xboxlive.com/v2/login/user', { + method: 'POST', + body: JSON.stringify({ + offeringId: 'xhome', + token: GSSV_TOKEN, + }), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }).then(resp => resp.json()) + .then(json => { + RemotePlay.#REGIONS = json.offeringSettings.regions; + RemotePlay.XHOME_TOKEN = json.gsToken; + callback(); + }); + } + + static async #getConsolesList(callback: any) { + if (RemotePlay.#CONSOLES) { + callback(); + return; + } + + const options = { + method: 'GET', + headers: { + 'Authorization': `Bearer ${RemotePlay.XHOME_TOKEN}`, + }, + }; + + // Test servers one by one + for (const region of RemotePlay.#REGIONS) { + try { + const url = `${region.baseUri}/v6/servers/home?mr=50`; + const resp = await fetch(url, options); + + const json = await resp.json(); + RemotePlay.#CONSOLES = json.results; + + // Store working server + States.remotePlay.server = region.baseUri; + + callback(); + } catch (e) {} + + if (RemotePlay.#CONSOLES) { + break; + } + } + + // None of the servers worked + if (!States.remotePlay.server) { + RemotePlay.#CONSOLES = []; + } + } + + static play(serverId: string, resolution?: string) { + if (resolution) { + setPref(PrefKey.REMOTE_PLAY_RESOLUTION, resolution); + } + + States.remotePlay.config = { + serverId: serverId, + }; + window.BX_REMOTE_PLAY_CONFIG = States.remotePlay.config; + + localRedirect('/launch/fortnite/BT5P2X999VH2#remote-play'); + RemotePlay.detachPopup(); + } + + static preload() { + RemotePlay.#initialize(); + } + + static detachPopup() { + // Detach popup from body + const $popup = document.querySelector('.bx-remote-play-popup'); + $popup && $popup.remove(); + } + + static togglePopup(force = null) { + if (!getPref(PrefKey.REMOTE_PLAY_ENABLED) || !RemotePlay.isReady()) { + Toast.show(t('getting-consoles-list')); + return; + } + + RemotePlay.#initialize(); + + if (AppInterface && AppInterface.showRemotePlayDialog) { + AppInterface.showRemotePlayDialog(JSON.stringify(RemotePlay.#CONSOLES)); + (document.activeElement as HTMLElement).blur(); + return; + } + + if (document.querySelector('.bx-remote-play-popup')) { + if (force === false) { + RemotePlay.#$content.classList.add('bx-gone'); + } else { + RemotePlay.#$content.classList.toggle('bx-gone'); + } + return; + } + + const $header = document.querySelector('#gamepass-root header')!; + + const group = $header.firstElementChild!.getAttribute('data-group')!; + RemotePlay.#$content.setAttribute('data-group', group); + RemotePlay.#$content.classList.add('bx-remote-play-popup'); + RemotePlay.#$content.classList.remove('bx-gone'); + + $header.insertAdjacentElement('afterend', RemotePlay.#$content); + } + + static detect() { + if (!getPref(PrefKey.REMOTE_PLAY_ENABLED)) { + return; + } + + States.remotePlay.isPlaying = window.location.pathname.includes('/launch/') && window.location.hash.startsWith('#remote-play'); + if (States.remotePlay?.isPlaying) { + window.BX_REMOTE_PLAY_CONFIG = States.remotePlay.config; + // Remove /launch/... from URL + window.history.replaceState({origin: 'better-xcloud'}, '', 'https://www.xbox.com/' + location.pathname.substring(1, 6) + '/play'); + } else { + window.BX_REMOTE_PLAY_CONFIG = null; + } + } + + static isReady() { + return RemotePlay.#CONSOLES !== null && RemotePlay.#CONSOLES.length > 0; + } +} diff --git a/src/modules/screenshot.ts b/src/modules/screenshot.ts new file mode 100644 index 0000000..51f762e --- /dev/null +++ b/src/modules/screenshot.ts @@ -0,0 +1,96 @@ +import { CE } from "../utils/html"; + +export function takeScreenshot(callback: any) { + const currentStream = States.currentStream!; + const $video = currentStream.$video; + const $canvas = currentStream.$screenshotCanvas; + if (!$video || !$canvas) { + return; + } + + const $canvasContext = $canvas.getContext('2d')!; + + $canvasContext.drawImage($video, 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.titleId, 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.titleId}-${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'); +} + + +export function setupScreenshotButton() { + const currentStream = States.currentStream! + currentStream.$screenshotCanvas = CE('canvas', {'class': 'bx-screenshot-canvas'}); + document.documentElement.appendChild(currentStream.$screenshotCanvas!); + + const delay = 2000; + const $btn = CE('div', {'class': 'bx-screenshot-button', 'data-showing': false}); + + let timeout: number | null; + const detectDbClick = (e: MouseEvent) => { + if (!currentStream.$video) { + timeout = null; + $btn.style.display = 'none'; + return; + } + + if (timeout) { + clearTimeout(timeout); + timeout = null; + $btn.setAttribute('data-capturing', 'true'); + + takeScreenshot(() => { + // Hide button + $btn.setAttribute('data-showing', 'false'); + setTimeout(() => { + if (!timeout) { + $btn.setAttribute('data-capturing', 'false'); + } + }, 100); + }); + + return; + } + + const isShowing = $btn.getAttribute('data-showing') === 'true'; + if (!isShowing) { + // Show button + $btn.setAttribute('data-showing', 'true'); + $btn.setAttribute('data-capturing', 'false'); + + timeout && clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + $btn.setAttribute('data-showing', 'false'); + $btn.setAttribute('data-capturing', 'false'); + }, delay); + } + } + + $btn.addEventListener('mousedown', detectDbClick); + document.documentElement.appendChild($btn); +} diff --git a/src/modules/stream-badges.ts b/src/modules/stream/stream-badges.ts similarity index 97% rename from src/modules/stream-badges.ts rename to src/modules/stream/stream-badges.ts index 7a46fc0..9c503c6 100644 --- a/src/modules/stream-badges.ts +++ b/src/modules/stream/stream-badges.ts @@ -1,6 +1,6 @@ -import { t } from "./translation"; -import { BxEvent } from "./bx-event"; -import { CE } from "../utils/html"; +import { t } from "../translation"; +import { BxEvent } from "../bx-event"; +import { CE } from "../../utils/html"; enum StreamBadge { PLAYTIME = 'playtime', @@ -29,7 +29,7 @@ export class StreamBadges { static #cachedDoms: {[index: string]: HTMLElement} = {}; static #interval?: number | null; - static get #REFRESH_INTERVAL() { return 3000; }; + static readonly #REFRESH_INTERVAL = 3000; static #renderBadge(name: StreamBadge, value: string, color: string) { if (name === StreamBadge.BREAK) { diff --git a/src/modules/stream-stats.ts b/src/modules/stream/stream-stats.ts similarity index 92% rename from src/modules/stream-stats.ts rename to src/modules/stream/stream-stats.ts index 37aed4d..ca636c0 100644 --- a/src/modules/stream-stats.ts +++ b/src/modules/stream/stream-stats.ts @@ -1,9 +1,9 @@ -import { Preferences } from "./preferences" -import { BxEvent } from "./bx-event" -import { getPref } from "./preferences" +import { PrefKey, Preferences } from "../preferences" +import { BxEvent } from "../bx-event" +import { getPref } from "../preferences" import { StreamBadges } from "./stream-badges" -import { CE } from "../utils/html" -import { t } from "./translation" +import { CE } from "../../utils/html" +import { t } from "../translation" export enum StreamStat { PING = 'ping', @@ -110,7 +110,7 @@ export class StreamStats { return; } - const PREF_STATS_CONDITIONAL_FORMATTING = getPref(Preferences.STATS_CONDITIONAL_FORMATTING); + const PREF_STATS_CONDITIONAL_FORMATTING = getPref(PrefKey.STATS_CONDITIONAL_FORMATTING); STREAM_WEBRTC.getStats().then(stats => { stats.forEach(stat => { let grade = ''; @@ -165,11 +165,11 @@ export class StreamStats { } static refreshStyles() { - const PREF_ITEMS = getPref(Preferences.STATS_ITEMS); - const PREF_POSITION = getPref(Preferences.STATS_POSITION); - const PREF_TRANSPARENT = getPref(Preferences.STATS_TRANSPARENT); - const PREF_OPACITY = getPref(Preferences.STATS_OPACITY); - const PREF_TEXT_SIZE = getPref(Preferences.STATS_TEXT_SIZE); + const PREF_ITEMS = getPref(PrefKey.STATS_ITEMS); + const PREF_POSITION = getPref(PrefKey.STATS_POSITION); + const PREF_TRANSPARENT = getPref(PrefKey.STATS_TRANSPARENT); + const PREF_OPACITY = getPref(PrefKey.STATS_OPACITY); + const PREF_TEXT_SIZE = getPref(PrefKey.STATS_TEXT_SIZE); const $container = StreamStats.#$container; $container.setAttribute('data-stats', '[' + PREF_ITEMS.join('][') + ']'); @@ -180,7 +180,7 @@ export class StreamStats { } static hideSettingsUi() { - if (StreamStats.isGlancing() && !getPref(Preferences.STATS_QUICK_GLANCE)) { + if (StreamStats.isGlancing() && !getPref(PrefKey.STATS_QUICK_GLANCE)) { StreamStats.stop(); } } @@ -277,7 +277,7 @@ export class StreamStats { StreamBadges.ipv6 = allCandidates[candidateId].includes(':'); } - if (getPref(Preferences.STATS_SHOW_WHEN_PLAYING)) { + if (getPref(PrefKey.STATS_SHOW_WHEN_PLAYING)) { StreamStats.start(); } }); @@ -285,8 +285,8 @@ export class StreamStats { static setupEvents() { window.addEventListener(BxEvent.STREAM_PLAYING, e => { - const PREF_STATS_QUICK_GLANCE = getPref(Preferences.STATS_QUICK_GLANCE); - const PREF_STATS_SHOW_WHEN_PLAYING = getPref(Preferences.STATS_SHOW_WHEN_PLAYING); + const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE); + const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING); StreamStats.getServerStats(); // Setup Stat's Quick Glance mode diff --git a/src/modules/stream/stream-ui.ts b/src/modules/stream/stream-ui.ts new file mode 100644 index 0000000..95f3e09 --- /dev/null +++ b/src/modules/stream/stream-ui.ts @@ -0,0 +1,344 @@ +import { Icon } from "../../utils/html"; +import { BxEvent } from "../bx-event"; +import { PrefKey, getPref } from "../preferences"; +import { t } from "../translation"; +import { StreamBadges } from "./stream-badges"; +import { StreamStats } from "./stream-stats"; + + +class MouseHoldEvent { + #isHolding = false; + #timeout?: number | null; + + #$elm; + #callback; + #duration; + + #onMouseDown(e: MouseEvent | TouchEvent) { + const _this = this; + this.#isHolding = false; + + this.#timeout && clearTimeout(this.#timeout); + this.#timeout = setTimeout(() => { + _this.#isHolding = true; + _this.#callback(); + }, this.#duration); + }; + + #onMouseUp(e: MouseEvent | TouchEvent) { + this.#timeout && clearTimeout(this.#timeout); + this.#timeout = null; + + if (this.#isHolding) { + e.preventDefault(); + e.stopPropagation(); + } + this.#isHolding = false; + }; + + #addEventListeners = () => { + this.#$elm.addEventListener('mousedown', this.#onMouseDown.bind(this)); + this.#$elm.addEventListener('click', this.#onMouseUp.bind(this)); + + this.#$elm.addEventListener('touchstart', this.#onMouseDown.bind(this)); + this.#$elm.addEventListener('touchend', this.#onMouseUp.bind(this)); + } + + #clearEventLiseners = () => { + this.#$elm.removeEventListener('mousedown', this.#onMouseDown); + this.#$elm.removeEventListener('click', this.#onMouseUp); + + this.#$elm.removeEventListener('touchstart', this.#onMouseDown); + this.#$elm.removeEventListener('touchend', this.#onMouseUp); + } + + constructor($elm: HTMLElement, callback: any, duration=1000) { + this.#$elm = $elm; + this.#callback = callback; + this.#duration = duration; + + this.#addEventListeners(); + // $elm.clearMouseHoldEventListeners = this.#clearEventLiseners; + } +} + + +function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: Icon) { + const $container = $orgButton.cloneNode(true) as HTMLElement; + let timeout: number | null; + + const onTransitionStart = (e: TransitionEvent) => { + if ( e.propertyName !== 'opacity') { + return; + } + + timeout && clearTimeout(timeout); + $container.style.pointerEvents = 'none'; + }; + + const onTransitionEnd = (e: TransitionEvent) => { + if ( e.propertyName !== 'opacity') { + return; + } + + const left = document.getElementById('StreamHud')?.style.left; + if (left === '0px') { + timeout && clearTimeout(timeout); + timeout = setTimeout(() => { + $container.style.pointerEvents = 'auto'; + }, 100); + } + }; + + if (States.hasTouchSupport) { + $container.addEventListener('transitionstart', onTransitionStart); + $container.addEventListener('transitionend', onTransitionEnd); + } + + const $button = $container.querySelector('button')!; + $button.setAttribute('title', label); + + const $svg = $button.querySelector('svg')!; + $svg.innerHTML = svgIcon; + $svg.style.fill = 'none'; + + const attrs = { + 'fill': 'none', + 'stroke': '#fff', + 'fill-rule': 'evenodd', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': '2', + 'viewBox': '0 0 32 32' + }; + + let attr: keyof typeof attrs; + for (attr in attrs) { + $svg.setAttribute(attr, attrs[attr]); + } + + return $container; +} + + +export function injectStreamMenuButtons() { + const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); + if (!$screen) { + return; + } + + if (($screen as any).xObserving) { + return; + } + + ($screen as any).xObserving = true; + + const $quickBar = document.querySelector('.bx-quick-settings-bar')!; + const $parent = $screen.parentElement; + const hideQuickBarFunc = (e?: MouseEvent | TouchEvent) => { + if (e) { + const $target = e.target as HTMLElement; + e.stopPropagation(); + if ($target != $parent && $target.id !== 'MultiTouchSurface' && !$target.querySelector('#BabylonCanvasContainer-main')) { + return; + } + if ($target.id === 'MultiTouchSurface') { + $target.removeEventListener('touchstart', hideQuickBarFunc); + } + } + + // Hide Quick settings bar + $quickBar.classList.add('bx-gone'); + + $parent?.removeEventListener('click', hideQuickBarFunc); + // $parent.removeEventListener('touchstart', hideQuickBarFunc); + } + + let $btnStreamSettings: HTMLElement; + let $btnStreamStats: HTMLElement; + + const PREF_DISABLE_FEEDBACK_DIALOG = getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG); + const observer = new MutationObserver(mutationList => { + mutationList.forEach(item => { + if (item.type !== 'childList') { + return; + } + + item.removedNodes.forEach($node => { + if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + if (!($node as HTMLElement).className || !($node as HTMLElement).className.startsWith) { + return; + } + + if (($node as HTMLElement).className.startsWith('StreamMenu')) { + if (!document.querySelector('div[class^=PureInStreamConfirmationModal]')) { + BxEvent.dispatch(window, BxEvent.STREAM_MENU_HIDDEN); + } + } + }); + + item.addedNodes.forEach(async $node => { + if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + let $elm: HTMLElement | null = $node as HTMLElement; + + // Error Page: .PureErrorPage.ErrorScreen + if ($elm.className.includes('PureErrorPage')) { + BxEvent.dispatch(window, BxEvent.STREAM_ERROR_PAGE); + return; + } + + if (PREF_DISABLE_FEEDBACK_DIALOG && $elm.className.startsWith('PostStreamFeedbackScreen')) { + const $btnClose = $elm.querySelector('button'); + $btnClose && $btnClose.click(); + return; + } + + // Render badges + if ($elm.className.startsWith('StreamMenu')) { + BxEvent.dispatch(window, BxEvent.STREAM_MENU_SHOWN); + + // Hide Quick bar when closing HUD + const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]'); + if (!$btnCloseHud) { + return; + } + + $btnCloseHud && $btnCloseHud.addEventListener('click', e => { + $quickBar.classList.add('bx-gone'); + }); + + // Get "Quit game" button + const $btnQuit = $elm.querySelector('div[class^=StreamMenu] > div > button:last-child') as HTMLElement; + // Hold "Quit game" button to refresh the stream + new MouseHoldEvent($btnQuit, () => { + confirm(t('confirm-reload-stream')) && window.location.reload(); + }, 1000); + + // Render stream badges + const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); + $menu?.appendChild(await StreamBadges.render()); + + hideQuickBarFunc(); + return; + } + + if ($elm.className.startsWith('Overlay-module_') || $elm.className.startsWith('InProgressScreen')) { + $elm = $elm.querySelector('#StreamHud'); + } + + if (!$elm || ($elm.id || '') !== 'StreamHud') { + return; + } + + // Grip handle + const $gripHandle = $elm.querySelector('button[class^=GripHandle]') as HTMLElement; + + const hideGripHandle = () => { + if (!$gripHandle) { + return; + } + + $gripHandle.dispatchEvent(new PointerEvent('pointerdown')); + $gripHandle.click(); + $gripHandle.dispatchEvent(new PointerEvent('pointerdown')); + $gripHandle.click(); + } + + // Get the second last button + const $orgButton = $elm.querySelector('div[class^=HUDButton]') as HTMLElement; + if (!$orgButton) { + return; + } + + // Create Stream Settings button + if (!$btnStreamSettings) { + $btnStreamSettings = cloneStreamHudButton($orgButton, t('menu-stream-settings'), Icon.STREAM_SETTINGS); + $btnStreamSettings.addEventListener('click', e => { + hideGripHandle(); + e.preventDefault(); + + // Show Quick settings bar + $quickBar.classList.remove('bx-gone'); + + $parent?.addEventListener('click', hideQuickBarFunc); + //$parent.addEventListener('touchstart', hideQuickBarFunc); + + const $touchSurface = document.getElementById('MultiTouchSurface'); + $touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideQuickBarFunc); + }); + } + + // Create Stream Stats button + if (!$btnStreamStats) { + $btnStreamStats = cloneStreamHudButton($orgButton, t('menu-stream-stats'), Icon.STREAM_STATS); + $btnStreamStats.addEventListener('click', e => { + hideGripHandle(); + e.preventDefault(); + + // Toggle Stream Stats + StreamStats.toggle(); + + const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); + $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); + }); + } + + const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); + $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); + + if ($orgButton) { + const $btnParent = $orgButton.parentElement!; + + // Insert buttons after Stream Settings button + $btnParent.insertBefore($btnStreamStats, $btnParent.lastElementChild); + $btnParent.insertBefore($btnStreamSettings, $btnStreamStats); + + // Move the Dots button to the beginning + const $dotsButton = $btnParent.lastElementChild!; + $dotsButton.parentElement!.insertBefore($dotsButton, $dotsButton.parentElement!.firstElementChild); + } + }); + }); + }); + observer.observe($screen, {subtree: true, childList: true}); +} + + +export function showStreamSettings(tabId: string) { + const $wrapper = document.querySelector('.bx-quick-settings-bar'); + if (!$wrapper) { + return; + } + + // Select tab + if (tabId) { + const $tab = $wrapper.querySelector(`.bx-quick-settings-tabs svg[data-group=${tabId}]`); + $tab && $tab.dispatchEvent(new Event('click')); + } + + $wrapper.classList.remove('bx-gone'); + + const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); + if ($screen && $screen.parentElement) { + const $parent = $screen.parentElement; + if (!$parent || ($parent as any).bxClick) { + return; + } + + ($parent as any).bxClick = true; + + const onClick = (e: Event) => { + $wrapper.classList.add('bx-gone'); + ($parent as any).bxClick = false; + $parent.removeEventListener('click', onClick); + }; + + $parent.addEventListener('click', onClick); + } +} diff --git a/src/modules/touch-controller.ts b/src/modules/touch-controller.ts new file mode 100644 index 0000000..f9fc4c4 --- /dev/null +++ b/src/modules/touch-controller.ts @@ -0,0 +1,300 @@ +import { CE } from "../utils/html"; +import { Toast } from "../utils/toast"; +import { BxEvent } from "./bx-event"; +import { BX_FLAGS } from "./bx-flags"; +import { getPref, Preferences, PrefKey } from "./preferences"; +import { t } from "./translation"; + +export class TouchController { + static readonly #EVENT_SHOW_DEFAULT_CONTROLLER = new MessageEvent('message', { + data: '{"content":"{\\"layoutId\\":\\"\\"}","target":"/streaming/touchcontrols/showlayoutv2","type":"Message"}', + origin: 'better-xcloud', + }); + + static readonly #EVENT_HIDE_CONTROLLER = new MessageEvent('message', { + data: '{"content":"","target":"/streaming/touchcontrols/hide","type":"Message"}', + origin: 'better-xcloud', + }); + + static #$bar: HTMLElement; + static #$style: HTMLStyleElement; + + static #enable = false; + static #showing = false; + static #dataChannel: RTCDataChannel | null; + + static #customLayouts: {[index: string]: any} = {}; + static #baseCustomLayouts: {[index: string]: any} = {}; + static #currentLayoutId: string; + + static enable() { + TouchController.#enable = true; + } + + static disable() { + TouchController.#enable = false; + } + + static isEnabled() { + return TouchController.#enable; + } + + static #showDefault() { + TouchController.#dispatchMessage(TouchController.#EVENT_SHOW_DEFAULT_CONTROLLER); + TouchController.#showing = true; + } + + static #show() { + document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.remove('bx-offscreen'); + TouchController.#showing = true; + } + + static #hide() { + document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.add('bx-offscreen'); + TouchController.#showing = false; + } + + static #toggleVisibility() { + if (!TouchController.#dataChannel) { + return; + } + + TouchController.#showing ? TouchController.#hide() : TouchController.#show(); + } + + static #toggleBar(value: boolean) { + TouchController.#$bar && TouchController.#$bar.setAttribute('data-showing', value.toString()); + } + + static reset() { + TouchController.#enable = false; + TouchController.#showing = false; + TouchController.#dataChannel = null; + + TouchController.#$bar && TouchController.#$bar.removeAttribute('data-showing'); + TouchController.#$style && (TouchController.#$style.textContent = ''); + } + + static #dispatchMessage(msg: any) { + TouchController.#dataChannel && setTimeout(() => { + TouchController.#dataChannel!.dispatchEvent(msg); + }, 10); + } + + static #dispatchLayouts(data: any) { + BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, { + data: data, + }); + }; + + static async getCustomLayouts(xboxTitleId: string, retries: number=1) { + if (xboxTitleId in TouchController.#customLayouts) { + TouchController.#dispatchLayouts(TouchController.#customLayouts[xboxTitleId]); + return; + } + + retries = retries || 1; + if (retries > 2) { + TouchController.#customLayouts[xboxTitleId] = null; + // Wait for BX_EXPOSED.touch_layout_manager + setTimeout(() => TouchController.#dispatchLayouts(null), 1000); + return; + } + + const baseUrl = `https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts${BX_FLAGS.UseDevTouchLayout ? '/dev' : ''}`; + const url = `${baseUrl}/${xboxTitleId}.json`; + + // Get layout info + try { + const resp = await NATIVE_FETCH(url); + const json = await resp.json(); + + const layouts = {}; + + json.layouts.forEach(async (layoutName: string) => { + let baseLayouts = {}; + if (layoutName in TouchController.#baseCustomLayouts) { + baseLayouts = TouchController.#baseCustomLayouts[layoutName]; + } else { + try { + const layoutUrl = `${baseUrl}/layouts/${layoutName}.json`; + const resp = await NATIVE_FETCH(layoutUrl); + const json = await resp.json(); + + baseLayouts = json.layouts; + TouchController.#baseCustomLayouts[layoutName] = baseLayouts; + } catch (e) {} + } + + Object.assign(layouts, baseLayouts); + }); + + json.layouts = layouts; + TouchController.#customLayouts[xboxTitleId] = json; + + // Wait for BX_EXPOSED.touch_layout_manager + setTimeout(() => TouchController.#dispatchLayouts(json), 1000); + } catch (e) { + // Retry + TouchController.getCustomLayouts(xboxTitleId, retries + 1); + } + } + + static loadCustomLayout(xboxTitleId: string, layoutId: string, delay: number=0) { + if (!window.BX_EXPOSED.touch_layout_manager) { + return; + } + + const layoutChanged = TouchController.#currentLayoutId !== layoutId; + TouchController.#currentLayoutId = layoutId; + + // Get layout data + const layoutData = TouchController.#customLayouts[xboxTitleId]; + if (!xboxTitleId || !layoutId || !layoutData) { + TouchController.#enable && TouchController.#showDefault(); + return; + } + + const layout = (layoutData.layouts[layoutId] || layoutData.layouts[layoutData.default_layout]); + if (!layout) { + return; + } + + // Show a toast with layout's name + layoutChanged && Toast.show(t('touch-control-layout'), layout.name); + + setTimeout(() => { + window.BX_EXPOSED.touch_layout_manager.changeLayoutForScope({ + type: 'showLayout', + scope: xboxTitleId, + subscope: 'base', + layout: { + id: 'System.Standard', + displayName: 'System', + layoutFile: { + content: layout.content, + }, + } + }); + }, delay); + } + + static setup() { + // Function for testing touch control + window.BX_EXPOSED.test_touch_control = (content: any) => { + const { touch_layout_manager } = window.BX_EXPOSED; + + touch_layout_manager && touch_layout_manager.changeLayoutForScope({ + type: 'showLayout', + scope: '' + States.currentStream?.xboxTitleId, + subscope: 'base', + layout: { + id: 'System.Standard', + displayName: 'Custom', + layoutFile: { + content: content, + }, + }, + }); + }; + + const $fragment = document.createDocumentFragment(); + const $style = document.createElement('style'); + $fragment.appendChild($style); + + const $bar = CE('div', {'id': 'bx-touch-controller-bar'}); + $fragment.appendChild($bar); + + document.documentElement.appendChild($fragment); + + // Setup double-tap event + let clickTimeout: number | null; + $bar.addEventListener('mousedown', (e: MouseEvent) => { + clickTimeout && clearTimeout(clickTimeout); + if (clickTimeout) { + // Double-clicked + clickTimeout = null; + TouchController.#toggleVisibility(); + return; + } + + clickTimeout = setTimeout(() => { + clickTimeout = null; + }, 400); + }); + + TouchController.#$bar = $bar; + TouchController.#$style = $style; + + const PREF_STYLE_STANDARD = getPref(PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD); + const PREF_STYLE_CUSTOM = getPref(PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM); + + window.addEventListener(BxEvent.DATA_CHANNEL_CREATED, e => { + const dataChannel = (e as any).dataChannel; + if (!dataChannel || dataChannel.label !== 'message') { + return; + } + + // Apply touch controller's style + let filter = ''; + if (TouchController.#enable) { + if (PREF_STYLE_STANDARD === 'white') { + filter = 'grayscale(1) brightness(2)'; + } else if (PREF_STYLE_STANDARD === 'muted') { + filter = 'sepia(0.5)'; + } + } else if (PREF_STYLE_CUSTOM === 'muted') { + filter = 'sepia(0.5)'; + } + + if (filter) { + $style.textContent = `#babylon-canvas { filter: ${filter} !important; }`; + } else { + $style.textContent = ''; + } + + TouchController.#dataChannel = dataChannel; + + // Fix sometimes the touch controller doesn't show at the beginning + dataChannel.addEventListener('open', () => { + setTimeout(TouchController.#show, 1000); + }); + + let focused = false; + dataChannel.addEventListener('message', (msg: MessageEvent) => { + if (msg.origin === 'better-xcloud' || typeof msg.data !== 'string') { + return; + } + + // Dispatch a message to display generic touch controller + if (msg.data.includes('touchcontrols/showtitledefault')) { + if (TouchController.#enable) { + if (focused) { + TouchController.getCustomLayouts(States.currentStream?.xboxTitleId!); + } else { + TouchController.#showDefault(); + } + } + return; + } + + // Load custom touch layout + try { + if (msg.data.includes('/titleinfo')) { + const json = JSON.parse(JSON.parse(msg.data).content); + TouchController.#toggleBar(json.focused); + + focused = json.focused; + if (!json.focused) { + TouchController.#show(); + } + + States.currentStream.xboxTitleId = parseInt(json.titleid, 16).toString(); + } + } catch (e) { + console.log(e); + } + }); + }); + } +} diff --git a/src/modules/translation.ts b/src/modules/translation.ts index c2f1450..17e8f23 100644 --- a/src/modules/translation.ts +++ b/src/modules/translation.ts @@ -3222,5 +3222,5 @@ const Translations = { ], } -let LOCALE = Translations.getLocale(); export const t = Translations.get; +export const getLocale = Translations.getLocale; diff --git a/src/modules/ui/global-settings.ts b/src/modules/ui/global-settings.ts new file mode 100644 index 0000000..f202ae1 --- /dev/null +++ b/src/modules/ui/global-settings.ts @@ -0,0 +1,347 @@ +import { CE, createButton, Icon, ButtonStyle } from "../../utils/html"; +import { getPreferredServerRegion } from "../../utils/region"; +import { UserAgent, UserAgentProfile } from "../../utils/user-agent"; +import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "../preferences"; +import { getLocale, t } from "../translation"; + +const SETTINGS_UI = { + 'Better xCloud': { + items: [ + PrefKey.BETTER_XCLOUD_LOCALE, + PrefKey.REMOTE_PLAY_ENABLED, + ], + }, + + [t('server')]: { + items: [ + PrefKey.SERVER_REGION, + PrefKey.STREAM_PREFERRED_LOCALE, + PrefKey.PREFER_IPV6_SERVER, + ], + }, + + [t('stream')]: { + items: [ + PrefKey.STREAM_TARGET_RESOLUTION, + PrefKey.STREAM_CODEC_PROFILE, + PrefKey.GAME_FORTNITE_FORCE_CONSOLE, + PrefKey.AUDIO_MIC_ON_PLAYING, + PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG, + + PrefKey.SCREENSHOT_BUTTON_POSITION, + PrefKey.SCREENSHOT_APPLY_FILTERS, + + PrefKey.AUDIO_ENABLE_VOLUME_CONTROL, + PrefKey.STREAM_COMBINE_SOURCES, + ], + }, + + [t('local-co-op')]: { + items: [ + PrefKey.LOCAL_CO_OP_ENABLED, + ], + }, + + [t('mouse-and-keyboard')]: { + items: [ + PrefKey.MKB_ENABLED, + PrefKey.MKB_HIDE_IDLE_CURSOR, + ], + }, + + [t('touch-controller')]: { + note: !States.hasTouchSupport ? '⚠️ ' + t('device-unsupported-touch') : null, + unsupported: !States.hasTouchSupport, + items: [ + PrefKey.STREAM_TOUCH_CONTROLLER, + PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF, + PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD, + PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM, + ], + }, + + [t('loading-screen')]: { + items: [ + PrefKey.UI_LOADING_SCREEN_GAME_ART, + PrefKey.UI_LOADING_SCREEN_WAIT_TIME, + PrefKey.UI_LOADING_SCREEN_ROCKET, + ], + }, + + [t('ui')]: { + items: [ + PrefKey.UI_LAYOUT, + PrefKey.STREAM_SIMPLIFY_MENU, + PrefKey.SKIP_SPLASH_VIDEO, + !AppInterface && PrefKey.UI_SCROLLBAR_HIDE, + PrefKey.HIDE_DOTS_ICON, + PrefKey.REDUCE_ANIMATIONS, + ], + }, + + [t('other')]: { + items: [ + PrefKey.BLOCK_SOCIAL_FEATURES, + PrefKey.BLOCK_TRACKING, + ], + }, + + [t('advanced')]: { + items: [ + PrefKey.USER_AGENT_PROFILE, + ], + }, +}; + + +export function setupSettingsUi() { + // Avoid rendering the Settings multiple times + if (document.querySelector('.bx-settings-container')) { + return; + } + + const PREF_PREFERRED_REGION = getPreferredServerRegion(); + const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION); + + let $reloadBtnWrapper: HTMLButtonElement; + + // Setup Settings UI + const $container = CE('div', { + 'class': 'bx-settings-container bx-gone', + }); + + let $updateAvailable; + + const $wrapper = CE('div', {'class': 'bx-settings-wrapper'}, + CE('div', {'class': 'bx-settings-title-wrapper'}, + CE('a', { + 'class': 'bx-settings-title', + 'href': SCRIPT_HOME, + 'target': '_blank', + }, 'Better xCloud ' + SCRIPT_VERSION), + createButton({icon: Icon.QUESTION, label: t('help'), url: 'https://better-xcloud.github.io/features/'}), + ) + ); + $updateAvailable = CE('a', { + 'class': 'bx-settings-update bx-gone', + 'href': 'https://github.com/redphx/better-xcloud/releases', + 'target': '_blank', + }); + + $wrapper.appendChild($updateAvailable); + + // Show new version indicator + if (PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) { + $updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`; + $updateAvailable.classList.remove('bx-gone'); + } + + // Show link to Android app + if (!AppInterface) { + const userAgent = UserAgent.getDefault().toLowerCase(); + if (userAgent.includes('android')) { + const $btn = createButton({ + label: '🔥 ' + t('install-android'), + style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, + url: 'https://better-xcloud.github.io/android', + }); + + $wrapper.appendChild($btn); + } + } + + const onChange = (e: Event) => { + if (!$reloadBtnWrapper) { + return; + } + + $reloadBtnWrapper.classList.remove('bx-gone'); + + if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) { + // Update locale + LOCALE = getLocale(); + + const $btn = $reloadBtnWrapper.firstElementChild! as HTMLButtonElement; + $btn.textContent = t('settings-reloading'); + $btn.click(); + } + }; + + // Render settings + for (let groupLabel in SETTINGS_UI) { + const $group = CE('span', {'class': 'bx-settings-group-label'}, groupLabel); + + // Render note + if (SETTINGS_UI[groupLabel].note) { + const $note = CE('b', {}, SETTINGS_UI[groupLabel].note); + $group.appendChild($note); + } + + $wrapper.appendChild($group); + + // Don't render settings if this is an unsupported feature + if (SETTINGS_UI[groupLabel].unsupported) { + continue; + } + + const settingItems = SETTINGS_UI[groupLabel].items + for (let settingId in settingItems) { + // Don't render custom settings + if (!settingId) { + continue; + } + + const setting = Preferences.SETTINGS[settingId]; + if (!setting) { + continue; + } + + let settingLabel = setting.label; + let settingNote = setting.note || ''; + + // Add Experimental text + if (setting.experimental) { + settingLabel = '🧪 ' + settingLabel; + if (!settingNote) { + settingNote = t('experimental') + } else { + settingNote = `${t('experimental')}: ${settingNote}` + } + } + + let $control; + let $inpCustomUserAgent: HTMLInputElement; + let labelAttrs = {}; + + if (settingId === PrefKey.USER_AGENT_PROFILE) { + let defaultUserAgent = (window.navigator as any).orgUserAgent || window.navigator.userAgent; + $inpCustomUserAgent = CE('input', { + 'type': 'text', + 'placeholder': defaultUserAgent, + 'class': 'bx-settings-custom-user-agent', + }); + $inpCustomUserAgent.addEventListener('change', e => { + setPref(PrefKey.USER_AGENT_CUSTOM, (e.target as HTMLInputElement).value.trim()); + onChange(e); + }); + + $control = toPrefElement(PrefKey.USER_AGENT_PROFILE, (e: Event) => { + const value = (e.target as HTMLInputElement).value; + let isCustom = value === UserAgentProfile.CUSTOM; + let userAgent = UserAgent.get(value); + + $inpCustomUserAgent.value = userAgent; + $inpCustomUserAgent.readOnly = !isCustom; + $inpCustomUserAgent.disabled = !isCustom; + + onChange(e); + }); + } else if (settingId === PrefKey.SERVER_REGION) { + let selectedValue; + + $control = CE('select', {id: `bx_setting_${settingId}`}); + $control.name = $control.id; + + $control.addEventListener('change', e => { + setPref(settingId, (e.target as HTMLSelectElement).value); + onChange(e); + }); + + selectedValue = PREF_PREFERRED_REGION; + + setting.options = {}; + for (let regionName in States.serverRegions) { + const region = States.serverRegions[regionName]; + let value = regionName; + + let label = `${region.shortName} - ${regionName}`; + if (region.isDefault) { + label += ` (${t('default')})`; + value = 'default'; + + if (selectedValue === regionName) { + selectedValue = 'default'; + } + } + + setting.options[value] = label; + } + + for (let value in setting.options) { + const label = setting.options[value]; + + const $option = CE('option', {value: value}, label); + $control.appendChild($option); + } + + // Select preferred region + $control.value = selectedValue; + } else { + if (settingId === PrefKey.BETTER_XCLOUD_LOCALE) { + $control = toPrefElement(settingId, (e: Event) => { + localStorage.setItem('better_xcloud_locale', (e.target as HTMLSelectElement).value); + onChange(e); + }); + } else { + $control = toPrefElement(settingId, onChange); + } + labelAttrs = {'for': $control.id, 'tabindex': 0}; + } + + // Disable unsupported settings + if (setting.unsupported) { + ($control as HTMLInputElement).disabled = true; + } + + const $label = CE('label', labelAttrs, settingLabel); + if (settingNote) { + $label.appendChild(CE('b', {}, settingNote)); + } + const $elm = CE('div', {'class': 'bx-settings-row'}, + $label, + $control + ); + + $wrapper.appendChild($elm); + + // Add User-Agent input + if (settingId === PrefKey.USER_AGENT_PROFILE) { + $wrapper.appendChild($inpCustomUserAgent!); + // Trigger 'change' event + $control.dispatchEvent(new Event('change')); + } + } + } + + // Setup Reload button + const $reloadBtn = createButton({ + label: t('settings-reload'), + style: ButtonStyle.DANGER | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH, + onClick: e => { + window.location.reload(); + $reloadBtn.disabled = true; + $reloadBtn.textContent = t('settings-reloading'); + }, + }); + $reloadBtn.setAttribute('tabindex', '0'); + + $reloadBtnWrapper = CE('div', {'class': 'bx-settings-reload-button-wrapper bx-gone'}, $reloadBtn); + $wrapper.appendChild($reloadBtnWrapper); + + // Donation link + const $donationLink = CE('a', {'class': 'bx-donation-link', href: 'https://ko-fi.com/redphx', target: '_blank'}, `❤️ ${t('support-better-xcloud')}`); + $wrapper.appendChild($donationLink); + + // Show Game Pass app version + try { + const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content; + const appDate = new Date((document.querySelector('meta[name=gamepass-app-date]') as HTMLMetaElement).content).toISOString().substring(0, 10); + $wrapper.appendChild(CE('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`)); + } catch (e) {} + + $container.appendChild($wrapper); + + // Add Settings UI to the web page + const $pageContent = document.getElementById('PageContent'); + $pageContent?.parentNode?.insertBefore($container, $pageContent); +} diff --git a/src/modules/ui/header.ts b/src/modules/ui/header.ts new file mode 100644 index 0000000..057ffe9 --- /dev/null +++ b/src/modules/ui/header.ts @@ -0,0 +1,83 @@ +import { createButton, Icon, ButtonStyle } from "../../utils/html"; +import { getPreferredServerRegion } from "../../utils/region"; +import { PrefKey, getPref } from "../preferences"; +import { RemotePlay } from "../remote-play"; +import { t } from "../translation"; +import { setupSettingsUi } from "./global-settings"; + + +function injectSettingsButton($parent?: HTMLElement) { + if (!$parent) { + return; + } + + const PREF_PREFERRED_REGION = getPreferredServerRegion(true); + const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION); + + const $headerFragment = document.createDocumentFragment(); + + // Remote Play button + if (getPref(PrefKey.REMOTE_PLAY_ENABLED)) { + const $remotePlayBtn = createButton({ + classes: ['bx-header-remote-play-button'], + icon: Icon.REMOTE_PLAY, + title: t('remote-play'), + style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE, + onClick: e => { + RemotePlay.togglePopup(); + }, + }); + $headerFragment.appendChild($remotePlayBtn); + } + + // Setup Settings button + const $settingsBtn = createButton({ + classes: ['bx-header-settings-button'], + label: PREF_PREFERRED_REGION, + style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT, + onClick: e => { + setupSettingsUi(); + + const $settings = document.querySelector('.bx-settings-container')!; + $settings.classList.toggle('bx-gone'); + window.scrollTo(0, 0); + document.activeElement && (document.activeElement as HTMLElement).blur(); + }, + }); + + // Show new update status + if (PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) { + $settingsBtn.setAttribute('data-update-available', 'true'); + } + + // Add the Settings button to the web page + $headerFragment.appendChild($settingsBtn); + $parent.appendChild($headerFragment); +} + + +export function checkHeader() { + const $button = document.querySelector('.bx-header-settings-button'); + + if (!$button) { + const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]'); + injectSettingsButton($rightHeader as HTMLElement); + } +} + + +export function watchHeader() { + const $header = document.querySelector('#PageContent header'); + if (!$header) { + return; + } + + let timeout: number | null; + const observer = new MutationObserver(mutationList => { + timeout && clearTimeout(timeout); + timeout = setTimeout(checkHeader, 2000); + }); + observer.observe($header, {subtree: true, childList: true}); + + checkHeader(); +} diff --git a/src/modules/ui/ui.ts b/src/modules/ui/ui.ts new file mode 100644 index 0000000..6d8523f --- /dev/null +++ b/src/modules/ui/ui.ts @@ -0,0 +1,483 @@ +import { Icon, CE, createButton, ButtonStyle } from "../../utils/html"; +import { UserAgent } from "../../utils/user-agent"; +import { BxEvent } from "../bx-event"; +import { MkbRemapper } from "../mkb/mkb-remapper"; +import { getPref, Preferences, PrefKey, toPrefElement } from "../preferences"; +import { setupScreenshotButton } from "../screenshot"; +import { StreamStats } from "../stream/stream-stats"; +import { TouchController } from "../touch-controller"; +import { t } from "../translation"; +import { VibrationManager } from "../vibration-manager"; + + +export function localRedirect(path: string) { + const url = window.location.href.substring(0, 31) + path; + + const $pageContent = document.getElementById('PageContent'); + if (!$pageContent) { + return; + } + + const $anchor = CE('a', { + href: url, + class: 'bx-hidden bx-offscreen' + }, ''); + $anchor.addEventListener('click', e => { + // Remove element after clicking on it + setTimeout(() => { + $pageContent.removeChild($anchor); + }, 1000); + }); + + $pageContent.appendChild($anchor); + $anchor.click(); +} + + +function getVideoPlayerFilterStyle() { + const filters = []; + + const clarity = getPref(PrefKey.VIDEO_CLARITY); + if (clarity != 0) { + const level = (7 - (clarity - 1) * 0.5).toFixed(1); // 5, 5.5, 6, 6.5, 7 + const matrix = `0 -1 0 -1 ${level} -1 0 -1 0`; + document.getElementById('bx-filter-clarity-matrix')!.setAttributeNS(null, 'kernelMatrix', matrix); + + filters.push(`url(#bx-filter-clarity)`); + } + + const saturation = getPref(PrefKey.VIDEO_SATURATION); + if (saturation != 100) { + filters.push(`saturate(${saturation}%)`); + } + + const contrast = getPref(PrefKey.VIDEO_CONTRAST); + if (contrast != 100) { + filters.push(`contrast(${contrast}%)`); + } + + const brightness = getPref(PrefKey.VIDEO_BRIGHTNESS); + if (brightness != 100) { + filters.push(`brightness(${brightness}%)`); + } + + return filters.join(' '); +} + +function setupQuickSettingsBar() { + const isSafari = UserAgent.isSafari(); + + const SETTINGS_UI = [ + getPref(PrefKey.MKB_ENABLED) && { + icon: Icon.MOUSE, + group: 'mkb', + items: [ + { + group: 'mkb', + label: t('mouse-and-keyboard'), + help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/', + content: MkbRemapper.INSTANCE.render(), + }, + ], + }, + + { + icon: Icon.DISPLAY, + group: 'stream', + items: [ + { + group: 'audio', + label: t('audio'), + help_url: 'https://better-xcloud.github.io/ingame-features/#audio', + items: [ + { + pref: PrefKey.AUDIO_VOLUME, + label: t('volume'), + onChange: (e: any, value: number) => { + States.currentStream && (States.currentStream.audioGainNode!.gain.value = value / 100) + }, + params: { + disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL), + }, + }, + ], + }, + + { + group: 'video', + label: t('video'), + help_url: 'https://better-xcloud.github.io/ingame-features/#video', + items: [ + { + pref: PrefKey.VIDEO_RATIO, + label: t('ratio'), + onChange: updateVideoPlayerCss, + }, + + { + pref: PrefKey.VIDEO_CLARITY, + label: t('clarity'), + onChange: updateVideoPlayerCss, + unsupported: isSafari, + }, + + { + pref: PrefKey.VIDEO_SATURATION, + label: t('saturation'), + onChange: updateVideoPlayerCss, + }, + + { + pref: PrefKey.VIDEO_CONTRAST, + label: t('contrast'), + onChange: updateVideoPlayerCss, + }, + + { + pref: PrefKey.VIDEO_BRIGHTNESS, + label: t('brightness'), + onChange: updateVideoPlayerCss, + }, + ], + }, + ], + }, + + { + icon: Icon.CONTROLLER, + group: 'controller', + items: [ + { + group: 'controller', + label: t('controller'), + help_url: 'https://better-xcloud.github.io/ingame-features/#controller', + items: [ + { + pref: PrefKey.CONTROLLER_ENABLE_VIBRATION, + label: t('controller-vibration'), + unsupported: !VibrationManager.supportControllerVibration(), + onChange: VibrationManager.updateGlobalVars, + }, + + { + pref: PrefKey.CONTROLLER_DEVICE_VIBRATION, + label: t('device-vibration'), + unsupported: !VibrationManager.supportDeviceVibration(), + onChange: VibrationManager.updateGlobalVars, + }, + + (VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && { + pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY, + label: t('vibration-intensity'), + unsupported: !VibrationManager.supportDeviceVibration(), + onChange: VibrationManager.updateGlobalVars, + }, + ], + }, + + States.hasTouchSupport && { + group: 'touch-controller', + label: t('touch-controller'), + items: [ + { + label: t('layout'), + content: CE('select', {disabled: true}, CE('option', {}, t('default'))), + onMounted: ($elm: HTMLSelectElement) => { + $elm.addEventListener('change', e => { + TouchController.loadCustomLayout(States.currentStream?.xboxTitleId!, $elm.value, 1000); + }); + + window.addEventListener(BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, e => { + const data = (e as any).data; + + if (States.currentStream?.xboxTitleId && ($elm as any).xboxTitleId === States.currentStream?.xboxTitleId) { + $elm.dispatchEvent(new Event('change')); + return; + } + + ($elm as any).xboxTitleId = States.currentStream?.xboxTitleId; + + // Clear options + while ($elm.firstChild) { + $elm.removeChild($elm.firstChild); + } + + $elm.disabled = !data; + if (!data) { + $elm.appendChild(CE('option', {value: ''}, t('default'))); + $elm.value = ''; + $elm.dispatchEvent(new Event('change')); + return; + } + + // Add options + const $fragment = document.createDocumentFragment(); + for (const key in data.layouts) { + const layout = data.layouts[key]; + + const $option = CE('option', {value: key}, layout.name); + $fragment.appendChild($option); + } + + $elm.appendChild($fragment); + $elm.value = data.default_layout; + $elm.dispatchEvent(new Event('change')); + }); + }, + }, + ], + } + ], + }, + + { + icon: Icon.STREAM_STATS, + group: 'stats', + items: [ + { + group: 'stats', + label: t('menu-stream-stats'), + help_url: 'https://better-xcloud.github.io/stream-stats/', + items: [ + { + pref: PrefKey.STATS_SHOW_WHEN_PLAYING, + label: t('show-stats-on-startup'), + }, + { + pref: PrefKey.STATS_QUICK_GLANCE, + label: '👀 ' + t('enable-quick-glance-mode'), + onChange: (e: InputEvent) => { + (e.target! as HTMLInputElement).checked ? StreamStats.quickGlanceSetup() : StreamStats.quickGlanceStop(); + }, + }, + { + pref: PrefKey.STATS_ITEMS, + label: t('stats'), + onChange: StreamStats.refreshStyles, + }, + { + pref: PrefKey.STATS_POSITION, + label: t('position'), + onChange: StreamStats.refreshStyles, + }, + { + pref: PrefKey.STATS_TEXT_SIZE, + label: t('text-size'), + onChange: StreamStats.refreshStyles, + }, + { + pref: PrefKey.STATS_OPACITY, + label: t('opacity'), + onChange: StreamStats.refreshStyles, + }, + { + pref: PrefKey.STATS_TRANSPARENT, + label: t('transparent-background'), + onChange: StreamStats.refreshStyles, + }, + { + pref: PrefKey.STATS_CONDITIONAL_FORMATTING, + label: t('conditional-formatting'), + onChange: StreamStats.refreshStyles, + }, + ], + }, + ], + }, + ]; + + let $tabs: HTMLElement; + let $settings: HTMLElement; + + const $wrapper = CE('div', {'class': 'bx-quick-settings-bar bx-gone'}, + $tabs = CE('div', {'class': 'bx-quick-settings-tabs'}), + $settings = CE('div', {'class': 'bx-quick-settings-tab-contents'}), + ); + + for (const settingTab of SETTINGS_UI) { + if (!settingTab) { + continue; + } + + const $svg = CE('svg', { + 'xmlns': 'http://www.w3.org/2000/svg', + 'data-group': settingTab.group, + 'fill': 'none', + 'stroke': '#fff', + 'fill-rule': 'evenodd', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': 2, + }); + $svg.innerHTML = settingTab.icon; + $svg.setAttribute('viewBox', '0 0 32 32'); + $svg.addEventListener('click', e => { + // Switch tab + for (const $child of Array.from($settings.children)) { + if ($child.getAttribute('data-group') === settingTab.group) { + $child.classList.remove('bx-gone'); + } else { + $child.classList.add('bx-gone'); + } + } + + // Highlight current tab button + for (const $child of Array.from($tabs.children)) { + $child.classList.remove('bx-active'); + } + + $svg.classList.add('bx-active'); + }); + + $tabs.appendChild($svg); + + const $group = CE('div', {'data-group': settingTab.group, 'class': 'bx-gone'}); + + for (const settingGroup of settingTab.items) { + if (!settingGroup) { + continue; + } + + $group.appendChild(CE('h2', {}, + CE('span', {}, settingGroup.label), + settingGroup.help_url && createButton({ + icon: Icon.QUESTION, + style: ButtonStyle.GHOST, + url: settingGroup.help_url, + title: t('help'), + }), + )); + if (settingGroup.note) { + if (typeof settingGroup.note === 'string') { + settingGroup.note = document.createTextNode(settingGroup.note); + } + $group.appendChild(settingGroup.note); + } + + if (settingGroup.content) { + $group.appendChild(settingGroup.content); + continue; + } + + if (!settingGroup.items) { + settingGroup.items = []; + } + + for (const setting of settingGroup.items) { + if (!setting) { + continue; + } + + const pref = setting.pref; + + let $control; + if (setting.content) { + $control = setting.content; + } else if (!setting.unsupported) { + $control = toPrefElement(pref, setting.onChange, setting.params); + } + + const $content = CE('div', {'class': 'bx-quick-settings-row', 'data-type': settingGroup.group}, + CE('label', {for: `bx_setting_${pref}`}, + setting.label, + setting.unsupported && CE('div', {'class': 'bx-quick-settings-bar-note'}, t('browser-unsupported-feature')), + ), + !setting.unsupported && $control, + ); + + $group.appendChild($content); + + setting.onMounted && setting.onMounted($control); + } + } + + $settings.appendChild($group); + } + + // Select first tab + $tabs.firstElementChild!.dispatchEvent(new Event('click')); + + document.documentElement.appendChild($wrapper); +} + + +export function updateVideoPlayerCss() { + let $elm = document.getElementById('bx-video-css'); + if (!$elm) { + const $fragment = document.createDocumentFragment(); + $elm = CE('style', {id: 'bx-video-css'}); + $fragment.appendChild($elm); + + // Setup SVG filters + const $svg = CE('svg', { + 'id': 'bx-video-filters', + 'xmlns': 'http://www.w3.org/2000/svg', + 'class': 'bx-gone', + }, CE('defs', {'xmlns': 'http://www.w3.org/2000/svg'}, + CE('filter', {'id': 'bx-filter-clarity', 'xmlns': 'http://www.w3.org/2000/svg'}, + CE('feConvolveMatrix', {'id': 'bx-filter-clarity-matrix', 'order': '3', 'xmlns': 'http://www.w3.org/2000/svg'})) + ) + ); + $fragment.appendChild($svg); + document.documentElement.appendChild($fragment); + } + + let filters = getVideoPlayerFilterStyle(); + let videoCss = ''; + if (filters) { + videoCss += `filter: ${filters} !important;`; + } + + // Apply video filters to screenshots + if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) { + States.currentStream.$screenshotCanvas!.getContext('2d')!.filter = filters; + } + + const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO); + if (PREF_RATIO && PREF_RATIO !== '16:9') { + if (PREF_RATIO.includes(':')) { + videoCss += `aspect-ratio: ${PREF_RATIO.replace(':', '/')}; object-fit: unset !important;`; + + const tmp = PREF_RATIO.split(':'); + const ratio = parseFloat(tmp[0]) / parseFloat(tmp[1]); + const maxRatio = window.innerWidth / window.innerHeight; + if (ratio < maxRatio) { + videoCss += 'width: fit-content !important;' + } else { + videoCss += 'height: fit-content !important;' + } + } else { + videoCss += `object-fit: ${PREF_RATIO} !important;`; + } + } + + let css = ''; + if (videoCss) { + css = ` +div[data-testid="media-container"] { + display: flex; +} + +#game-stream video { + margin: 0 auto; + align-self: center; + background: #000; + ${videoCss} +} +`; + } + + $elm.textContent = css; +} + +export function setupBxUi() { + // Prevent initializing multiple times + if (!document.querySelector('.bx-quick-settings-bar')) { + window.addEventListener('resize', updateVideoPlayerCss); + setupQuickSettingsBar(); + setupScreenshotButton(); + StreamStats.render(); + } + + updateVideoPlayerCss(); +} diff --git a/src/modules/vibration-manager.ts b/src/modules/vibration-manager.ts new file mode 100644 index 0000000..88a8eff --- /dev/null +++ b/src/modules/vibration-manager.ts @@ -0,0 +1,150 @@ +import { BxEvent } from "./bx-event"; +import { PrefKey, getPref } from "./preferences"; + +const VIBRATION_DATA_MAP = { + 'gamepadIndex': 8, + 'leftMotorPercent': 8, + 'rightMotorPercent': 8, + 'leftTriggerMotorPercent': 8, + 'rightTriggerMotorPercent': 8, + 'durationMs': 16, + // 'delayMs': 16, + // 'repeat': 8, +}; + +type VibrationData = { + [key in keyof typeof VIBRATION_DATA_MAP]?: number; +} + +export class VibrationManager { + static #playDeviceVibration(data: Required) { + // console.log(+new Date, data); + + if (AppInterface) { + AppInterface.vibrate(JSON.stringify(data), window.BX_VIBRATION_INTENSITY); + return; + } + + const intensity = Math.min(100, data.leftMotorPercent + data.rightMotorPercent / 2) * window.BX_VIBRATION_INTENSITY; + if (intensity === 0 || intensity === 100) { + // Stop vibration + window.navigator.vibrate(intensity ? data.durationMs : 0); + return; + } + + const pulseDuration = 200; + const onDuration = Math.floor(pulseDuration * intensity / 100); + const offDuration = pulseDuration - onDuration; + + const repeats = Math.ceil(data.durationMs / pulseDuration); + + const pulses = Array(repeats).fill([onDuration, offDuration]).flat(); + // console.log(pulses); + + window.navigator.vibrate(pulses); + } + + static supportControllerVibration() { + return Gamepad.prototype.hasOwnProperty('vibrationActuator'); + } + + static supportDeviceVibration() { + return !!window.navigator.vibrate; + } + + static updateGlobalVars() { + window.BX_ENABLE_CONTROLLER_VIBRATION = VibrationManager.supportControllerVibration() ? getPref(PrefKey.CONTROLLER_ENABLE_VIBRATION) : false; + window.BX_VIBRATION_INTENSITY = getPref(PrefKey.CONTROLLER_VIBRATION_INTENSITY) / 100; + + if (!VibrationManager.supportDeviceVibration()) { + window.BX_ENABLE_DEVICE_VIBRATION = false; + return; + } + + // Stop vibration + window.navigator.vibrate(0); + + const value = getPref(PrefKey.CONTROLLER_DEVICE_VIBRATION); + let enabled; + + if (value === 'on') { + enabled = true; + } else if (value === 'auto') { + enabled = true; + const gamepads = window.navigator.getGamepads(); + for (const gamepad of gamepads) { + if (gamepad) { + enabled = false; + break; + } + } + } else { + enabled = false; + } + + window.BX_ENABLE_DEVICE_VIBRATION = enabled; + } + + static #onMessage(e: MessageEvent) { + if (!window.BX_ENABLE_DEVICE_VIBRATION) { + return; + } + + if (typeof e !== 'object' || !(e.data instanceof ArrayBuffer)) { + return; + } + + const dataView = new DataView(e.data); + let offset = 0; + + let messageType; + if (dataView.byteLength === 13) { // version >= 8 + messageType = dataView.getUint16(offset, true); + offset += Uint16Array.BYTES_PER_ELEMENT; + } else { + messageType = dataView.getUint8(offset); + offset += Uint8Array.BYTES_PER_ELEMENT; + } + + if (!(messageType & 128)) { // Vibration + return; + } + + const vibrationType = dataView.getUint8(offset); + offset += Uint8Array.BYTES_PER_ELEMENT; + + if (vibrationType !== 0) { // FourMotorRumble + return; + } + + const data: VibrationData = {}; + let key: keyof typeof VIBRATION_DATA_MAP; + for (key in VIBRATION_DATA_MAP) { + if (VIBRATION_DATA_MAP[key] === 16) { + data[key] = dataView.getUint16(offset, true); + offset += Uint16Array.BYTES_PER_ELEMENT; + } else { + data[key] = dataView.getUint8(offset); + offset += Uint8Array.BYTES_PER_ELEMENT; + } + } + + VibrationManager.#playDeviceVibration(data as Required); + } + + static initialSetup() { + window.addEventListener('gamepadconnected', VibrationManager.updateGlobalVars); + window.addEventListener('gamepaddisconnected', VibrationManager.updateGlobalVars); + + VibrationManager.updateGlobalVars(); + + window.addEventListener(BxEvent.DATA_CHANNEL_CREATED, e => { + const dataChannel = (e as any).dataChannel; + if (!dataChannel || dataChannel.label !== 'input') { + return; + } + + dataChannel.addEventListener('message', VibrationManager.#onMessage); + }); + } +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index bdf7068..acd267f 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -6,6 +6,12 @@ interface Window { BX_FLAGS?: BxFlags; BX_CE: (elmName: string, props: {[index: string]: any}={}) => HTMLElement; BX_EXPOSED: any; + + BX_VIBRATION_INTENSITY: number; + BX_ENABLE_CONTROLLER_VIBRATION: boolean; + BX_ENABLE_DEVICE_VIBRATION: boolean; + + BX_REMOTE_PLAY_CONFIG: BxStates.remotePlay.config; } interface NavigatorBattery extends Navigator { @@ -15,33 +21,41 @@ interface NavigatorBattery extends Navigator { }>, } -type RTCBasicStat = { - address: string, - bytesReceived: number, - clockRate: number, - codecId: string, - framesDecoded: number, - id: string, - kind: string, - mimeType: string, - packetsReceived: number, - profile: string, - remoteCandidateId: string, - sdpFmtpLine: string, - state: string, - timestamp: number, - totalDecodeTime: number, - type: string, -} - type BxStates = { isPlaying: boolean; appContext: any | null; + serverRegions: any; + hasTouchSupport: boolean; + + currentStream: Partial<{ + titleId: string; + xboxTitleId: string; + productId: string; + + $video: HTMLVideoElement | null; + $screenshotCanvas: HTMLCanvasElement | null; + + peerConnection: RTCPeerConnection; + audioContext: AudioContext | null; + audioGainNode: GainNode | null; + }>; + + remotePlay: Partial<{ + isPlaying: boolean; + server: string; + config: { + serverId: string; + }; + }>; } type DualEnum = {[index: string]: number} & {[index: number]: string}; -declare var window: Window & typeof globalThis; -declare var AppInterface: any; -declare var STREAM_WEBRTC: RTCPeerConnection; -declare var States: BxStates; +declare const window: Window & typeof globalThis; +declare const AppInterface: any; +declare const STREAM_WEBRTC: RTCPeerConnection; +declare let States: BxStates; +declare const NATIVE_FETCH: typeof window.fetch; +declare const SCRIPT_VERSION: string; +declare const SCRIPT_HOME: string; +declare var LOCALE: number; diff --git a/src/types/preferences.d.ts b/src/types/preferences.d.ts index 898e8ce..005bb11 100644 --- a/src/types/preferences.d.ts +++ b/src/types/preferences.d.ts @@ -1,7 +1,7 @@ export type PreferenceSetting = { default: any; options?: {[index: string]: string}; - multiple_options?: {[index: string]: string}; + multiplOptions?: {[index: string]: string}; unsupported?: string | boolean; note?: string | HTMLElement; type?: SettingElementType; diff --git a/src/types/stream-stats.d.ts b/src/types/stream-stats.d.ts new file mode 100644 index 0000000..fc60ef0 --- /dev/null +++ b/src/types/stream-stats.d.ts @@ -0,0 +1,18 @@ +type RTCBasicStat = { + address: string, + bytesReceived: number, + clockRate: number, + codecId: string, + framesDecoded: number, + id: string, + kind: string, + mimeType: string, + packetsReceived: number, + profile: string, + remoteCandidateId: string, + sdpFmtpLine: string, + state: string, + timestamp: number, + totalDecodeTime: number, + type: string, +} diff --git a/src/types/titles-info.d.ts b/src/types/titles-info.d.ts new file mode 100644 index 0000000..a11f85a --- /dev/null +++ b/src/types/titles-info.d.ts @@ -0,0 +1,25 @@ +type TitleInfo = { + titleId?: string; + xboxTitleId?: string; + hasTouchSupport?: boolean; + imageHero?: string; +}; + +type ApiTitleInfo = { + titleId: string; + details: { + xboxTitleId: string; + productId: string; + supportedInputTypes: string[]; + }; +}; + +type ApiCatalogInfo = { + StoreId: string; + Image_Hero: { + URL: string; + }; + Image_Tile: { + URL: string; + }; +}; diff --git a/src/utils/gamepad.ts b/src/utils/gamepad.ts new file mode 100644 index 0000000..aedf311 --- /dev/null +++ b/src/utils/gamepad.ts @@ -0,0 +1,33 @@ +import { MkbHandler } from "../modules/mkb/mkb-handler"; +import { PrefKey, getPref } from "../modules/preferences"; +import { t } from "../modules/translation"; +import { Toast } from "./toast"; + +// Show a toast when connecting/disconecting controller +export function showGamepadToast(gamepad: Gamepad) { + // Don't show Toast for virtual controller + if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) { + return; + } + + console.log(gamepad); + let text = '🎮'; + + if (getPref(PrefKey.LOCAL_CO_OP_ENABLED)) { + text += ` #${gamepad.index + 1}`; + } + + // Remove "(STANDARD GAMEPAD Vendor: xxx Product: xxx)" from ID + const gamepadId = gamepad.id.replace(/ \(.*?Vendor: \w+ Product: \w+\)$/, ''); + text += ` - ${gamepadId}`; + + let status; + if (gamepad.connected) { + const supportVibration = !!gamepad.vibrationActuator; + status = (supportVibration ? '✅' : '❌') + ' ' + t('vibration-status'); + } else { + status = t('disconnected'); + } + + Toast.show(text, status, {instant: false}); +} diff --git a/src/utils/history.ts b/src/utils/history.ts new file mode 100644 index 0000000..bbed3c5 --- /dev/null +++ b/src/utils/history.ts @@ -0,0 +1,40 @@ +import { BxEvent } from "../modules/bx-event"; +import { LoadingScreen } from "../modules/loading-screen"; +import { RemotePlay } from "../modules/remote-play"; +import { checkHeader } from "../modules/ui/header"; + +export function patchHistoryMethod(type: 'pushState' | 'replaceState') { + const orig = window.history[type]; + + return function(...args: any[]) { + BxEvent.dispatch(window, BxEvent.POPSTATE, { + arguments: args, + }); + + // @ts-ignore + return orig.apply(this, arguments); + }; +}; + + +export function onHistoryChanged(e: PopStateEvent) { + // @ts-ignore + if (e && e.arguments && e.arguments[0] && e.arguments[0].origin === 'better-xcloud') { + return; + } + + setTimeout(RemotePlay.detect, 10); + + const $settings = document.querySelector('.bx-settings-container'); + if ($settings) { + $settings.classList.add('bx-gone'); + } + + // Hide Remote Play popup + RemotePlay.detachPopup(); + + LoadingScreen.reset(); + setTimeout(checkHeader, 2000); + + BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); +} diff --git a/src/utils/monkey-patches.ts b/src/utils/monkey-patches.ts new file mode 100644 index 0000000..2f9602e --- /dev/null +++ b/src/utils/monkey-patches.ts @@ -0,0 +1,157 @@ +import { BxEvent } from "../modules/bx-event"; +import { getPref, PrefKey } from "../modules/preferences"; +import { UserAgent } from "./user-agent"; + +export function patchVideoApi() { + const PREF_SKIP_SPLASH_VIDEO = getPref(PrefKey.SKIP_SPLASH_VIDEO); + + // Show video player when it's ready + const showFunc = function(this: HTMLVideoElement) { + this.style.visibility = 'visible'; + this.removeEventListener('playing', showFunc); + + if (!this.videoWidth) { + return; + } + + BxEvent.dispatch(window, BxEvent.STREAM_PLAYING, { + $video: this, + }); + } + + const nativePlay = HTMLMediaElement.prototype.play; + HTMLMediaElement.prototype.play = function() { + if (this.className && this.className.startsWith('XboxSplashVideo')) { + if (PREF_SKIP_SPLASH_VIDEO) { + this.volume = 0; + this.style.display = 'none'; + this.dispatchEvent(new Event('ended')); + + return new Promise(() => {}); + } + + return nativePlay.apply(this); + } + + if (!!this.src) { + return nativePlay.apply(this); + } + + this.addEventListener('playing', showFunc); + + return nativePlay.apply(this); + }; +} + + +export function patchRtcCodecs() { + const codecProfile = getPref(PrefKey.STREAM_CODEC_PROFILE); + if (codecProfile === 'default') { + return; + } + + if (typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) { + return false; + } + + const profilePrefix = codecProfile === 'high' ? '4d' : (codecProfile === 'low' ? '420' : '42e'); + const profileLevelId = `profile-level-id=${profilePrefix}`; + + const nativeSetCodecPreferences = RTCRtpTransceiver.prototype.setCodecPreferences; + RTCRtpTransceiver.prototype.setCodecPreferences = function(codecs) { + // Use the same codecs as desktop + const newCodecs = codecs.slice(); + let pos = 0; + newCodecs.forEach((codec, i) => { + // Find high-quality codecs + if (codec.sdpFmtpLine && codec.sdpFmtpLine.includes(profileLevelId)) { + // Move it to the top of the array + newCodecs.splice(i, 1); + newCodecs.splice(pos, 0, codec); + ++pos; + } + }); + + try { + nativeSetCodecPreferences.apply(this, [newCodecs]); + } catch (e) { + // Didn't work -> use default codecs + console.log(e); + nativeSetCodecPreferences.apply(this, [codecs]); + } + } +} + +export function patchRtcPeerConnection() { + const nativeCreateDataChannel = RTCPeerConnection.prototype.createDataChannel; + RTCPeerConnection.prototype.createDataChannel = function() { + // @ts-ignore + const dataChannel = nativeCreateDataChannel.apply(this, arguments); + + BxEvent.dispatch(window, BxEvent.DATA_CHANNEL_CREATED, { + dataChannel: dataChannel, + }); + + return dataChannel; + } + + const OrgRTCPeerConnection = window.RTCPeerConnection; + // @ts-ignore + window.RTCPeerConnection = function() { + const conn = new OrgRTCPeerConnection(); + States.currentStream.peerConnection = conn; + + conn.addEventListener('connectionstatechange', e => { + if (conn.connectionState === 'connecting') { + States.currentStream.audioGainNode = null; + } + console.log('connectionState', conn.connectionState); + }); + return conn; + } +} + +export function patchAudioContext() { + if (UserAgent.isSafari(true)) { + const nativeCreateGain = window.AudioContext.prototype.createGain; + window.AudioContext.prototype.createGain = function() { + const gainNode = nativeCreateGain.apply(this); + gainNode.gain.value = getPref(PrefKey.AUDIO_VOLUME) / 100; + States.currentStream.audioGainNode = gainNode; + return gainNode; + } + } + + const OrgAudioContext = window.AudioContext; + // @ts-ignore + window.AudioContext = function() { + const ctx = new OrgAudioContext(); + States.currentStream.audioContext = ctx; + States.currentStream.audioGainNode = null; + return ctx; + } + + const nativePlay = HTMLAudioElement.prototype.play; + HTMLAudioElement.prototype.play = function() { + this.muted = true; + + const promise = nativePlay.apply(this); + if (States.currentStream.audioGainNode) { + return promise; + } + + this.addEventListener('playing', e => (e.target as HTMLAudioElement).pause()); + + const audioCtx = States.currentStream.audioContext!; + // TOOD: check srcObject + const audioStream = audioCtx.createMediaStreamSource(this.srcObject as any); + const gainNode = audioCtx.createGain(); + + audioStream.connect(gainNode); + gainNode.connect(audioCtx.destination); + gainNode.gain.value = getPref(PrefKey.AUDIO_VOLUME) / 100; + States.currentStream.audioGainNode = gainNode; + + return promise; + } +} diff --git a/src/utils/network.ts b/src/utils/network.ts new file mode 100644 index 0000000..f014d40 --- /dev/null +++ b/src/utils/network.ts @@ -0,0 +1,590 @@ +import { BxEvent } from "../modules/bx-event"; +import { BX_FLAGS } from "../modules/bx-flags"; +import { LoadingScreen } from "../modules/loading-screen"; +import { MouseCursorHider } from "../modules/mkb/mouse-cursor-hider"; +import { PrefKey, getPref } from "../modules/preferences"; +import { RemotePlay } from "../modules/remote-play"; +import { StreamBadges } from "../modules/stream/stream-badges"; +import { TouchController } from "../modules/touch-controller"; +import { getPreferredServerRegion } from "./region"; +import { TitlesInfo } from "./titles-info"; + + +enum RequestType { + XCLOUD = 'xcloud', + XHOME = 'xhome', +}; + +function clearApplicationInsightsBuffers() { + window.sessionStorage.removeItem('AI_buffer'); + window.sessionStorage.removeItem('AI_sentBuffer'); +} + +function clearDbLogs(dbName: string, table: string) { + const request = window.indexedDB.open(dbName); + request.onsuccess = e => { + const db = (e.target as any).result; + + try { + const objectStore = db.transaction(table, 'readwrite').objectStore(table); + const objectStoreRequest = objectStore.clear(); + + objectStoreRequest.onsuccess = function() { + console.log(`[Better xCloud] Cleared ${dbName}.${table}`); + }; + } catch (ex) { + console.log(ex); + } + } +} + +function clearAllLogs() { + clearApplicationInsightsBuffers(); + clearDbLogs('StreamClientLogHandler', 'logs'); + clearDbLogs('XCloudAppLogs', 'logs'); +} + +function updateIceCandidates(candidates: any, options: any) { + const pattern = new RegExp(/a=candidate:(?\d+) (?\d+) UDP (?\d+) (?[^\s]+) (?\d+) (?.*)/); + + const lst = []; + for (let item of candidates) { + if (item.candidate == 'a=end-of-candidates') { + continue; + } + + const groups: {[index: string]: string | number} = pattern.exec(item.candidate)!.groups!; + lst.push(groups); + } + + if (options.preferIpv6Server) { + lst.sort((a, b) => { + const firstIp = a.ip as string; + const secondIp = b.ip as string; + + return (!firstIp.includes(':') && secondIp.includes(':')) ? 1 : -1; + }); + } + + const newCandidates = []; + let foundation = 1; + + const newCandidate = (candidate: string) => { + return { + 'candidate': candidate, + 'messageType': 'iceCandidate', + 'sdpMLineIndex': '0', + 'sdpMid': '0', + }; + }; + + lst.forEach(item => { + item.foundation = foundation; + item.priority = (foundation == 1) ? 10000 : 1; + + newCandidates.push(newCandidate(`a=candidate:${item.foundation} 1 UDP ${item.priority} ${item.ip} ${item.port} ${item.the_rest}`)); + ++foundation; + }); + + if (options.consoleAddrs) { + for (const ip in options.consoleAddrs) { + const port = options.consoleAddrs[ip]; + + newCandidates.push(newCandidate(`a=candidate:${newCandidates.length + 1} 1 UDP 1 ${ip} ${port} typ host`)); + } + } + + newCandidates.push(newCandidate('a=end-of-candidates')); + + console.log(newCandidates); + return newCandidates; +} + + +async function patchIceCandidates(request: Request, consoleAddrs?: {[index: string]: number}) { + // ICE server candidates + const url = (typeof request === 'string') ? request : request.url; + + if (url && url.endsWith('/ice') && url.includes('/sessions/') && request.method === 'GET') { + const response = await NATIVE_FETCH(request); + const text = await response.clone().text(); + + if (!text.length) { + return response; + } + + const options = { + preferIpv6Server: getPref(PrefKey.PREFER_IPV6_SERVER), + consoleAddrs: consoleAddrs, + }; + + const obj = JSON.parse(text); + let exchangeResponse = JSON.parse(obj.exchangeResponse); + exchangeResponse = updateIceCandidates(exchangeResponse, options) + obj.exchangeResponse = JSON.stringify(exchangeResponse); + + response.json = () => Promise.resolve(obj); + response.text = () => Promise.resolve(JSON.stringify(obj)); + + return response; + } + + return null; +} + + +class XhomeInterceptor { + static #consoleAddrs: {[index: string]: number} = {}; + + static async #handleLogin(request: Request) { + try { + const clone = (request as Request).clone(); + + const obj = await clone.json(); + obj.offeringId = 'xhome'; + + request = new Request('https://xhome.gssv-play-prod.xboxlive.com/v2/login/user', { + method: 'POST', + body: JSON.stringify(obj), + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (e) { + alert(e); + console.log(e); + } + + return NATIVE_FETCH(request); + } + + static async #handleConfiguration(request: Request | URL) { + const response = await NATIVE_FETCH(request); + + const obj = await response.clone().json() + console.log(obj); + + const serverDetails = obj.serverDetails; + if (serverDetails.ipV4Address) { + XhomeInterceptor.#consoleAddrs[serverDetails.ipV4Address] = serverDetails.ipV4Port; + } + + if (serverDetails.ipV6Address) { + XhomeInterceptor.#consoleAddrs[serverDetails.ipV6Address] = serverDetails.ipV6Port; + } + + response.json = () => Promise.resolve(obj); + response.text = () => Promise.resolve(JSON.stringify(obj)); + + return response; + } + + static async #handleInputConfigs(request: Request | URL, opts: {[index: string]: any}) { + const response = await NATIVE_FETCH(request); + + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'all') { + return response; + } + + const obj = await response.clone().json() as any; + + const xboxTitleId = JSON.parse(opts.body).titleIds[0]; + States.currentStream.xboxTitleId = xboxTitleId; + + const inputConfigs = obj[0]; + + let hasTouchSupport = inputConfigs.supportedTabs.length > 0; + if (!hasTouchSupport) { + const supportedInputTypes = inputConfigs.supportedInputTypes; + hasTouchSupport = supportedInputTypes.includes('NativeTouch'); + } + + if (hasTouchSupport) { + TouchController.disable(); + + BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, { + data: null, + }); + } else { + TouchController.enable(); + TouchController.getCustomLayouts(xboxTitleId); + } + + response.json = () => Promise.resolve(obj); + response.text = () => Promise.resolve(JSON.stringify(obj)); + + return response; + } + + static async #handleTitles(request: Request) { + const clone = request.clone(); + + const headers: {[index: string]: any} = {}; + for (const pair of (clone.headers as any).entries()) { + headers[pair[0]] = pair[1]; + } + headers.authorization = `Bearer ${RemotePlay.XCLOUD_TOKEN}`; + + const index = request.url.indexOf('.xboxlive.com'); + request = new Request('https://wus.core.gssv-play-prod' + request.url.substring(index), { + method: clone.method, + body: await clone.text(), + headers: headers, + }); + + return NATIVE_FETCH(request); + } + + static async handle(request: Request) { + TouchController.disable(); + + const clone = request.clone(); + + const headers: {[index: string]: string} = {}; + for (const pair of (clone.headers as any).entries()) { + headers[pair[0]] = pair[1]; + } + // Add xHome token to headers + headers.authorization = `Bearer ${RemotePlay.XHOME_TOKEN}`; + + // Patch resolution + const deviceInfo = RemotePlay.BASE_DEVICE_INFO; + if (getPref(PrefKey.REMOTE_PLAY_RESOLUTION) === '720p') { + deviceInfo.dev.os.name = 'android'; + } + + headers['x-ms-device-info'] = JSON.stringify(deviceInfo); + + const opts: {[index: string]: any} = { + method: clone.method, + headers: headers, + }; + + if (clone.method === 'POST') { + opts.body = await clone.text(); + } + + const index = request.url.indexOf('.xboxlive.com'); + let newUrl = States.remotePlay.server + request.url.substring(index + 13); + + request = new Request(newUrl, opts); + let url = (typeof request === 'string') ? request : request.url; + + // Get console IP + if (url.includes('/configuration')) { + return XhomeInterceptor.#handleConfiguration(request); + } else if (url.includes('inputconfigs')) { + return XhomeInterceptor.#handleInputConfigs(request, opts); + } else if (url.includes('/login/user')) { + return XhomeInterceptor.#handleLogin(request); + } else if (url.endsWith('/titles')) { + return XhomeInterceptor.#handleTitles(request); + } + + return await patchIceCandidates(request, XhomeInterceptor.#consoleAddrs) || NATIVE_FETCH(request); + } +} + + +class XcloudInterceptor { + static async #handleLogin(request: RequestInfo | URL, init?: RequestInit) { + const response = await NATIVE_FETCH(request, init); + const obj = await response.clone().json(); + + // Preload Remote Play + BX_FLAGS.PreloadRemotePlay && RemotePlay.preload(); + + // Store xCloud token + RemotePlay.XCLOUD_TOKEN = obj.gsToken; + + // Get server list + const serverEmojis = { + AustraliaEast: '🇦🇺', + AustraliaSouthEast: '🇦🇺', + BrazilSouth: '🇧🇷', + EastUS: '🇺🇸', + EastUS2: '🇺🇸', + JapanEast: '🇯🇵', + KoreaCentral: '🇰🇷', + MexicoCentral: '🇲🇽', + NorthCentralUs: '🇺🇸', + SouthCentralUS: '🇺🇸', + UKSouth: '🇬🇧', + WestEurope: '🇪🇺', + WestUS: '🇺🇸', + WestUS2: '🇺🇸', + }; + + const serverRegex = /\/\/(\w+)\./; + + for (let region of obj.offeringSettings.regions) { + const regionName = region.name as keyof typeof serverEmojis; + let shortName = region.name; + + let match = serverRegex.exec(region.baseUri); + if (match) { + shortName = match[1]; + if (serverEmojis[regionName]) { + shortName = serverEmojis[regionName] + ' ' + shortName; + } + } + + region.shortName = shortName.toUpperCase(); + States.serverRegions[region.name] = Object.assign({}, region); + } + + BxEvent.dispatch(window, BxEvent.XCLOUD_SERVERS_READY); + + const preferredRegion = getPreferredServerRegion(); + if (preferredRegion in States.serverRegions) { + const tmp = Object.assign({}, States.serverRegions[preferredRegion]); + tmp.isDefault = true; + + obj.offeringSettings.regions = [tmp]; + } + + response.json = () => Promise.resolve(obj); + return response; + } + + static async #handlePlay(request: RequestInfo | URL, init?: RequestInit) { + const PREF_STREAM_TARGET_RESOLUTION = getPref(PrefKey.STREAM_TARGET_RESOLUTION); + const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE); + + const url = (typeof request === 'string') ? request : (request as Request).url; + const parsedUrl = new URL(url); + + StreamBadges.region = parsedUrl.host.split('.', 1)[0]; + for (let regionName in States.appContext) { + const region = States.appContext[regionName]; + if (parsedUrl.origin == region.baseUri) { + StreamBadges.region = regionName; + break; + } + } + + const clone = (request as Request).clone(); + const body = await clone.json(); + + // Force stream's resolution + if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') { + const osName = (PREF_STREAM_TARGET_RESOLUTION === '720p') ? 'android' : 'windows'; + body.settings.osName = osName; + } + + // Override "locale" value + if (PREF_STREAM_PREFERRED_LOCALE !== 'default') { + body.settings.locale = PREF_STREAM_PREFERRED_LOCALE; + } + + const newRequest = new Request(request, { + body: JSON.stringify(body), + }); + + return NATIVE_FETCH(newRequest); + } + + static async #handleWaitTime(request: RequestInfo | URL, init?: RequestInit) { + const response = await NATIVE_FETCH(request, init); + + if (getPref(PrefKey.UI_LOADING_SCREEN_WAIT_TIME)) { + const json = await response.clone().json(); + if (json.estimatedAllocationTimeInSeconds > 0) { + // Setup wait time overlay + LoadingScreen.setupWaitTime(json.estimatedTotalWaitTimeInSeconds); + } + } + + return response; + } + + static async #handleConfiguration(request: RequestInfo | URL, init?: RequestInit) { + if ((request as Request).method !== 'GET') { + return NATIVE_FETCH(request, init); + } + + // Touch controller for all games + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') { + TouchController.disable(); + + // Get game ID from window.location + const match = window.location.pathname.match(/\/launch\/[^\/]+\/([\w\d]+)/); + // Check touch support + if (match) { + const titleId = match[1]; + !TitlesInfo.hasTouchSupport(titleId) && TouchController.enable(); + } + } + + // Intercept configurations + const response = await NATIVE_FETCH(request, init); + const text = await response.clone().text(); + if (!text.length) { + return response; + } + + const obj = JSON.parse(text); + let overrides = JSON.parse(obj.clientStreamingConfigOverrides || '{}') || {}; + + overrides.inputConfiguration = overrides.inputConfiguration || {}; + overrides.inputConfiguration.enableVibration = true; + + // Enable touch controller + if (TouchController.isEnabled()) { + overrides.inputConfiguration.enableTouchInput = true; + overrides.inputConfiguration.maxTouchPoints = 10; + } + + // Enable mic + if (getPref(PrefKey.AUDIO_MIC_ON_PLAYING)) { + overrides.audioConfiguration = overrides.audioConfiguration || {}; + overrides.audioConfiguration.enableMicrophone = true; + } + + obj.clientStreamingConfigOverrides = JSON.stringify(overrides); + + response.json = () => Promise.resolve(obj); + response.text = () => Promise.resolve(JSON.stringify(obj)); + + return response; + } + + static async #handleCatalog(request: RequestInfo | URL, init?: RequestInit) { + const response = await NATIVE_FETCH(request, init); + const json = await response.clone().json() + + for (let productId in json.Products) { + TitlesInfo.saveFromCatalogInfo(json.Products[productId]); + } + + return response; + } + + static async #handleTitles(request: RequestInfo | URL, init?: RequestInit) { + const response = await NATIVE_FETCH(request, init); + + if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') { + const json = await response.clone().json() + for (let game of json.results) { + TitlesInfo.saveFromTitleInfo(game); + } + } + + return response; + } + + static async handle(request: RequestInfo | URL, init?: RequestInit) { + let url = (typeof request === 'string') ? request : (request as Request).url; + + // ICE server candidates + const patchedIpv6 = await patchIceCandidates(request as Request); + if (patchedIpv6) { + return patchedIpv6; + } + + // Server list + if (url.endsWith('/v2/login/user')) { + return XcloudInterceptor.#handleLogin(request, init); + } else if (url.endsWith('/sessions/cloud/play')) { // Get session + return XcloudInterceptor.#handlePlay(request, init); + } else if (url.includes('xboxlive.com') && url.includes('/waittime/')) { + return XcloudInterceptor.#handleWaitTime(request, init); + } else if (url.endsWith('/configuration')) { + return XcloudInterceptor.#handleConfiguration(request, init); + } else if (url.startsWith('https://catalog.gamepass.com') && url.includes('/products')) { + return XcloudInterceptor.#handleCatalog(request, init); + } else if (url.includes('/v2/titles') || url.includes('/mru')) { + return XcloudInterceptor.#handleTitles(request, init); + } + + return NATIVE_FETCH(request, init); + } +} + + +export function interceptHttpRequests() { + let BLOCKED_URLS: string[] = []; + if (getPref(PrefKey.BLOCK_TRACKING)) { + // Clear Applications Insight buffers + clearAllLogs(); + + BLOCKED_URLS = BLOCKED_URLS.concat([ + 'https://arc.msn.com', + 'https://browser.events.data.microsoft.com', + 'https://dc.services.visualstudio.com', + // 'https://2c06dea3f26c40c69b8456d319791fd0@o427368.ingest.sentry.io', + ]); + } + + if (getPref(PrefKey.BLOCK_SOCIAL_FEATURES)) { + BLOCKED_URLS = BLOCKED_URLS.concat([ + 'https://peoplehub.xboxlive.com/users/me/people/social', + 'https://peoplehub.xboxlive.com/users/me/people/recommendations', + 'https://notificationinbox.xboxlive.com', + // 'https://accounts.xboxlive.com/family/memberXuid', + ]); + } + + const xhrPrototype = XMLHttpRequest.prototype; + const nativeXhrOpen = xhrPrototype.open; + const nativeXhrSend = xhrPrototype.send; + + xhrPrototype.open = function(method, url) { + // Save URL to use it later in send() + (this as any)._url = url; + // @ts-ignore + return nativeXhrOpen.apply(this, arguments); + }; + + xhrPrototype.send = function(...arg) { + for (const blocked of BLOCKED_URLS) { + if ((this as any)._url.startsWith(blocked)) { + if (blocked === 'https://dc.services.visualstudio.com') { + setTimeout(clearAllLogs, 1000); + } + return false; + } + } + // @ts-ignore + return nativeXhrSend.apply(this, arguments); + }; + + const PREF_UI_LOADING_SCREEN_GAME_ART = getPref(PrefKey.UI_LOADING_SCREEN_GAME_ART); + + 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; + } + + 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); + } + + let requestType: RequestType; + if (States.remotePlay.isPlaying || url.includes('/sessions/home')) { + requestType = RequestType.XHOME; + } else { + requestType = RequestType.XCLOUD; + } + + if (requestType === RequestType.XHOME) { + return XhomeInterceptor.handle(request as Request); + } + + return XcloudInterceptor.handle(request, init); + } +} diff --git a/src/utils/region.ts b/src/utils/region.ts new file mode 100644 index 0000000..db29738 --- /dev/null +++ b/src/utils/region.ts @@ -0,0 +1,30 @@ +import { getPref, Preferences, PrefKey } from "../modules/preferences"; + + +declare var States: BxStates; + +export function getPreferredServerRegion(shortName = false) { + let preferredRegion = getPref(PrefKey.SERVER_REGION); + if (preferredRegion in States.serverRegions) { + if (shortName && States.serverRegions[preferredRegion].shortName) { + return States.serverRegions[preferredRegion].shortName; + } else { + return preferredRegion; + } + } + + for (let regionName in States.serverRegions) { + const region = States.serverRegions[regionName]; + if (!region.isDefault) { + continue; + } + + if (shortName && region.shortName) { + return region.shortName; + } else { + return regionName; + } + } + + return '???'; +} diff --git a/src/utils/titles-info.ts b/src/utils/titles-info.ts index 8cdbe72..45f4770 100644 --- a/src/utils/titles-info.ts +++ b/src/utils/titles-info.ts @@ -2,32 +2,6 @@ import { PrefKey } from "../modules/preferences"; import { getPref } from "../modules/preferences"; import { UserAgent } from "./user-agent"; -type TitleInfo = { - titleId?: string; - xboxTitleId?: string; - hasTouchSupport?: boolean; - imageHero?: string; -}; - -type ApiTitleInfo = { - titleId: string; - details: { - xboxTitleId: string; - productId: string; - supportedInputTypes: string[]; - }; -}; - -type ApiCatalogInfo = { - StoreId: string; - Image_Hero: { - URL: string; - }; - Image_Tile: { - URL: string; - }; -}; - export class TitlesInfo { static #INFO: {[index: string]: TitleInfo} = {}; @@ -44,7 +18,7 @@ export class TitlesInfo { const details = titleInfo.details; const info: TitleInfo = { titleId: titleInfo.titleId, - xboxTitleId: details.xboxTitleId, + xboxTitleId: '' + details.xboxTitleId, // Has more than one input type -> must have touch support hasTouchSupport: (details.supportedInputTypes.length > 1), }; @@ -85,7 +59,7 @@ export class TitlesInfo { } -class PreloadedState { +export class PreloadedState { static override() { Object.defineProperty(window, '__PRELOADED_STATE__', { configurable: true, diff --git a/src/utils/toast.ts b/src/utils/toast.ts index 0100474..3c8dfbc 100644 --- a/src/utils/toast.ts +++ b/src/utils/toast.ts @@ -14,7 +14,7 @@ export class Toast { static #timeout?: number | null; static #DURATION = 3000; - static show(msg: string, status: string, options: ToastOptions={}) { + static show(msg: string, status?: string, options: Partial={}) { options = options || {}; const args = Array.from(arguments) as [string, string, ToastOptions]; diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..a927637 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,40 @@ +import { PrefKey, getPref, setPref } from "../modules/preferences"; +import { UserAgent } from "./user-agent"; + +export function checkForUpdate() { + const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours + + const currentVersion = getPref(PrefKey.CURRENT_VERSION); + const lastCheck = getPref(PrefKey.LAST_UPDATE_CHECK); + const now = Math.round((+new Date) / 1000); + + if (currentVersion === SCRIPT_VERSION && now - lastCheck < CHECK_INTERVAL_SECONDS) { + return; + } + + // Start checking + setPref(PrefKey.LAST_UPDATE_CHECK, now); + fetch('https://api.github.com/repos/redphx/better-xcloud/releases/latest') + .then(response => response.json()) + .then(json => { + // Store the latest version + setPref(PrefKey.LATEST_VERSION, json.tag_name.substring(1)); + setPref(PrefKey.CURRENT_VERSION, SCRIPT_VERSION); + }); +} + + +export function disablePwa() { + const userAgent = ((window.navigator as any).orgUserAgent || window.navigator.userAgent || '').toLowerCase(); + if (!userAgent) { + return; + } + + // Check if it's Safari on mobile + if (UserAgent.isSafari(true)) { + // Disable the PWA prompt + Object.defineProperty(window.navigator, 'standalone', { + value: true, + }); + } +}