Compare commits

..

38 Commits

Author SHA1 Message Date
b66ca192b2 Bump version to 4.2.0 2024-05-08 17:38:45 +07:00
660aac4e8c Update better-xcloud.user.js 2024-05-08 17:18:42 +07:00
3b1f5155c6 Update translations 2024-05-08 17:18:23 +07:00
500f6671c6 Rename "touchLayoutManager" and "testTouchLayout" 2024-05-08 17:11:15 +07:00
26bf14eda6 Show custom touch layout's author name in toast message 2024-05-08 17:04:14 +07:00
d8fada8f5d Fix not able to get Chromium version in WebView 2024-05-08 16:48:42 +07:00
4e8848d2fb Update better-xcloud.user.js 2024-05-08 08:55:10 +07:00
8e23ca51de Remove debuggers 2024-05-08 08:55:05 +07:00
9ac988e894 Update dist 2024-05-08 08:04:09 +07:00
c2efbd9c1d Remove non-cloud games from touch games list 2024-05-08 08:03:58 +07:00
7eda0b61cc Update dist 2024-05-07 21:40:28 +07:00
c948b63b8d Show touch icon on games with custom layouts 2024-05-07 21:40:12 +07:00
fc56d486a7 Update dist 2024-05-07 18:03:00 +07:00
7dacc8f23a Fix problems when holding NumberStepper's buttons 2024-05-07 18:01:36 +07:00
2df3bb4611 Add "Default opacity" setting for touch controller 2024-05-07 17:37:49 +07:00
b9355d5c01 Optimize touch control's canvas, use low-power profile and disable antialiasing 2024-05-07 17:07:15 +07:00
d1b99705e6 Bump version to 4.1.2 2024-05-05 21:32:41 +07:00
52896c94ae Update dist 2024-05-05 18:29:14 +07:00
cadc7987b7 Update "disableIndexDbLogging" patch 2024-05-05 18:29:04 +07:00
8fb1787222 Disable telemetry flags in meversion.js 2024-05-05 18:04:20 +07:00
4231d7e9c6 Update logs 2024-05-05 11:54:59 +07:00
ba05eab47b Update dist 2024-05-05 09:37:06 +07:00
e852b246d3 Rewrite volume control feature 2024-05-05 09:36:53 +07:00
23fb50cb6f Update dist 2024-05-04 17:32:17 +07:00
443bf93c9a Fix the refresh button not focusable using gamepad 2024-05-04 17:32:08 +07:00
df2af43c64 Bump version to 4.1.1 2024-05-04 17:01:57 +07:00
fca3bee6dd Update dist 2024-05-04 16:20:38 +07:00
9bf8a2ef66 Add a standalone Refresh stream button (#315) 2024-05-04 16:20:12 +07:00
b1df189c7d Support "styles" param in touch control 2024-05-04 15:33:19 +07:00
d91fdb798e Update dist 2024-05-04 15:15:15 +07:00
a291443d43 Update dist 2024-05-04 15:01:49 +07:00
8a7be5d523 Add ability to use normal website's layout on TV 2024-05-04 15:00:27 +07:00
7588f37472 Add "Smart TV" + "Meta Quest VR" User-Agent profiles 2024-05-04 14:52:54 +07:00
a597d52585 Update dist 2024-05-04 14:34:19 +07:00
f945a3adde Move PatcherCache.init() to patchFunctionBind() 2024-05-04 14:34:06 +07:00
438afe086a Update dist 2024-05-04 11:23:19 +07:00
f6ee79770c Clear PatcherCache when changing settings 2024-05-04 11:22:42 +07:00
f36c77e727 Fix touch control not showing when using "caches" as variable name 2024-05-04 11:21:18 +07:00
29 changed files with 1411 additions and 939 deletions

View File

@ -1,5 +1,5 @@
// ==UserScript==
// @name Better xCloud
// @namespace https://github.com/redphx
// @version 4.1.0
// @version 4.2.0
// ==/UserScript==

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,6 @@
.bx-number-stepper {
text-align: center;
span {
display: inline-block;
width: 40px;
@ -35,6 +37,13 @@
}
}
input[type="range"] {
display: block;
margin: 12px auto 2px;
width: 180px;
color: #959595 !important;
}
input[type=range]:disabled, button:disabled {
display: none;
}

View File

@ -86,13 +86,6 @@
white-space: nowrap;
}
}
input[type="range"] {
display: block;
margin: 12px auto 2px;
width: 180px;
color: #959595 !important;
}
}

View File

@ -7,3 +7,15 @@ div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] {
background-color: #2d2d2d !important;
color: #000 !important;
}
.bx-stream-refresh-button {
top: calc(env(safe-area-inset-top, 0px) + 10px + 50px) !important;
}
body[data-media-type=default] .bx-stream-refresh-button {
left: calc(env(safe-area-inset-left, 0px) + 11px) !important;
}
body[data-media-type=tv] .bx-stream-refresh-button {
top: calc(var(--gds-focus-borderSize) + 80px) !important;
}

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d="M23.247 12.377h7.247V5.13"/><path d="M23.911 25.663a13.29 13.29 0 0 1-9.119 3.623C7.504 29.286 1.506 23.289 1.506 16S7.504 2.713 14.792 2.713a13.29 13.29 0 0 1 9.395 3.891l6.307 5.772"/>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@ -22,8 +22,8 @@ import { Patcher } from "@modules/patcher";
import { RemotePlay } from "@modules/remote-play";
import { onHistoryChanged, patchHistoryMethod } from "@utils/history";
import { VibrationManager } from "@modules/vibration-manager";
import { PreloadedState } from "@utils/titles-info";
import { patchAudioContext, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches";
import { overridePreloadState } from "@utils/preload-state";
import { patchAudioContext, patchCanvasContext, patchMeControl, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches";
import { STATES } from "@utils/global";
import { injectStreamMenuButtons } from "@modules/stream/stream-ui";
import { BxLogger } from "@utils/bx-logger";
@ -215,12 +215,13 @@ function main() {
patchRtcCodecs();
interceptHttpRequests();
patchVideoApi();
patchCanvasContext();
if (getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL)) {
patchAudioContext();
}
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext();
getPref(PrefKey.BLOCK_TRACKING) && patchMeControl();
PreloadedState.override();
STATES.hasTouchSupport && TouchController.updateCustomList();
overridePreloadState();
VibrationManager.initialSetup();

View File

@ -393,7 +393,7 @@ export class MkbHandler {
}),
CE('div', {},
CE('p', {}, t('mkb-click-to-activate')),
CE('p', {}, t<any>('press-key-to-toggle-mkb')({key: 'F8'})),
CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})),
),
);

View File

@ -3,7 +3,7 @@ import { BX_FLAGS } from "@utils/bx-flags";
import { getPref, PrefKey } from "@utils/preferences";
import { VibrationManager } from "@modules/vibration-manager";
import { BxLogger } from "@utils/bx-logger";
import { hashCode } from "@/utils/utils";
import { hashCode } from "@utils/utils";
type PatchArray = (keyof typeof PATCHES)[];
@ -59,22 +59,25 @@ const PATCHES = {
// Disable IndexDB logging
disableIndexDbLogging(str: string) {
const text = 'async addLog(e,t=1e4){';
const text = ',this.logsDb=new';
if (!str.includes(text)) {
return false;
}
return str.replace(text, text + 'return;');
// Replace log() with an empty function
let newCode = ',this.log=()=>{}';
return str.replace(text, newCode + text);
},
// Set TV layout
tvLayout(str: string) {
// Set custom website layout
websiteLayout(str: string) {
const text = '?"tv":"default"';
if (!str.includes(text)) {
return false;
}
return str.replace(text, '?"tv":"tv"');
const layout = getPref(PrefKey.UI_LAYOUT) === 'tv' ? 'tv' : 'default';
return str.replace(text, `?"${layout}":"${layout}"`);
},
// Replace "/direct-connect" with "/play"
@ -282,7 +285,7 @@ if (window.BX_VIBRATION_INTENSITY && window.BX_VIBRATION_INTENSITY < 1) {
return false;
}
str = str.replace(text, 'window.BX_EXPOSED["touch_layout_manager"] = this,' + text);
str = str.replace(text, 'window.BX_EXPOSED["touchLayoutManager"] = this,' + text);
return str;
},
@ -458,6 +461,41 @@ BxLogger.info('patchRemotePlayMkb', ${configsVar});
return str;
},
patchAudioMediaStream(str: string) {
const text = '.srcObject=this.audioMediaStream,';
if (!str.includes(text)) {
return false;
}
const newCode = `window.BX_EXPOSED.setupGainNode(arguments[1], this.audioMediaStream),`;
str = str.replace(text, text + newCode);
return str;
},
patchCombinedAudioVideoMediaStream(str: string) {
const text = '.srcObject=this.combinedAudioVideoStream';
if (!str.includes(text)) {
return false;
}
const newCode = `,window.BX_EXPOSED.setupGainNode(arguments[0], this.combinedAudioVideoStream)`;
str = str.replace(text, text + newCode);
return str;
},
patchTouchControlDefaultOpacity(str: string) {
const text = 'opacityMultiplier:1';
if (!str.includes(text)) {
return false;
}
const opacity = (getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) / 100).toFixed(1);
const newCode = `opacityMultiplier: ${opacity}`;
str = str.replace(text, newCode);
return str;
},
};
let PATCH_ORDERS: PatchArray = [
@ -465,7 +503,7 @@ let PATCH_ORDERS: PatchArray = [
'overrideSettings',
'broadcastPollingMode',
getPref(PrefKey.UI_LAYOUT) === 'tv' && 'tvLayout',
getPref(PrefKey.UI_LAYOUT) !== 'default' && 'websiteLayout',
getPref(PrefKey.LOCAL_CO_OP_ENABLED) && 'supportLocalCoOp',
getPref(PrefKey.GAME_FORTNITE_FORCE_CONSOLE) && 'forceFortniteConsole',
@ -500,8 +538,15 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
'patchStreamHud',
'playVibration',
// Patch volume control for normal stream
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && !getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchAudioMediaStream',
// Patch volume control for combined audio+video stream
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchCombinedAudioVideoMediaStream',
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',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity',
BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging',
@ -520,8 +565,14 @@ const ALL_PATCHES = [...PATCH_ORDERS, ...PLAYING_PATCH_ORDERS];
export class Patcher {
static #patchFunctionBind() {
const nativeBind = Function.prototype.bind;
Function.prototype.bind = function () {
Function.prototype.bind = function() {
let valid = false;
// Looking for these criteria:
// - Variable name <= 2 characters
// - Has 2 params:
// - The first one is null
// - The second one is either 0 or a function
if (this.name.length <= 2 && arguments.length === 2 && arguments[0] === null) {
if (arguments[1] === 0 || (typeof arguments[1] === 'function')) {
valid = true;
@ -533,6 +584,8 @@ export class Patcher {
return nativeBind.apply(this, arguments);
}
PatcherCache.init();
if (typeof arguments[1] === 'function') {
BxLogger.info(LOG_TAG, 'Restored Function.prototype.bind()');
Function.prototype.bind = nativeBind;
@ -549,23 +602,23 @@ export class Patcher {
};
}
static length() { return PATCH_ORDERS.length; };
static patch(item: [[number], { [key: string]: () => {} }]) {
// !!! Use "caches" as variable name will break touch controller???
// console.log('patch', '-----');
let patchesToCheck: PatchArray;
let appliedPatches;
const caches: { [key: string]: string[] } = {};
let appliedPatches: PatchArray;
const patchesMap: Record<string, PatchArray> = {};
for (let id in item[1]) {
appliedPatches = [];
const cachedPatches = PatcherCache.getPatches(id);
if (cachedPatches) {
patchesToCheck = cachedPatches;
patchesToCheck = cachedPatches.slice(0);
patchesToCheck.push(...PATCH_ORDERS);
} else {
patchesToCheck = PATCH_ORDERS;
patchesToCheck = PATCH_ORDERS.slice(0);
}
// Empty patch list
@ -573,20 +626,21 @@ export class Patcher {
continue;
}
// console.log(patchesToCheck);
const func = item[1][id];
let str = func.toString();
// console.log(id, str);
for (let groupIndex = 0; groupIndex < patchesToCheck.length; groupIndex++) {
const patchName = patchesToCheck[groupIndex];
let modified = false;
let modified = false;
for (let patchIndex = 0; patchIndex < patchesToCheck.length; patchIndex++) {
const patchName = patchesToCheck[patchIndex];
if (appliedPatches.indexOf(patchName) > -1) {
continue;
}
if (!PATCHES[patchName]) {
continue;
}
// Check function against patch
const patchedStr = PATCHES[patchName].call(null, str);
@ -598,28 +652,28 @@ export class Patcher {
modified = true;
str = patchedStr;
BxLogger.info(LOG_TAG, `Applied "${patchName}" patch`);
BxLogger.info(LOG_TAG, `${patchName}`);
appliedPatches.push(patchName);
// Remove patch
patchesToCheck.splice(groupIndex, 1);
groupIndex--;
patchesToCheck.splice(patchIndex, 1);
patchIndex--;
PATCH_ORDERS = PATCH_ORDERS.filter(item => item != patchName);
}
// Apply patched functions
if (modified) {
item[1][id] = eval(str);
}
// Apply patched functions
if (modified) {
item[1][id] = eval(str);
}
// Save to cache
if (appliedPatches.length) {
caches[id] = appliedPatches;
patchesMap[id] = appliedPatches;
}
}
if (Object.keys(caches).length) {
PatcherCache.saveToCache(caches);
if (Object.keys(patchesMap).length) {
PatcherCache.saveToCache(patchesMap);
}
}
@ -628,12 +682,14 @@ export class Patcher {
}
}
class PatcherCache {
export class PatcherCache {
static #KEY_CACHE = 'better_xcloud_patches_cache';
static #KEY_SIGNATURE = 'better_xcloud_patches_cache_signature';
static #CACHE: any;
static #isInitialized = false;
/**
* Get patch's signature
*/
@ -647,18 +703,22 @@ class PatcherCache {
return sig;
}
static clear() {
// Clear cache
window.localStorage.removeItem(PatcherCache.#KEY_CACHE);
PatcherCache.#CACHE = {};
}
static checkSignature() {
const storedSig = window.localStorage.getItem(PatcherCache.#KEY_SIGNATURE) || 0;
const currentSig = PatcherCache.#getSignature();
if (currentSig !== parseInt(storedSig as string)) {
BxLogger.warning(LOG_TAG, 'Signature changed');
// Clear cache
window.localStorage.setItem(PatcherCache.#KEY_CACHE, '{}');
// Save new signature
BxLogger.warning(LOG_TAG, 'Signature changed');
window.localStorage.setItem(PatcherCache.#KEY_SIGNATURE, currentSig.toString());
PatcherCache.clear();
} else {
BxLogger.info(LOG_TAG, 'Signature unchanged');
}
@ -682,7 +742,7 @@ class PatcherCache {
return PatcherCache.#CACHE[id];
}
static saveToCache(subCache: { [key: string]: string[] }) {
static saveToCache(subCache: Record<string, PatchArray>) {
for (const id in subCache) {
const patchNames = subCache[id];
@ -703,6 +763,13 @@ class PatcherCache {
}
static init() {
if (PatcherCache.#isInitialized) {
return;
}
PatcherCache.#isInitialized = true;
PatcherCache.checkSignature();
// Read cache from storage
PatcherCache.#CACHE = JSON.parse(window.localStorage.getItem(PatcherCache.#KEY_CACHE) || '{}');
BxLogger.info(LOG_TAG, PatcherCache.#CACHE);
@ -721,11 +788,3 @@ class PatcherCache {
BxLogger.info(LOG_TAG, PLAYING_PATCH_ORDERS.slice(0));
}
}
document.addEventListener('readystatechange', e => {
if (document.readyState === 'interactive') {
PatcherCache.checkSignature();
}
});
PatcherCache.init();

View File

@ -9,7 +9,10 @@ export function takeScreenshot(callback: any) {
return;
}
const $canvasContext = $canvas.getContext('2d')!;
const $canvasContext = $canvas.getContext('2d', {
alpha: false,
willReadFrequently: false,
})!;
$canvasContext.drawImage($video, 0, 0, $canvas.width, $canvas.height);

View File

@ -8,65 +8,6 @@ import { StreamBadges } from "./stream-badges.ts";
import { StreamStats } from "./stream-stats.ts";
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 = window.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: typeof BxIcon) {
const $container = $orgButton.cloneNode(true) as HTMLElement;
let timeout: number | null;
@ -192,25 +133,39 @@ export function injectStreamMenuButtons() {
}
// Render badges
if ($elm.className.startsWith('StreamMenu')) {
if ($elm.className.startsWith('StreamMenu-module__container')) {
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;
}
// Hide Quick bar when closing HUD
$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, () => {
// Create Refresh button from the Close button
const $btnRefresh = $btnCloseHud.cloneNode(true) as HTMLElement;
// Refresh SVG
const $svgRefresh = createSvgIcon(BxIcon.REFRESH);
// Copy classes
$svgRefresh.setAttribute('class', $btnRefresh.firstElementChild!.getAttribute('class') || '');
$svgRefresh.style.fill = 'none';
$btnRefresh.classList.add('bx-stream-refresh-button');
// Remove icon
$btnRefresh.removeChild($btnRefresh.firstElementChild!);
// Add Refresh icon
$btnRefresh.appendChild($svgRefresh);
// Add "click" event listener
$btnRefresh.addEventListener('click', e => {
confirm(t('confirm-reload-stream')) && window.location.reload();
}, 1000);
});
// Add to website
$btnCloseHud.insertAdjacentElement('afterend', $btnRefresh);
// Render stream badges
const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]');

View File

@ -1,5 +1,5 @@
import { STATES } from "@utils/global";
import { CE } from "@utils/html";
import { CE, escapeHtml } from "@utils/html";
import { Toast } from "@utils/toast";
import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS } from "@utils/bx-flags";
@ -103,7 +103,7 @@ export class TouchController {
retries = retries || 1;
if (retries > 2) {
TouchController.#customLayouts[xboxTitleId] = null;
// Wait for BX_EXPOSED.touch_layout_manager
// Wait for BX_EXPOSED.touchLayoutManager
window.setTimeout(() => TouchController.#dispatchLayouts(null), 1000);
return;
}
@ -139,7 +139,7 @@ export class TouchController {
json.layouts = layouts;
TouchController.#customLayouts[xboxTitleId] = json;
// Wait for BX_EXPOSED.touch_layout_manager
// Wait for BX_EXPOSED.touchLayoutManager
window.setTimeout(() => TouchController.#dispatchLayouts(json), 1000);
} catch (e) {
// Retry
@ -148,7 +148,7 @@ export class TouchController {
}
static loadCustomLayout(xboxTitleId: string, layoutId: string, delay: number=0) {
if (!window.BX_EXPOSED.touch_layout_manager) {
if (!window.BX_EXPOSED.touchLayoutManager) {
return;
}
@ -168,39 +168,57 @@ export class TouchController {
}
// Show a toast with layout's name
layoutChanged && Toast.show(t('touch-control-layout'), layout.name);
let msg: string;
let html = false;
if (layout.author) {
const author = `<b>${escapeHtml(layout.author)}</b>`;
msg = t('touch-control-layout-by', {name: author});
html = true;
} else {
msg = t('touch-control-layout');
}
layoutChanged && Toast.show(msg, layout.name, {html: html});
window.setTimeout(() => {
window.BX_EXPOSED.touch_layout_manager.changeLayoutForScope({
window.BX_EXPOSED.touchLayoutManager.changeLayoutForScope({
type: 'showLayout',
scope: xboxTitleId,
subscope: 'base',
layout: {
id: 'System.Standard',
displayName: 'System',
layoutFile: {
content: layout.content,
},
layoutFile: layout,
}
});
}, delay);
}
static updateCustomList() {
NATIVE_FETCH('https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts/ids.json')
.then(response => response.json())
.then(json => {
window.localStorage.setItem('better_xcloud_custom_touch_layouts', JSON.stringify(json));
});
}
static getCustomList(): string[] {
return JSON.parse(window.localStorage.getItem('better_xcloud_custom_touch_layouts') || '[]');
}
static setup() {
// Function for testing touch control
window.BX_EXPOSED.test_touch_control = (content: any) => {
const { touch_layout_manager } = window.BX_EXPOSED;
(window as any).testTouchLayout = (layout: any) => {
const { touchLayoutManager } = window.BX_EXPOSED;
touch_layout_manager && touch_layout_manager.changeLayoutForScope({
touchLayoutManager && touchLayoutManager.changeLayoutForScope({
type: 'showLayout',
scope: '' + STATES.currentStream?.xboxTitleId,
subscope: 'base',
layout: {
id: 'System.Standard',
displayName: 'Custom',
layoutFile: {
content: content,
},
layoutFile: layout,
},
});
};

View File

@ -5,6 +5,7 @@ import { getPreferredServerRegion } from "@utils/region";
import { UserAgent, UserAgentProfile } from "@utils/user-agent";
import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences";
import { t, refreshCurrentLocale } from "@utils/translation";
import { PatcherCache } from "../patcher";
const SETTINGS_UI = {
'Better xCloud': {
@ -57,6 +58,7 @@ const SETTINGS_UI = {
items: [
PrefKey.STREAM_TOUCH_CONTROLLER,
PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF,
PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY,
PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD,
PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM,
],
@ -159,6 +161,9 @@ export function setupSettingsUi() {
$reloadBtnWrapper.classList.remove('bx-gone');
// Clear PatcherCache;
PatcherCache.clear();
if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) {
// Update locale
refreshCurrentLocale();

View File

@ -217,7 +217,14 @@ function setupQuickSettingsBar() {
for (const key in data.layouts) {
const layout = data.layouts[key];
const $option = CE('option', {value: key}, layout.name);
let name;
if (layout.author) {
name = `${layout.name} (${layout.author})`;
} else {
name = layout.name;
}
const $option = CE('option', {value: key}, name);
$fragment.appendChild($option);
}

View File

@ -1,6 +1,8 @@
// Get type of an array's element
type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
type PartialRecord<K extends keyof any, T> = Partial<Record<K, T>>
interface Window {
AppInterface: any;
BX_FLAGS?: BxFlags;

View File

@ -73,9 +73,9 @@ export const BxExposed = {
// Pre-check supported input types
titleInfo.details.hasMkbSupport = supportedInputTypes.includes(InputType.MKB);
titleInfo.details.hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) &&
!supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) &&
!supportedInputTypes.includes(InputType.GENERIC_TOUCH);
titleInfo.details.hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) ||
supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) ||
supportedInputTypes.includes(InputType.GENERIC_TOUCH);
if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === 'all') {
// Add generic touch support for non touch-supported games
@ -91,5 +91,26 @@ export const BxExposed = {
BxEvent.dispatch(window, BxEvent.TITLE_INFO_READY);
return titleInfo;
},
setupGainNode: ($media: HTMLMediaElement, audioStream: MediaStream) => {
if ($media instanceof HTMLAudioElement) {
$media.muted = true;
$media.addEventListener('playing', e => {
$media.muted = true;
$media.pause();
});
} else {
$media.muted = true;
$media.addEventListener('playing', e => {
$media.muted = true;
});
}
const audioCtx = STATES.currentStream.audioContext!;
const source = audioCtx.createMediaStreamSource(audioStream);
const gainNode = audioCtx.createGain(); // call monkey-patched createGain() in BxAudioContext
source.connect(gainNode).connect(audioCtx.destination);
}
};

View File

@ -6,6 +6,7 @@ import iconMouseSettings from "@assets/svg/mouse-settings.svg" with { type: "tex
import iconMouse from "@assets/svg/mouse.svg" with { type: "text" };
import iconNew from "@assets/svg/new.svg" with { type: "text" };
import iconQuestion from "@assets/svg/question.svg" with { type: "text" };
import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" };
import iconRemotePlay from "@assets/svg/remote-play.svg" with { type: "text" };
import iconStreamSettings from "@assets/svg/stream-settings.svg" with { type: "text" };
import iconStreamStats from "@assets/svg/stream-stats.svg" with { type: "text" };
@ -23,6 +24,7 @@ export const BxIcon = {
TRASH: iconTrash,
CURSOR_TEXT: iconCursorText,
QUESTION: iconQuestion,
REFRESH: iconRefresh,
REMOTE_PLAY: iconRemotePlay,

View File

@ -20,7 +20,7 @@ export class BxLogger {
}
static #log(color: TextColor, tag: string, ...args: any) {
console.log('%c' + BxLogger.#PREFIX, 'color:' + color + ';font-weight:bold;', tag, '-', ...args);
console.log(`%c${BxLogger.#PREFIX}`, `color:${color};font-weight:bold;`, tag, '//', ...args);
}
}

View File

@ -0,0 +1,4 @@
export enum GamePassCloudGallery {
TOUCH = '9c86f07a-f3e8-45ad-82a0-a1f759597059',
ALL = '29a81209-df6f-41fd-a528-2ae6b91f719c',
}

View File

@ -96,5 +96,13 @@ export const createButton = <T=HTMLButtonElement>(options: BxButton): T => {
return $btn as T;
}
export function escapeHtml(html: string): string {
const text = document.createTextNode(html);
const $span = document.createElement('span');
$span.appendChild(text);
return $span.innerHTML;
}
export const CTN = document.createTextNode.bind(document);
window.BX_CE = createElement;

View File

@ -1,7 +1,6 @@
import { BxEvent } from "@utils/bx-event";
import { getPref, PrefKey } from "@utils/preferences";
import { STATES } from "@utils/global";
import { UserAgent } from "@utils/user-agent";
import { BxLogger } from "@utils/bx-logger";
export function patchVideoApi() {
@ -104,10 +103,6 @@ export function patchRtcPeerConnection() {
STATES.currentStream.peerConnection = conn;
conn.addEventListener('connectionstatechange', e => {
if (conn.connectionState === 'connecting') {
STATES.currentStream.audioGainNode = null;
}
BxLogger.info('connectionstatechange', conn.connectionState);
});
return conn;
@ -115,46 +110,95 @@ export function patchRtcPeerConnection() {
}
export function patchAudioContext() {
if (UserAgent.isSafari(true)) {
const nativeCreateGain = window.AudioContext.prototype.createGain;
window.AudioContext.prototype.createGain = function() {
const OrgAudioContext = window.AudioContext;
const nativeCreateGain = OrgAudioContext.prototype.createGain;
// @ts-ignore
window.AudioContext = function(options?: AudioContextOptions | undefined): AudioContext {
const ctx = new OrgAudioContext(options);
BxLogger.info('patchAudioContext', ctx, options);
ctx.createGain = function() {
const gainNode = nativeCreateGain.apply(this);
gainNode.gain.value = getPref(PrefKey.AUDIO_VOLUME) / 100;
STATES.currentStream.audioGainNode = gainNode;
return gainNode;
}
}
const OrgAudioContext = window.AudioContext;
// @ts-ignore
window.AudioContext = function() {
const ctx = new OrgAudioContext();
STATES.currentStream.audioContext = ctx;
STATES.currentStream.audioGainNode = null;
return ctx;
}
}
const nativePlay = HTMLAudioElement.prototype.play;
HTMLAudioElement.prototype.play = function() {
this.muted = true;
/**
* Disable telemetry flags in meversion.js
*/
export function patchMeControl() {
const overrideConfigs = {
enableAADTelemetry: false,
enableTelemetry: false,
telEvs: '',
oneDSUrl: '',
};
const promise = nativePlay.apply(this);
if (STATES.currentStream.audioGainNode) {
return promise;
const MSA = {
MeControl: {},
};
const MeControl = {};
const MsaHandler: ProxyHandler<any> = {
get(target, prop, receiver) {
return target[prop];
},
set(obj, prop, value) {
if (prop === 'MeControl' && value.Config) {
value.Config = Object.assign(value.Config, overrideConfigs);
}
obj[prop] = value;
return true;
},
};
const MeControlHandler: ProxyHandler<any> = {
get(target, prop, receiver) {
return target[prop];
},
set(obj, prop, value) {
if (prop === 'Config') {
value = Object.assign(value, overrideConfigs);
}
obj[prop] = value;
return true;
},
};
(window as any).MSA = new Proxy(MSA, MsaHandler);
(window as any).MeControl = new Proxy(MeControl, MeControlHandler);
}
/**
* Use power-saving flags for touch control
*/
export function patchCanvasContext() {
const nativeGetContext = HTMLCanvasElement.prototype.getContext;
// @ts-ignore
HTMLCanvasElement.prototype.getContext = function(contextType: string, contextAttributes?: any) {
if (contextType.includes('webgl')) {
contextAttributes = contextAttributes || {};
contextAttributes.antialias = false;
// Use low-power profile for touch controller
if (contextAttributes.powerPreference === 'high-performance') {
contextAttributes.powerPreference = 'low-power';
}
}
this.addEventListener('playing', e => (e.target as HTMLAudioElement).pause());
const audioCtx = STATES.currentStream.audioContext!;
// TOOD: check srcObject
const audioStream = audioCtx.createMediaStreamSource(this.srcObject as any);
const gainNode = audioCtx.createGain();
audioStream.connect(gainNode);
gainNode.connect(audioCtx.destination);
gainNode.gain.value = getPref(PrefKey.AUDIO_VOLUME) / 100;
STATES.currentStream.audioGainNode = gainNode;
return promise;
return nativeGetContext.apply(this, [contextType, contextAttributes]);
}
}

View File

@ -7,6 +7,7 @@ import { StreamBadges } from "@modules/stream/stream-badges";
import { TouchController } from "@modules/touch-controller";
import { STATES } from "@utils/global";
import { getPreferredServerRegion } from "@utils/region";
import { GamePassCloudGallery } from "./gamepass-gallery";
export const NATIVE_FETCH = window.fetch;
@ -526,6 +527,8 @@ export function interceptHttpRequests() {
return nativeXhrSend.apply(this, arguments);
};
let gamepassAllGames: string[] = [];
(window as any).BX_FETCH = window.fetch = async (request: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
let url = (typeof request === 'string') ? request : (request as Request).url;
@ -549,6 +552,31 @@ export function interceptHttpRequests() {
BxEvent.dispatch(window, BxEvent.STREAM_STARTING);
}
// Add list of games with custom layouts to the official list
if (STATES.hasTouchSupport && url.includes('catalog.gamepass.com/sigls/')) {
const response = await NATIVE_FETCH(request, init);
const obj = await response.clone().json();
if (url.includes(GamePassCloudGallery.ALL)) {
for (let i = 1; i < obj.length; i++) {
gamepassAllGames.push(obj[i].id);
}
} else if (url.includes(GamePassCloudGallery.TOUCH)) {
try {
let customList = TouchController.getCustomList();
// Remove non-cloud games from the list
customList = customList.filter(id => gamepassAllGames.includes(id));
const newCustomList = customList.map(item => ({ id: item }));
obj.push(...newCustomList);
} catch (e) {}
}
response.json = () => Promise.resolve(obj);
return response;
}
let requestType: RequestType;
if (url.includes('/sessions/home') || url.includes('xhome.') || (STATES.remotePlay.isPlaying && url.endsWith('/inputconfigs'))) {
requestType = RequestType.XHOME;

View File

@ -27,6 +27,7 @@ export enum PrefKey {
STREAM_TOUCH_CONTROLLER = 'stream_touch_controller',
STREAM_TOUCH_CONTROLLER_AUTO_OFF = 'stream_touch_controller_auto_off',
STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY = 'stream_touch_controller_default_opacity',
STREAM_TOUCH_CONTROLLER_STYLE_STANDARD = 'stream_touch_controller_style_standard',
STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM = 'stream_touch_controller_style_custom',
@ -277,6 +278,20 @@ export class Preferences {
default: false,
unsupported: !STATES.hasTouchSupport,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: {
type: SettingElementType.NUMBER_STEPPER,
label: t('tc-default-opacity'),
default: 100,
min: 10,
max: 100,
steps: 10,
params: {
suffix: '%',
ticks: 10,
hideSlider: true,
},
unsupported: !STATES.hasTouchSupport,
},
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: {
label: t('tc-standard-layout-style'),
default: 'default',
@ -417,6 +432,7 @@ export class Preferences {
default: 'default',
options: {
default: t('default'),
normal: t('normal'),
tv: t('smart-tv'),
},
},
@ -441,7 +457,9 @@ export class Preferences {
[UserAgentProfile.DEFAULT]: t('default'),
[UserAgentProfile.EDGE_WINDOWS]: 'Edge + Windows',
[UserAgentProfile.SAFARI_MACOS]: 'Safari + macOS',
[UserAgentProfile.SMARTTV]: 'Smart TV',
[UserAgentProfile.SMARTTV_TIZEN]: 'Samsung Smart TV',
[UserAgentProfile.VR_OCULUS]: 'Meta Quest VR',
[UserAgentProfile.KIWI_V123]: 'Kiwi Browser v123',
[UserAgentProfile.CUSTOM]: t('custom'),
},

View File

@ -0,0 +1,55 @@
import { STATES } from "@utils/global";
import { UserAgent } from "@utils/user-agent";
import { BxLogger } from "./bx-logger";
import { TouchController } from "@modules/touch-controller";
import { GamePassCloudGallery } from "./gamepass-gallery";
const LOG_TAG = 'PreloadState';
export function overridePreloadState() {
let _state: any;
Object.defineProperty(window, '__PRELOADED_STATE__', {
configurable: true,
get: () => {
// @ts-ignore
return _state;
},
set: state => {
// Override User-Agent
const userAgent = UserAgent.spoof();
if (userAgent) {
try {
// @ts-ignore
state.appContext.requestInfo.userAgent = userAgent;
} catch (e) {
BxLogger.error(LOG_TAG, e);
}
}
// Add list of games with custom layouts to the official list
if (STATES.hasTouchSupport) {
try {
const sigls = state.xcloud.sigls;
if (GamePassCloudGallery.TOUCH in sigls) {
let customList = TouchController.getCustomList();
const allGames = sigls[GamePassCloudGallery.ALL].data.products;
// Remove non-cloud games from the list
customList = customList.filter(id => allGames.includes(id));
// Add to the official list
sigls[GamePassCloudGallery.TOUCH]?.data.products.push(...customList);
}
} catch (e) {
BxLogger.error(LOG_TAG, e);
}
}
// @ts-ignore
_state = state;
STATES.appContext = structuredClone(state.appContext);
}
});
}

View File

@ -211,10 +211,13 @@ export class SettingElement {
onChange && onChange(e, value);
}
const onMouseDown = (e: MouseEvent | TouchEvent) => {
const onMouseDown = (e: PointerEvent) => {
e.preventDefault();
isHolding = true;
const args = arguments;
interval && clearInterval(interval);
interval = window.setInterval(() => {
const event = new Event('click');
(event as any).arguments = args;
@ -223,11 +226,15 @@ export class SettingElement {
}, 200);
};
const onMouseUp = (e: MouseEvent | TouchEvent) => {
clearInterval(interval);
const onMouseUp = (e: PointerEvent) => {
e.preventDefault();
interval && clearInterval(interval);
isHolding = false;
};
const onContextMenu = (e: Event) => e.preventDefault();
// Custom method
($wrapper as any).setValue = (value: any) => {
$text.textContent = value + options.suffix;
@ -235,16 +242,14 @@ export class SettingElement {
};
$decBtn.addEventListener('click', onClick);
$decBtn.addEventListener('mousedown', onMouseDown);
$decBtn.addEventListener('mouseup', onMouseUp);
$decBtn.addEventListener('touchstart', onMouseDown);
$decBtn.addEventListener('touchend', onMouseUp);
$decBtn.addEventListener('pointerdown', onMouseDown);
$decBtn.addEventListener('pointerup', onMouseUp);
$decBtn.addEventListener('contextmenu', onContextMenu);
$incBtn.addEventListener('click', onClick);
$incBtn.addEventListener('mousedown', onMouseDown);
$incBtn.addEventListener('mouseup', onMouseUp);
$incBtn.addEventListener('touchstart', onMouseDown);
$incBtn.addEventListener('touchend', onMouseUp);
$incBtn.addEventListener('pointerdown', onMouseDown);
$incBtn.addEventListener('pointerup', onMouseUp);
$incBtn.addEventListener('contextmenu', onContextMenu);
return $wrapper;
}

View File

@ -1,24 +0,0 @@
import { STATES } from "@utils/global";
import { UserAgent } from "@utils/user-agent";
export class PreloadedState {
static override() {
Object.defineProperty(window, '__PRELOADED_STATE__', {
configurable: true,
get: () => {
// Override User-Agent
const userAgent = UserAgent.spoof();
if (userAgent) {
(this as any)._state.appContext.requestInfo.userAgent = userAgent;
}
return (this as any)._state;
},
set: state => {
(this as any)._state = state;
STATES.appContext = structuredClone(state.appContext);
}
});
}
}

View File

@ -2,6 +2,7 @@ import { CE } from "@utils/html";
type ToastOptions = {
instant?: boolean;
html?: boolean;
}
export class Toast {
@ -40,9 +41,13 @@ export class Toast {
Toast.#timeout = window.setTimeout(Toast.#hide, Toast.#DURATION);
// Get values from item
const [msg, status, _] = Toast.#stack.shift()!;
const [msg, status, options] = Toast.#stack.shift()!;
Toast.#$msg.textContent = msg;
if (options.html) {
Toast.#$msg.innerHTML = msg;
} else {
Toast.#$msg.textContent = msg;
}
if (status) {
Toast.#$status.classList.remove('bx-gone');

View File

@ -1599,7 +1599,7 @@ const Texts = {
"未找到主机",
],
"normal": [
"Mittel",
"Normal",
"Normal",
"Normal",
"Normal",
@ -2754,6 +2754,23 @@ const Texts = {
"Màu của bố cục tùy chọn",
"特殊游戏按钮样式",
],
"tc-default-opacity": [
"Standard Deckkraft",
,
"Default opacity",
"Opacidad por defecto",
,
,
"既定の透過度",
,
"Domyślna przezroczystość",
"Opacidade padrão",
"Прозрачность по умолчанию",
"Varsayılan opaklık",
"Непрозорість за замовчуванням",
"Độ mờ mặc định",
"默认不透明度",
],
"tc-muted-colors": [
"Matte Farben",
"Warna redup",
@ -2873,6 +2890,23 @@ const Texts = {
"Bố cục điều khiển cảm ứng",
"触摸控制布局",
],
"touch-control-layout-by": [
(e: any) => `Touch-Steuerungslayout von ${e.name}`,
,
(e: any) => `Touch control layout by ${e.name}`,
,
,
,
(e: any) => `タッチ操作レイアウト作成者: ${e.name}`,
,
(e: any) => `Układ sterowania dotykowego stworzony przez ${e.name}`,
,
,
(e: any) => `${e.name} kişisinin dokunmatik kontrolcü tuş şeması`,
(e: any) => `Розташування сенсорного керування від ${e.name}`,
(e: any) => `Bố cục điều khiển cảm ứng tạo bởi ${e.name}`,
,
],
"touch-controller": [
"Touch-Controller",
"Kontrol sentuhan",

View File

@ -3,14 +3,16 @@ import { PrefKey, getPref } from "@utils/preferences";
export enum UserAgentProfile {
EDGE_WINDOWS = 'edge-windows',
SAFARI_MACOS = 'safari-macos',
SMARTTV = 'smarttv',
SMARTTV_TIZEN = 'smarttv-tizen',
VR_OCULUS = 'vr-oculus',
KIWI_V123 = 'kiwi-v123',
DEFAULT = 'default',
CUSTOM = 'custom',
}
let CHROMIUM_VERSION = '123.0.0.0';
if (!!(window as any).chrome) {
if (!!(window as any).chrome || window.navigator.userAgent.includes('Chrome')) {
// Get Chromium version in the original User-Agent value
const match = window.navigator.userAgent.match(/\s(?:Chrome|Edg)\/([\d\.]+)/);
if (match) {
@ -18,15 +20,13 @@ if (!!(window as any).chrome) {
}
}
// Repace Chromium version
let EDGE_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/[[VERSION]] Safari/537.36 Edg/[[VERSION]]';
EDGE_USER_AGENT = EDGE_USER_AGENT.replaceAll('[[VERSION]]', CHROMIUM_VERSION);
export class UserAgent {
static #USER_AGENTS = {
[UserAgentProfile.EDGE_WINDOWS]: EDGE_USER_AGENT,
static #USER_AGENTS: PartialRecord<UserAgentProfile, string> = {
[UserAgentProfile.EDGE_WINDOWS]: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Safari/537.36 Edg/${CHROMIUM_VERSION}`,
[UserAgentProfile.SAFARI_MACOS]: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.1',
[UserAgentProfile.SMARTTV_TIZEN]: 'Mozilla/5.0 (SMART-TV; LINUX; Tizen 7.0) AppleWebKit/537.36 (KHTML, like Gecko) 94.0.4606.31/7.0 TV Safari/537.36',
[UserAgentProfile.SMARTTV]: window.navigator.userAgent + ' SmartTV',
[UserAgentProfile.SMARTTV_TIZEN]: `Mozilla/5.0 (SMART-TV; LINUX; Tizen 7.0) AppleWebKit/537.36 (KHTML, like Gecko) ${CHROMIUM_VERSION}/7.0 TV Safari/537.36`,
[UserAgentProfile.VR_OCULUS]: window.navigator.userAgent + ' OculusBrowser VR',
[UserAgentProfile.KIWI_V123]: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.118 Mobile Safari/537.36',
}