mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-06-03 06:07:19 +02:00
Port the rest of the code
This commit is contained in:
parent
27a277309b
commit
be0cbff344
199
src/index.ts
199
src/index.ts
@ -16,18 +16,46 @@
|
||||
|
||||
import { BxEvent } from "./modules/bx-event";
|
||||
import { BX_FLAGS } from "./modules/bx-flags";
|
||||
import { CE, CTN, createButton, createSvgIcon, Icon } from "./utils/html";
|
||||
import { BxExposed } from "./modules/bx-exposed";
|
||||
import { t } from "./modules/translation";
|
||||
import { Dialog } from "./modules/dialog";
|
||||
import { getLocale, t } from "./modules/translation";
|
||||
import { CE } from "./utils/html";
|
||||
import { showGamepadToast } from "./utils/gamepad";
|
||||
import { MkbHandler } from "./modules/mkb/mkb-handler";
|
||||
import { StreamBadges } from "./modules/stream/stream-badges";
|
||||
import { StreamStats } from "./modules/stream/stream-stats";
|
||||
import { addCss } from "./utils/css";
|
||||
import { Toast } from "./utils/toast";
|
||||
import { setupBxUi, updateVideoPlayerCss } from "./modules/ui/ui";
|
||||
import { PrefKey, Preferences, getPref } from "./modules/preferences";
|
||||
import { LoadingScreen } from "./modules/loading-screen";
|
||||
import { MouseCursorHider } from "./modules/mkb/mouse-cursor-hider";
|
||||
import { TouchController } from "./modules/touch-controller";
|
||||
import { watchHeader } from "./modules/ui/header";
|
||||
import { checkForUpdate, disablePwa } from "./utils/utils";
|
||||
import { Patcher } from "./modules/patcher";
|
||||
import { RemotePlay } from "./modules/remote-play";
|
||||
import { onHistoryChanged, patchHistoryMethod } from "./utils/history";
|
||||
import { VibrationManager } from "./modules/vibration-manager";
|
||||
import { PreloadedState } from "./utils/titles-info";
|
||||
import { patchAudioContext, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "./utils/monkey-patches";
|
||||
import { interceptHttpRequests } from "./utils/network";
|
||||
|
||||
const SCRIPT_VERSION = '3.5.3';
|
||||
const SCRIPT_HOME = 'https://github.com/redphx/better-xcloud';
|
||||
|
||||
const NATIVE_FETCH = window.fetch;
|
||||
|
||||
let LOCALE = getLocale();
|
||||
|
||||
const AppInterface = window.AppInterface;
|
||||
const States: BxStates = {
|
||||
let States: BxStates = {
|
||||
isPlaying: false,
|
||||
appContext: {},
|
||||
serverRegions: {},
|
||||
hasTouchSupport: ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
||||
|
||||
currentStream: {},
|
||||
remotePlay: {},
|
||||
};
|
||||
|
||||
|
||||
@ -99,4 +127,165 @@ window.addEventListener('load', e => {
|
||||
|
||||
window.BX_EXPOSED = BxExposed;
|
||||
|
||||
new Dialog({})
|
||||
// Hide Settings UI when navigate to another page
|
||||
// @ts-ignore
|
||||
window.addEventListener(BxEvent.POPSTATE, onHistoryChanged);
|
||||
window.addEventListener('popstate', onHistoryChanged);
|
||||
|
||||
// Make pushState/replaceState methods dispatch BxEvent.POPSTATE event
|
||||
window.history.pushState = patchHistoryMethod('pushState');
|
||||
window.history.replaceState = patchHistoryMethod('replaceState');
|
||||
|
||||
window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => {
|
||||
// Start rendering UI
|
||||
if (document.querySelector('div[class^=UnsupportedMarketPage]')) {
|
||||
setTimeout(watchHeader, 2000);
|
||||
} else {
|
||||
watchHeader();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener(BxEvent.STREAM_LOADING, e => {
|
||||
// Get title ID for screenshot's name
|
||||
if (window.location.pathname.includes('/launch/')) {
|
||||
const matches = /\/launch\/(?<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.");
|
||||
}
|
||||
|
@ -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;
|
||||
|
183
src/modules/loading-screen.ts
Normal file
183
src/modules/loading-screen.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
||||
|
34
src/modules/mkb/mouse-cursor-hider.ts
Normal file
34
src/modules/mkb/mouse-cursor-hider.ts
Normal 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
586
src/modules/patcher.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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
353
src/modules/remote-play.ts
Normal 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
96
src/modules/screenshot.ts
Normal 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);
|
||||
}
|
@ -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) {
|
@ -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
|
344
src/modules/stream/stream-ui.ts
Normal file
344
src/modules/stream/stream-ui.ts
Normal 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);
|
||||
}
|
||||
}
|
300
src/modules/touch-controller.ts
Normal file
300
src/modules/touch-controller.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -3222,5 +3222,5 @@ const Translations = {
|
||||
],
|
||||
}
|
||||
|
||||
let LOCALE = Translations.getLocale();
|
||||
export const t = Translations.get;
|
||||
export const getLocale = Translations.getLocale;
|
||||
|
347
src/modules/ui/global-settings.ts
Normal file
347
src/modules/ui/global-settings.ts
Normal 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
83
src/modules/ui/header.ts
Normal 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
483
src/modules/ui/ui.ts
Normal 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();
|
||||
}
|
150
src/modules/vibration-manager.ts
Normal file
150
src/modules/vibration-manager.ts
Normal 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
60
src/types/index.d.ts
vendored
@ -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;
|
||||
|
2
src/types/preferences.d.ts
vendored
2
src/types/preferences.d.ts
vendored
@ -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
18
src/types/stream-stats.d.ts
vendored
Normal 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
25
src/types/titles-info.d.ts
vendored
Normal 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
33
src/utils/gamepad.ts
Normal 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
40
src/utils/history.ts
Normal 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
157
src/utils/monkey-patches.ts
Normal 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
590
src/utils/network.ts
Normal 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
30
src/utils/region.ts
Normal 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 '???';
|
||||
}
|
@ -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,
|
||||
|
@ -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
40
src/utils/utils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user