Use a better method to enable touch control for all games

This commit is contained in:
redphx 2024-04-29 10:05:22 +07:00
parent 102a4657f0
commit cd6dd1e22d
9 changed files with 104 additions and 128 deletions

View File

@ -124,10 +124,11 @@ window.addEventListener(BxEvent.STREAM_LOADING, e => {
// Setup UI // Setup UI
setupBxUi(); setupBxUi();
// Setup loading screen
getPref(PrefKey.UI_LOADING_SCREEN_GAME_ART) && LoadingScreen.setup();
}); });
// Setup loading screen
getPref(PrefKey.UI_LOADING_SCREEN_GAME_ART) && window.addEventListener(BxEvent.TITLE_INFO_READY, LoadingScreen.setup);
window.addEventListener(BxEvent.STREAM_STARTING, e => { window.addEventListener(BxEvent.STREAM_STARTING, e => {
// Hide loading screen // Hide loading screen

View File

@ -1,8 +1,8 @@
import { CE } from "../utils/html"; import { CE } from "../utils/html";
import { getPreferredServerRegion } from "../utils/region"; import { getPreferredServerRegion } from "../utils/region";
import { TitlesInfo } from "../utils/titles-info";
import { PrefKey, getPref } from "../utils/preferences"; import { PrefKey, getPref } from "../utils/preferences";
import { t } from "../utils/translation"; import { t } from "../utils/translation";
import { STATES } from "../utils/global";
export class LoadingScreen { export class LoadingScreen {
static #$bgStyle: HTMLElement; static #$bgStyle: HTMLElement;
@ -21,9 +21,8 @@ export class LoadingScreen {
} }
static setup() { static setup() {
// Get titleId from location const titleInfo = STATES.currentStream.titleInfo;
const match = window.location.pathname.match(/\/launch\/[^\/]+\/([\w\d]+)/); if (!titleInfo) {
if (!match) {
return; return;
} }
@ -33,15 +32,7 @@ export class LoadingScreen {
LoadingScreen.#$bgStyle = $bgStyle; LoadingScreen.#$bgStyle = $bgStyle;
} }
const titleId = match[1]; LoadingScreen.#setBackground(titleInfo.product.heroImageUrl || titleInfo.product.titledHeroImageUrl || titleInfo.product.tileImageUrl);
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') { if (getPref(PrefKey.UI_LOADING_SCREEN_ROCKET) === 'hide') {
LoadingScreen.#hideRocket(); LoadingScreen.#hideRocket();

View File

@ -332,14 +332,14 @@ const gamepads = window.navigator.getGamepads();
let gamepadFound = false; let gamepadFound = false;
for (let gamepad of gamepads) { for (let gamepad of gamepads) {
if (gamepad && gamepad.connected) { if (gamepad && gamepad.connected) {
gamepadFound = true; gamepadFound = true;
break; break;
} }
} }
if (gamepadFound) { if (gamepadFound) {
return; return;
} }
`; `;
} }
@ -386,6 +386,24 @@ window.BX_EXPOSED.onPollingModeChanged && window.BX_EXPOSED.onPollingModeChanged
str = str.replace(text, text + newCode); str = str.replace(text, text + newCode);
return str; return str;
}, },
patchXcloudTitleInfo(str: string) {
const text = 'async cloudConnect';
let index = str.indexOf(text);
if (index === -1) {
return false;
}
// Find the next "{" backet
index = str.indexOf('{', index) + 1;
const newCode = `
e = window.BX_EXPOSED.modifyTitleInfo(e);
console.log(e);
`;
str = str.substring(0, index) + newCode + str.substring(index);
return str;
},
}; };
let PATCH_ORDERS = [ let PATCH_ORDERS = [
@ -432,6 +450,8 @@ let PATCH_ORDERS = [
// Only when playing // Only when playing
const PLAYING_PATCH_ORDERS = [ const PLAYING_PATCH_ORDERS = [
['patchXcloudTitleInfo'],
getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayConnectMode'], getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayConnectMode'],
getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayGuideWorkaround'], getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayGuideWorkaround'],

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

@ -31,6 +31,7 @@ type BxStates = {
titleId: string; titleId: string;
xboxTitleId: string; xboxTitleId: string;
productId: string; productId: string;
titleInfo: XcloudTitleInfo;
$video: HTMLVideoElement | null; $video: HTMLVideoElement | null;
$screenshotCanvas: HTMLCanvasElement | null; $screenshotCanvas: HTMLCanvasElement | null;
@ -50,3 +51,17 @@ type BxStates = {
} }
type DualEnum = {[index: string]: number} & {[index: number]: string}; type DualEnum = {[index: string]: number} & {[index: number]: string};
type XcloudTitleInfo = {
details: {
productId: string;
supportedInputTypes: InputType[];
hasTouchSupport: boolean;
};
product: {
heroImageUrl: string;
titledHeroImageUrl: string;
tileImageUrl: string;
};
};

View File

@ -4,6 +4,8 @@ export enum BxEvent {
JUMP_BACK_IN_READY = 'bx-jump-back-in-ready', JUMP_BACK_IN_READY = 'bx-jump-back-in-ready',
POPSTATE = 'bx-popstate', POPSTATE = 'bx-popstate',
TITLE_INFO_READY = 'bx-title-info-ready',
STREAM_LOADING = 'bx-stream-loading', STREAM_LOADING = 'bx-stream-loading',
STREAM_STARTING = 'bx-stream-starting', STREAM_STARTING = 'bx-stream-starting',
STREAM_STARTED = 'bx-stream-started', STREAM_STARTED = 'bx-stream-started',

View File

@ -1,4 +1,16 @@
import { BxEvent } from "./bx-event";
import { STATES } from "./global"; import { STATES } from "./global";
import { getPref, PrefKey } from "./preferences";
import { UserAgent } from "./user-agent";
enum InputType {
CONTROLLER = 'Controller',
MKB = 'MKB',
CUSTOM_TOUCH_OVERLAY = 'CustomTouchOverlay',
GENERIC_TOUCH = 'GenericTouch',
NATIVE_TOUCH = 'NativeTouch',
BATIVE_SENSOR = 'NativeSensor',
}
export const BxExposed = { export const BxExposed = {
onPollingModeChanged: (mode: 'All' | 'None') => { onPollingModeChanged: (mode: 'All' | 'None') => {
@ -23,4 +35,35 @@ export const BxExposed = {
$touchControllerBar && $touchControllerBar.classList.remove('bx-gone'); $touchControllerBar && $touchControllerBar.classList.remove('bx-gone');
} }
}, },
modifyTitleInfo: (titleInfo: XcloudTitleInfo): XcloudTitleInfo => {
// Clone the object since the original is read-only
titleInfo = structuredClone(titleInfo);
const touchControllerAvailability = getPref(PrefKey.STREAM_TOUCH_CONTROLLER);
let supportedInputTypes = titleInfo.details.supportedInputTypes;
// Remove MKB support on mobile browsers
if (UserAgent.isMobile()) {
supportedInputTypes = supportedInputTypes.filter(i => i !== 'MKB');
}
// Add custom property
titleInfo.details.hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) &&
!supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) &&
!supportedInputTypes.includes(InputType.GENERIC_TOUCH);
// Add generic touch support for non touch-supported games
if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === 'all') {
supportedInputTypes.push(InputType.GENERIC_TOUCH);
}
titleInfo.details.supportedInputTypes = supportedInputTypes;
// Save this info in STATES
STATES.currentStream.titleInfo = titleInfo;
BxEvent.dispatch(window, BxEvent.TITLE_INFO_READY);
return titleInfo;
}
}; };

View File

@ -7,7 +7,6 @@ import { StreamBadges } from "../modules/stream/stream-badges";
import { TouchController } from "../modules/touch-controller"; import { TouchController } from "../modules/touch-controller";
import { STATES } from "./global"; import { STATES } from "./global";
import { getPreferredServerRegion } from "./region"; import { getPreferredServerRegion } from "./region";
import { TitlesInfo } from "./titles-info";
export const NATIVE_FETCH = window.fetch; export const NATIVE_FETCH = window.fetch;
@ -402,14 +401,11 @@ class XcloudInterceptor {
// Touch controller for all games // Touch controller for all games
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') { if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
TouchController.disable(); const titleInfo = STATES.currentStream.titleInfo;
if (titleInfo?.details.hasTouchSupport) {
// Get game ID from window.location TouchController.disable();
const match = window.location.pathname.match(/\/launch\/[^\/]+\/([\w\d]+)/); } else {
// Check touch support TouchController.enable();
if (match) {
const titleId = match[1];
!TitlesInfo.hasTouchSupport(titleId) && TouchController.enable();
} }
} }
@ -446,30 +442,6 @@ class XcloudInterceptor {
return response; 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) { static async handle(request: RequestInfo | URL, init?: RequestInit) {
let url = (typeof request === 'string') ? request : (request as Request).url; let url = (typeof request === 'string') ? request : (request as Request).url;
@ -482,10 +454,6 @@ class XcloudInterceptor {
return XcloudInterceptor.#handleWaitTime(request, init); return XcloudInterceptor.#handleWaitTime(request, init);
} else if (url.endsWith('/configuration')) { } else if (url.endsWith('/configuration')) {
return XcloudInterceptor.#handleConfiguration(request, init); 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);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') { } else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request as Request); return patchIceCandidates(request as Request);
} }

View File

@ -1,64 +1,6 @@
import { PrefKey } from "./preferences";
import { getPref } from "./preferences";
import { STATES } from "./global"; import { STATES } from "./global";
import { UserAgent } from "./user-agent"; import { UserAgent } from "./user-agent";
export class TitlesInfo {
static #INFO: {[index: string]: TitleInfo} = {};
static get(titleId: string) {
return TitlesInfo.#INFO[titleId];
}
static update(titleId: string, info: TitleInfo) {
TitlesInfo.#INFO[titleId] = TitlesInfo.#INFO[titleId] || {};
Object.assign(TitlesInfo.#INFO[titleId], info);
}
static saveFromTitleInfo(titleInfo: ApiTitleInfo) {
const details = titleInfo.details;
const info: TitleInfo = {
titleId: titleInfo.titleId,
xboxTitleId: '' + details.xboxTitleId,
// Has more than one input type -> must have touch support
hasTouchSupport: (details.supportedInputTypes.length > 1),
};
TitlesInfo.update(details.productId, info);
}
static saveFromCatalogInfo(catalogInfo: ApiCatalogInfo) {
const titleId = catalogInfo.StoreId;
const imageHero = (catalogInfo.Image_Hero || catalogInfo.Image_Tile || {}).URL;
TitlesInfo.update(titleId, {
imageHero: imageHero,
});
}
static hasTouchSupport(titleId: string): boolean {
return !!TitlesInfo.#INFO[titleId]?.hasTouchSupport;
}
static requestCatalogInfo(titleId: string, callback: any) {
const url = `https://catalog.gamepass.com/v3/products?market=${STATES.appContext.marketInfo.market}&language=${STATES.appContext.marketInfo.locale}&hydration=RemoteHighSapphire0`;
const appVersion = document.querySelector('meta[name=gamepass-app-version]')!.getAttribute('content');
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Ms-Cv': STATES.appContext.telemetryInfo.initialCv,
'Calling-App-Name': 'Xbox Cloud Gaming Web',
'Calling-App-Version': appVersion,
} as any,
body: JSON.stringify({
Products: [titleId],
}),
}).then(resp => {
callback && callback(TitlesInfo.get(titleId));
});
}
}
export class PreloadedState { export class PreloadedState {
static override() { static override() {
@ -76,18 +18,6 @@ export class PreloadedState {
set: state => { set: state => {
(this as any)._state = state; (this as any)._state = state;
STATES.appContext = structuredClone(state.appContext); STATES.appContext = structuredClone(state.appContext);
// Get a list of touch-supported games
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
let titles: {[index: string]: any} = {};
try {
titles = state.xcloud.titles.data.titles;
} catch (e) {}
for (let id in titles) {
TitlesInfo.saveFromTitleInfo(titles[id].data);
}
}
} }
}); });
} }

View File

@ -43,7 +43,7 @@ export class UserAgent {
return (UserAgent.#USER_AGENTS as any)[profile] || defaultUserAgent; return (UserAgent.#USER_AGENTS as any)[profile] || defaultUserAgent;
} }
static isSafari(mobile=false) { static isSafari(mobile=false): boolean {
const userAgent = (UserAgent.getDefault() || '').toLowerCase(); const userAgent = (UserAgent.getDefault() || '').toLowerCase();
let result = userAgent.includes('safari') && !userAgent.includes('chrom'); let result = userAgent.includes('safari') && !userAgent.includes('chrom');
@ -54,6 +54,11 @@ export class UserAgent {
return result; return result;
} }
static isMobile(): boolean {
const userAgent = (UserAgent.getDefault() || '').toLowerCase();
return /iphone|ipad|android/.test(userAgent);
}
static spoof() { static spoof() {
let newUserAgent; let newUserAgent;
@ -67,6 +72,7 @@ export class UserAgent {
} }
// Clear data of navigator.userAgentData, force xCloud to detect browser based on navigator.userAgent // Clear data of navigator.userAgentData, force xCloud to detect browser based on navigator.userAgent
(window.navigator as any).orgUserAgentData = (window.navigator as any).userAgentData;
Object.defineProperty(window.navigator, 'userAgentData', {}); Object.defineProperty(window.navigator, 'userAgentData', {});
// Override navigator.userAgent // Override navigator.userAgent