Port the rest of the code

This commit is contained in:
redphx 2024-04-23 17:55:52 +07:00
parent 27a277309b
commit be0cbff344
30 changed files with 4338 additions and 234 deletions

View File

@ -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\/(?<title_id>[^\/]+)\/(?<product_id>\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.");
}

View File

@ -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;

View File

@ -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<HTMLElement>('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;
}
}

View File

@ -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';

View File

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

586
src/modules/patcher.ts Normal file
View File

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

View File

@ -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<HTMLAnchorElement>('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<HTMLAnchorElement>('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);

353
src/modules/remote-play.ts Normal file
View File

@ -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<RemotePlayConsole>;
static #REGIONS: Array<RemotePlayRegion>;
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<HTMLInputElement>('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;
}
}

96
src/modules/screenshot.ts Normal file
View File

@ -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<HTMLAnchorElement>('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);
}

View File

@ -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) {

View File

@ -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

View File

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

View File

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

View File

@ -3222,5 +3222,5 @@ const Translations = {
],
}
let LOCALE = Translations.getLocale();
export const t = Translations.get;
export const getLocale = Translations.getLocale;

View File

@ -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<HTMLElement>('div', {
'class': 'bx-settings-container bx-gone',
});
let $updateAvailable;
const $wrapper = CE<HTMLElement>('div', {'class': 'bx-settings-wrapper'},
CE<HTMLElement>('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<HTMLSelectElement>('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<HTMLElement>('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<HTMLButtonElement>('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<HTMLElement>('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);
}

83
src/modules/ui/header.ts Normal file
View File

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

483
src/modules/ui/ui.ts Normal file
View File

@ -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<HTMLAnchorElement>('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<HTMLElement>('div', {'class': 'bx-quick-settings-bar bx-gone'},
$tabs = CE<HTMLElement>('div', {'class': 'bx-quick-settings-tabs'}),
$settings = CE<HTMLElement>('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<HTMLElement>('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<HTMLElement>('div', {'class': 'bx-quick-settings-row', 'data-type': settingGroup.group},
CE('label', {for: `bx_setting_${pref}`},
setting.label,
setting.unsupported && CE<HTMLElement>('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<HTMLStyleElement>('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();
}

View File

@ -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<VibrationData>) {
// 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<VibrationData>);
}
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);
});
}
}

60
src/types/index.d.ts vendored
View File

@ -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;

View File

@ -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;

18
src/types/stream-stats.d.ts vendored Normal file
View File

@ -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,
}

25
src/types/titles-info.d.ts vendored Normal file
View File

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

33
src/utils/gamepad.ts Normal file
View File

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

40
src/utils/history.ts Normal file
View File

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

157
src/utils/monkey-patches.ts Normal file
View File

@ -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<void>(() => {});
}
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;
}
}

590
src/utils/network.ts Normal file
View File

@ -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:(?<foundation>\d+) (?<component>\d+) UDP (?<priority>\d+) (?<ip>[^\s]+) (?<port>\d+) (?<the_rest>.*)/);
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<Response> => {
let url = (typeof request === 'string') ? request : (request as Request).url;
// Check blocked URLs
for (let blocked of BLOCKED_URLS) {
if (!url.startsWith(blocked)) {
continue;
}
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);
}
}

30
src/utils/region.ts Normal file
View File

@ -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 '???';
}

View File

@ -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,

View File

@ -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<ToastOptions>={}) {
options = options || {};
const args = Array.from(arguments) as [string, string, ToastOptions];

40
src/utils/utils.ts Normal file
View File

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