mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-08-08 06:08:27 +02:00
Port the rest of the code
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user