Compare commits

..

17 Commits

Author SHA1 Message Date
redphx
22e7400e06 Bump version to 5.0.0 2024-06-21 18:10:50 +07:00
redphx
f169c17e18 Add WebGL2 renderer 2024-06-21 17:45:43 +07:00
redphx
6150c2ea70 Update better-xcloud.user.js 2024-06-20 20:46:15 +07:00
redphx
2cdf92b159 Update better-xcloud.user.js 2024-06-19 18:17:42 +07:00
redphx
6f6a9e223e Show WS error in toast 2024-06-10 08:57:07 +07:00
redphx
f71904c30b Bump version to 4.7.1 2024-06-10 08:29:53 +07:00
redphx
3a16187504 Update Guide menu detection 2024-06-10 08:03:13 +07:00
redphx
00ebb3f672 Fix dispatching STREAM_PLAYING event when playing normal video 2024-06-09 18:31:57 +07:00
redphx
ebb4d3c141 Add FeatureGates 2024-06-09 18:31:15 +07:00
redphx
902918d7fb Update URLs 2024-06-09 15:56:32 +07:00
redphx
b780e4e63b Minor fix 2024-06-09 15:45:41 +07:00
redphx
32889e0cf1 Minor fix 2024-06-09 15:43:19 +07:00
redphx
d8b9fcc951 Update better-xcloud.user.js 2024-06-09 15:40:15 +07:00
redphx
c7734245ae Don't check update for beta version 2024-06-09 11:50:46 +07:00
redphx
35e7fdacb5 Disable context menu on devices with touch support by default 2024-06-09 11:48:12 +07:00
redphx
504f16b802 Rename "hasTouchSupport" to "userAgentHasTouchSupport" 2024-06-09 11:47:08 +07:00
redphx
a3a7a57b51 Get PointerServer's port from the app 2024-06-09 11:41:00 +07:00
50 changed files with 2037 additions and 814 deletions

View File

@@ -1,6 +1,7 @@
MIT License MIT License
Copyright (c) 2023 redphx Copyright (c) 2023 redphx
Copyright (c) 2023 Advanced Micro Devices, Inc.
Copyright (c) 2020 Phosphor Icons Copyright (c) 2020 Phosphor Icons
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,12 @@
"build": "build.ts" "build": "build.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.3", "@types/bun": "^1.1.5",
"@types/node": "^20.13.0", "@types/node": "^20.14.7",
"@types/stylus": "^0.48.42", "@types/stylus": "^0.48.42",
"stylus": "^0.63.0" "stylus": "^0.63.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.4.5" "typescript": "^5.5.2"
} }
} }

View File

@@ -47,4 +47,10 @@
input[type=range]:disabled, button:disabled { input[type=range]:disabled, button:disabled {
display: none; display: none;
} }
&[data-disabled=true] {
input[type=range], button {
display: none;
}
}
} }

View File

@@ -79,6 +79,11 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
visibility: hidden !important; visibility: hidden !important;
} }
.bx-pixel {
width: 1px !important;
height: 1px !important;
}
.bx-no-margin { .bx-no-margin {
margin: 0 !important; margin: 0 !important;
} }

View File

@@ -113,6 +113,11 @@
background: transparent; background: transparent;
text-align-last: right; text-align-last: right;
border: none; border: none;
color: #fff;
}
select option:disabled {
display: none;
} }
} }

View File

@@ -16,7 +16,6 @@
margin: 0 8px 8px 0; margin: 0 8px 8px 0;
box-shadow: 0px 0px 6px #000; box-shadow: 0px 0px 6px #000;
border-radius: 4px; border-radius: 4px;
height: 30px;
} }
.bx-badge-name { .bx-badge-name {

View File

@@ -66,6 +66,14 @@ div[data-testid=media-container] {
background: #000; background: #000;
} }
#game-stream canvas {
position: absolute;
align-self: center;
margin: auto;
left: 0;
right: 0;
}
#gamepass-dialog-root div[class^=Guide-module__guide] { #gamepass-dialog-root div[class^=Guide-module__guide] {
.bx-button { .bx-button {
overflow: visible; overflow: visible;

View File

@@ -1,5 +1,5 @@
import type { GamepadKeyNameType } from "@/types/mkb"; import type { GamepadKeyNameType } from "@/types/mkb";
import { PrompFont } from "@/utils/prompt-font"; import { PrompFont } from "@enums/prompt-font";
export enum GamepadKey { export enum GamepadKey {
A = 0, A = 0,

View File

@@ -0,0 +1,9 @@
export enum StreamPlayerType {
VIDEO = 'default',
WEBGL2 = 'webgl2',
}
export enum StreamVideoProcessing {
USM = 'usm',
CAS = 'cas',
}

10
src/enums/user-agent.ts Normal file
View File

@@ -0,0 +1,10 @@
export enum UserAgentProfile {
WINDOWS_EDGE = 'windows-edge',
MACOS_SAFARI = 'macos-safari',
SMARTTV_GENERIC = 'smarttv-generic',
SMARTTV_TIZEN = 'smarttv-tizen',
VR_OCULUS = 'vr-oculus',
ANDROID_KIWI_V123 = 'android-kiwi-v123',
DEFAULT = 'default',
CUSTOM = 'custom',
}

View File

@@ -11,7 +11,7 @@ import { StreamBadges } from "@modules/stream/stream-badges";
import { StreamStats } from "@modules/stream/stream-stats"; import { StreamStats } from "@modules/stream/stream-stats";
import { addCss } from "@utils/css"; import { addCss } from "@utils/css";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { setupStreamUi, updateVideoPlayerCss } from "@modules/ui/ui"; import { setupStreamUi, updateVideoPlayer } from "@modules/ui/ui";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { LoadingScreen } from "@modules/loading-screen"; import { LoadingScreen } from "@modules/loading-screen";
import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider"; import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider";
@@ -146,9 +146,6 @@ window.addEventListener(BxEvent.STREAM_STARTING, e => {
}); });
window.addEventListener(BxEvent.STREAM_PLAYING, e => { window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const $video = (e as any).$video as HTMLVideoElement;
STATES.currentStream.$video = $video;
STATES.isPlaying = true; STATES.isPlaying = true;
injectStreamMenuButtons(); injectStreamMenuButtons();
@@ -159,9 +156,10 @@ window.addEventListener(BxEvent.STREAM_PLAYING, e => {
gameBar.showBar(); gameBar.showBar();
} }
const $video = (e as any).$video as HTMLVideoElement;
Screenshot.updateCanvasSize($video.videoWidth, $video.videoHeight); Screenshot.updateCanvasSize($video.videoWidth, $video.videoHeight);
updateVideoPlayerCss(); updateVideoPlayer();
}); });
window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => { window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => {
@@ -177,6 +175,9 @@ function unload() {
EmulatedMkbHandler.getInstance().destroy(); EmulatedMkbHandler.getInstance().destroy();
NativeMkbHandler.getInstance().destroy(); NativeMkbHandler.getInstance().destroy();
// Destroy StreamPlayer
STATES.currentStream.streamPlayer?.destroy();
STATES.isPlaying = false; STATES.isPlaying = false;
STATES.currentStream = {}; STATES.currentStream = {};
window.BX_EXPOSED.shouldShowSensorControls = false; window.BX_EXPOSED.shouldShowSensorControls = false;
@@ -187,8 +188,6 @@ function unload() {
$streamSettingsDialog.classList.add('bx-gone'); $streamSettingsDialog.classList.add('bx-gone');
} }
STATES.currentStream.audioGainNode = null;
STATES.currentStream.$video = null;
StreamStats.getInstance().onStoppedPlaying(); StreamStats.getInstance().onStoppedPlaying();
MouseCursorHider.stop(); MouseCursorHider.stop();
@@ -219,15 +218,18 @@ function observeRootDialog($root: HTMLElement) {
const $addedElm = mutation.addedNodes[0]; const $addedElm = mutation.addedNodes[0];
if ($addedElm instanceof HTMLElement && $addedElm.className) { if ($addedElm instanceof HTMLElement && $addedElm.className) {
if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) { if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) {
// Find navigation bar // Make sure it's Guide dialog
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true'); if (document.querySelector('#gamepass-dialog-root div[class*=GuideDialog]')) {
if ($selectedTab) { // Find navigation bar
let $elm: Element | null = $selectedTab; const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
let index; if ($selectedTab) {
for (index = 0; ($elm = $elm?.previousElementSibling); index++); let $elm: Element | null = $selectedTab;
let index;
for (index = 0; ($elm = $elm?.previousElementSibling); index++);
if (index === 0) { if (index === 0) {
BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME}); BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
}
} }
} }
} }
@@ -277,7 +279,7 @@ function main() {
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext(); getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext();
getPref(PrefKey.BLOCK_TRACKING) && patchMeControl(); getPref(PrefKey.BLOCK_TRACKING) && patchMeControl();
STATES.hasTouchSupport && TouchController.updateCustomList(); STATES.userAgentHasTouchSupport && TouchController.updateCustomList();
overridePreloadState(); overridePreloadState();
VibrationManager.initialSetup(); VibrationManager.initialSetup();
@@ -314,7 +316,10 @@ function main() {
} }
// Start PointerProviderServer // Start PointerProviderServer
(getPref(PrefKey.MKB_ENABLED)) && AppInterface && AppInterface.startPointerServer(); if (getPref(PrefKey.MKB_ENABLED) && AppInterface) {
STATES.pointerServerPort = AppInterface.startPointerServer() || 9269;
BxLogger.info('startPointerServer', 'Port', STATES.pointerServerPort.toString());
}
} }
main(); main();

View File

@@ -1,6 +1,6 @@
import { Screenshot } from "@utils/screenshot"; import { Screenshot } from "@utils/screenshot";
import { GamepadKey } from "./mkb/definitions"; import { GamepadKey } from "@enums/mkb";
import { PrompFont } from "@utils/prompt-font"; import { PrompFont } from "@enums/prompt-font";
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { EmulatedMkbHandler } from "./mkb/mkb-handler"; import { EmulatedMkbHandler } from "./mkb/mkb-handler";

View File

@@ -41,7 +41,7 @@ export class GameBar {
this.actions = [ this.actions = [
new ScreenshotAction(), new ScreenshotAction(),
...(STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []), ...(STATES.userAgentHasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []),
new MicrophoneAction(), new MicrophoneAction(),
]; ];

View File

@@ -1,4 +1,4 @@
import { MouseButtonCode, WheelCode } from "./definitions"; import { MouseButtonCode, WheelCode } from "@enums/mkb";
export class KeyHelper { export class KeyHelper {
static #NON_PRINTABLE_KEYS = { static #NON_PRINTABLE_KEYS = {

View File

@@ -1,5 +1,5 @@
import { MkbPreset } from "./mkb-preset"; import { MkbPreset } from "./mkb-preset";
import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo, WheelCode } from "./definitions"; import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo, WheelCode } from "@enums/mkb";
import { createButton, ButtonStyle, CE } from "@utils/html"; import { createButton, ButtonStyle, CE } from "@utils/html";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
@@ -33,7 +33,7 @@ class WebSocketMouseDataProvider extends MouseDataProvider {
this.#pointerClient = PointerClient.getInstance(); this.#pointerClient = PointerClient.getInstance();
this.#connected = false; this.#connected = false;
try { try {
this.#pointerClient.start(this.mkbHandler); this.#pointerClient.start(STATES.pointerServerPort, this.mkbHandler);
this.#connected = true; this.#connected = true;
} catch (e) { } catch (e) {
Toast.show('Cannot enable Mouse & Keyboard feature'); Toast.show('Cannot enable Mouse & Keyboard feature');

View File

@@ -1,6 +1,6 @@
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { SettingElementType } from "@utils/settings"; import { SettingElementType } from "@utils/settings";
import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "./definitions"; import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "@enums/mkb";
import { EmulatedMkbHandler } from "./mkb-handler"; import { EmulatedMkbHandler } from "./mkb-handler";
import type { MkbPresetData, MkbConvertedPresetData } from "@/types/mkb"; import type { MkbPresetData, MkbConvertedPresetData } from "@/types/mkb";
import type { PreferenceSettings } from "@/types/preferences"; import type { PreferenceSettings } from "@/types/preferences";

View File

@@ -1,9 +1,7 @@
import { GamepadKey } from "./definitions";
import { CE, createButton, ButtonStyle } from "@utils/html"; import { CE, createButton, ButtonStyle } from "@utils/html";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { Dialog } from "@modules/dialog"; import { Dialog } from "@modules/dialog";
import { getPref, setPref, PrefKey } from "@utils/preferences"; import { getPref, setPref, PrefKey } from "@utils/preferences";
import { MkbPresetKey, GamepadKeyName } from "./definitions";
import { KeyHelper } from "./key-helper"; import { KeyHelper } from "./key-helper";
import { MkbPreset } from "./mkb-preset"; import { MkbPreset } from "./mkb-preset";
import { EmulatedMkbHandler } from "./mkb-handler"; import { EmulatedMkbHandler } from "./mkb-handler";
@@ -11,6 +9,7 @@ import { LocalDb } from "@utils/local-db";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { SettingElement } from "@utils/settings"; import { SettingElement } from "@utils/settings";
import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb"; import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb";
import { MkbPresetKey, GamepadKey, GamepadKeyName } from "@enums/mkb";
type MkbRemapperElements = { type MkbRemapperElements = {

View File

@@ -1,6 +1,6 @@
import { Toast } from "@/utils/toast"; import { Toast } from "@/utils/toast";
import { PointerClient } from "./pointer-client"; import { PointerClient } from "./pointer-client";
import { AppInterface } from "@/utils/global"; import { AppInterface, STATES } from "@/utils/global";
import { MkbHandler } from "./base-mkb-handler"; import { MkbHandler } from "./base-mkb-handler";
import { t } from "@/utils/translation"; import { t } from "@/utils/translation";
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
@@ -149,7 +149,7 @@ export class NativeMkbHandler extends MkbHandler {
this.#updateInputConfigurationAsync(false); this.#updateInputConfigurationAsync(false);
try { try {
this.#pointerClient.start(this); this.#pointerClient.start(STATES.pointerServerPort, this);
} catch (e) { } catch (e) {
Toast.show('Cannot enable Mouse & Keyboard feature'); Toast.show('Cannot enable Mouse & Keyboard feature');
} }

View File

@@ -14,8 +14,6 @@ enum PointerAction {
export class PointerClient { export class PointerClient {
static #PORT = 9269;
private static instance: PointerClient; private static instance: PointerClient;
public static getInstance(): PointerClient { public static getInstance(): PointerClient {
if (!PointerClient.instance) { if (!PointerClient.instance) {
@@ -28,11 +26,15 @@ export class PointerClient {
#socket: WebSocket | undefined | null; #socket: WebSocket | undefined | null;
#mkbHandler: MkbHandler | undefined; #mkbHandler: MkbHandler | undefined;
start(mkbHandler: MkbHandler) { start(port: number, mkbHandler: MkbHandler) {
if (!port) {
throw new Error('PointerServer port is 0');
}
this.#mkbHandler = mkbHandler; this.#mkbHandler = mkbHandler;
// Create WebSocket connection. // Create WebSocket connection.
this.#socket = new WebSocket(`ws://localhost:${PointerClient.#PORT}`); this.#socket = new WebSocket(`ws://localhost:${port}`);
this.#socket.binaryType = 'arraybuffer'; this.#socket.binaryType = 'arraybuffer';
// Connection opened // Connection opened
@@ -43,7 +45,7 @@ export class PointerClient {
// Error // Error
this.#socket.addEventListener('error', (event) => { this.#socket.addEventListener('error', (event) => {
BxLogger.error(LOG_TAG, event); BxLogger.error(LOG_TAG, event);
Toast.show('Cannot setup mouse'); Toast.show('Cannot setup mouse: ' + event);
}); });
this.#socket.addEventListener('close', (event) => { this.#socket.addEventListener('close', (event) => {

View File

@@ -7,10 +7,12 @@ import { hashCode, renderString } from "@utils/utils";
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
import codeControllerShortcuts from "./patches/controller-shortcuts.js" with { type: "text" }; import codeControllerShortcuts from "./patches/controller-shortcuts.js" with { type: "text" };
import codeExposeStreamSession from "./patches/expose-stream-session.js" with { type: "text" };
import codeLocalCoOpEnable from "./patches/local-co-op-enable.js" with { type: "text" }; import codeLocalCoOpEnable from "./patches/local-co-op-enable.js" with { type: "text" };
import codeRemotePlayEnable from "./patches/remote-play-enable.js" with { type: "text" }; import codeRemotePlayEnable from "./patches/remote-play-enable.js" with { type: "text" };
import codeRemotePlayKeepAlive from "./patches/remote-play-keep-alive.js" with { type: "text" }; import codeRemotePlayKeepAlive from "./patches/remote-play-keep-alive.js" with { type: "text" };
import codeVibrationAdjust from "./patches/vibration-adjust.js" with { type: "text" }; import codeVibrationAdjust from "./patches/vibration-adjust.js" with { type: "text" };
import { FeatureGates } from "@/utils/feature-gates.js";
type PatchArray = (keyof typeof PATCHES)[]; type PatchArray = (keyof typeof PATCHES)[];
@@ -228,12 +230,10 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
// Find the next "}," // Find the next "},"
const endIndex = str.indexOf('},', index); const endIndex = str.indexOf('},', index);
const newSettings = [ let newSettings = JSON.stringify(FeatureGates);
// 'EnableStreamGate: false', newSettings = newSettings.substring(1, newSettings.length - 1);
'PwaPrompt: false',
];
const newCode = newSettings.join(','); const newCode = newSettings;
str = str.substring(0, endIndex) + ',' + newCode + str.substring(endIndex); str = str.substring(0, endIndex) + ',' + newCode + str.substring(endIndex);
return str; return str;
@@ -566,20 +566,7 @@ window.dispatchEvent(new Event('${BxEvent.STREAM_EVENT_TARGET_READY}'))
} }
const newCode = `; const newCode = `;
window.BX_EXPOSED.streamSession = this; ${codeExposeStreamSession}
const orgSetMicrophoneState = this.setMicrophoneState.bind(this);
this.setMicrophoneState = state => {
orgSetMicrophoneState(state);
const evt = new Event('${BxEvent.MICROPHONE_STATE_CHANGED}');
evt.microphoneState = state;
window.dispatchEvent(evt);
};
window.dispatchEvent(new Event('${BxEvent.STREAM_SESSION_READY}'))
true` + text; true` + text;
str = str.replace(text, newCode); str = str.replace(text, newCode);
@@ -672,7 +659,7 @@ let PATCH_ORDERS: PatchArray = [
'remotePlayKeepAlive', 'remotePlayKeepAlive',
'remotePlayDirectConnectUrl', 'remotePlayDirectConnectUrl',
'remotePlayDisableAchievementToast', 'remotePlayDisableAchievementToast',
STATES.hasTouchSupport && 'patchUpdateInputConfigurationAsync', STATES.userAgentHasTouchSupport && 'patchUpdateInputConfigurationAsync',
] : []), ] : []),
...(BX_FLAGS.EnableXcloudLogging ? [ ...(BX_FLAGS.EnableXcloudLogging ? [
@@ -698,7 +685,7 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
// Skip feedback dialog // Skip feedback dialog
getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog', getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog',
...(STATES.hasTouchSupport ? [ ...(STATES.userAgentHasTouchSupport ? [
getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls',
getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager',
(getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer', (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer',

View File

@@ -0,0 +1,29 @@
window.BX_EXPOSED.streamSession = this;
const orgSetMicrophoneState = this.setMicrophoneState.bind(this);
this.setMicrophoneState = state => {
orgSetMicrophoneState(state);
const evt = new Event(BxEvent.MICROPHONE_STATE_CHANGED);
evt.microphoneState = state;
window.dispatchEvent(evt);
};
window.dispatchEvent(new Event(BxEvent.STREAM_SESSION_READY));
// Patch updateDimensions() to make native touch work correctly with WebGL2
let updateDimensionsStr = this.updateDimensions.toString();
// if(r){
const renderTargetVar = updateDimensionsStr.match(/if\((\w+)\){/)[1];
updateDimensionsStr = updateDimensionsStr.replaceAll(renderTargetVar + '.scroll', 'scroll');
updateDimensionsStr = updateDimensionsStr.replace(`if(${renderTargetVar}){`, `
if (${renderTargetVar}) {
const scrollWidth = ${renderTargetVar}.dataset.width ? parseInt(${renderTargetVar}.dataset.width) : ${renderTargetVar}.scrollWidth;
const scrollHeight = ${renderTargetVar}.dataset.height ? parseInt(${renderTargetVar}.dataset.height) : ${renderTargetVar}.scrollHeight;
`);
eval(`this.updateDimensions = function ${updateDimensionsStr}`);

View File

@@ -0,0 +1,121 @@
const int FILTER_UNSHARP_MASKING = 1;
const int FILTER_CAS = 2;
precision highp float;
uniform sampler2D data;
uniform vec2 iResolution;
uniform int filterId;
uniform float sharpenFactor;
uniform float brightness;
uniform float contrast;
uniform float saturation;
vec3 textureAt(sampler2D tex, vec2 coord) {
return texture2D(tex, coord / iResolution.xy).rgb;
}
vec3 clarityBoost(sampler2D tex, vec2 coord)
{
// Load a collection of samples in a 3x3 neighorhood, where e is the current pixel.
// a b c
// d e f
// g h i
vec3 a = textureAt(tex, coord + vec2(-1, 1));
vec3 b = textureAt(tex, coord + vec2(0, 1));
vec3 c = textureAt(tex, coord + vec2(1, 1));
vec3 d = textureAt(tex, coord + vec2(-1, 0));
vec3 e = textureAt(tex, coord);
vec3 f = textureAt(tex, coord + vec2(1, 0));
vec3 g = textureAt(tex, coord + vec2(-1, -1));
vec3 h = textureAt(tex, coord + vec2(0, -1));
vec3 i = textureAt(tex, coord + vec2(1, -1));
if (filterId == FILTER_CAS) {
// Soft min and max.
// a b c b
// d e f * 0.5 + d e f * 0.5
// g h i h
// These are 2.0x bigger (factored out the extra multiply).
vec3 minRgb = min(min(min(d, e), min(f, b)), h);
vec3 minRgb2 = min(min(a, c), min(g, i));
minRgb += min(minRgb, minRgb2);
vec3 maxRgb = max(max(max(d, e), max(f, b)), h);
vec3 maxRgb2 = max(max(a, c), max(g, i));
maxRgb += max(maxRgb, maxRgb2);
// Smooth minimum distance to signal limit divided by smooth max.
vec3 reciprocalMaxRgb = 1.0 / maxRgb;
vec3 amplifyRgb = clamp(min(minRgb, 2.0 - maxRgb) * reciprocalMaxRgb, 0.0, 1.0);
// Shaping amount of sharpening.
amplifyRgb = inversesqrt(amplifyRgb);
float contrast = 0.8;
float peak = -3.0 * contrast + 8.0;
vec3 weightRgb = -(1.0 / (amplifyRgb * peak));
vec3 reciprocalWeightRgb = 1.0 / (4.0 * weightRgb + 1.0);
// 0 w 0
// Filter shape: w 1 w
// 0 w 0
vec3 window = (b + d) + (f + h);
vec3 outColor = clamp((window * weightRgb + e) * reciprocalWeightRgb, 0.0, 1.0);
outColor = mix(e, outColor, sharpenFactor / 2.0);
return outColor;
} else if (filterId == FILTER_UNSHARP_MASKING) {
vec3 gaussianBlur = (a * 1.0 + b * 2.0 + c * 1.0 +
d * 2.0 + e * 4.0 + f * 2.0 +
g * 1.0 + h * 2.0 + i * 1.0) / 16.0;
// Return edge detection
return e + (e - gaussianBlur) * sharpenFactor / 3.0;
}
return e;
}
vec3 adjustBrightness(vec3 color) {
return (1.0 + brightness) * color;
}
vec3 adjustContrast(vec3 color) {
return 0.5 + (1.0 + contrast) * (color - 0.5);
}
vec3 adjustSaturation(vec3 color) {
const vec3 luminosityFactor = vec3(0.2126, 0.7152, 0.0722);
vec3 grayscale = vec3(dot(color, luminosityFactor));
return mix(grayscale, color, 1.0 + saturation);
}
void main() {
vec3 color;
if (sharpenFactor > 0.0) {
color = clarityBoost(data, gl_FragCoord.xy);
} else {
color = textureAt(data, gl_FragCoord.xy);
}
if (saturation != 0.0) {
color = adjustSaturation(color);
}
if (contrast != 0.0) {
color = adjustContrast(color);
}
if (brightness != 0.0) {
color = adjustBrightness(color);
}
gl_FragColor = vec4(color, 1.0);
}

View File

@@ -0,0 +1,5 @@
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0, 1);
}

View File

@@ -0,0 +1,250 @@
import vertClarityBoost from "./shaders/clarity_boost.vert" with { type: "text" };
import fsClarityBoost from "./shaders/clarity_boost.fs" with { type: "text" };
import { BxLogger } from "@/utils/bx-logger";
const LOG_TAG = 'WebGL2Player';
export class WebGL2Player {
#$video: HTMLVideoElement;
#$canvas: HTMLCanvasElement;
#gl: WebGL2RenderingContext | null = null;
#resources: Array<any> = [];
#program: WebGLProgram | null = null;
#stopped: boolean = false;
#options = {
filterId: 1,
sharpenFactor: 0,
brightness: 0.0,
contrast: 0.0,
saturation: 0.0,
};
#animFrameId: number | null = null;
constructor($video: HTMLVideoElement) {
BxLogger.info(LOG_TAG, 'Initialize');
this.#$video = $video;
const $canvas = document.createElement('canvas');
$canvas.width = $video.videoWidth;
$canvas.height = $video.videoHeight;
this.#$canvas = $canvas;
this.#setupShaders();
this.#setupRendering();
$video.insertAdjacentElement('afterend', $canvas);
}
setFilter(filterId: number, update = true) {
this.#options.filterId = filterId;
update && this.updateCanvas();
}
setSharpness(sharpness: number, update = true) {
this.#options.sharpenFactor = sharpness;
update && this.updateCanvas();
}
setBrightness(brightness: number, update = true) {
this.#options.brightness = (brightness - 100) / 100;
update && this.updateCanvas();
}
setContrast(contrast: number, update = true) {
this.#options.contrast = (contrast - 100) / 100;
update && this.updateCanvas();
}
setSaturation(saturation: number, update = true) {
this.#options.saturation = (saturation - 100) / 100;
update && this.updateCanvas();
}
getCanvas() {
return this.#$canvas;
}
updateCanvas() {
const gl = this.#gl!;
const program = this.#program!;
gl.uniform2f(gl.getUniformLocation(program, 'iResolution'), this.#$canvas.width, this.#$canvas.height);
gl.uniform1i(gl.getUniformLocation(program, 'filterId'), this.#options.filterId);
gl.uniform1f(gl.getUniformLocation(program, 'sharpenFactor'), this.#options.sharpenFactor);
gl.uniform1f(gl.getUniformLocation(program, 'brightness'), this.#options.brightness);
gl.uniform1f(gl.getUniformLocation(program, 'contrast'), this.#options.contrast);
gl.uniform1f(gl.getUniformLocation(program, 'saturation'), this.#options.saturation);
}
drawFrame() {
const gl = this.#gl!;
const $video = this.#$video;
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, $video);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
#setupRendering() {
let animate: any;
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
const $video = this.#$video;
animate = () => {
if (this.#stopped) {
return;
}
this.drawFrame();
this.#animFrameId = $video.requestVideoFrameCallback(animate);
}
this.#animFrameId = $video.requestVideoFrameCallback(animate);
} else {
animate = () => {
if (this.#stopped) {
return;
}
this.drawFrame();
this.#animFrameId = requestAnimationFrame(animate);
}
this.#animFrameId = requestAnimationFrame(animate);
}
}
#setupShaders() {
const gl = this.#$canvas.getContext('webgl2', {
isBx: true,
antialias: true,
alpha: false,
powerPreference: 'high-performance',
}) as WebGL2RenderingContext;
this.#gl = gl;
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferWidth);
// Vertex shader: Identity map
const vShader = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vShader, vertClarityBoost);
gl.compileShader(vShader);
const fShader = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(fShader, fsClarityBoost);
gl.compileShader(fShader);
// Create and link program
const program = gl.createProgram()!;
this.#program = program;
gl.attachShader(program, vShader);
gl.attachShader(program, fShader);
gl.linkProgram(program);
gl.useProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(`Link failed: ${gl.getProgramInfoLog(program)}`);
console.error(`vs info-log: ${gl.getShaderInfoLog(vShader)}`);
console.error(`fs info-log: ${gl.getShaderInfoLog(fShader)}`);
}
this.updateCanvas();
// Vertices: A screen-filling quad made from two triangles
const buffer = gl.createBuffer();
this.#resources.push(buffer);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
// Texture to contain the video data
const texture = gl.createTexture();
this.#resources.push(texture);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
// Bind texture to the "data" argument to the fragment shader
gl.uniform1i(gl.getUniformLocation(program, 'data'), 0);
gl.activeTexture(gl.TEXTURE0);
// gl.bindTexture(gl.TEXTURE_2D, texture);
}
resume() {
this.stop();
this.#stopped = false;
BxLogger.info(LOG_TAG, 'Resume');
this.#$canvas.classList.remove('bx-gone');
this.#setupRendering();
}
stop() {
BxLogger.info(LOG_TAG, 'Stop');
this.#$canvas.classList.add('bx-gone');
this.#stopped = true;
if (this.#animFrameId) {
if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
this.#$video.cancelVideoFrameCallback(this.#animFrameId);
} else {
cancelAnimationFrame(this.#animFrameId);
}
this.#animFrameId = null;
}
}
destroy() {
BxLogger.info(LOG_TAG, 'Destroy');
this.stop();
const gl = this.#gl;
if (gl) {
gl.getExtension('WEBGL_lose_context')?.loseContext();
for (const resource of this.#resources) {
if (resource instanceof WebGLProgram) {
gl.useProgram(null);
gl.deleteProgram(resource);
} else if (resource instanceof WebGLShader) {
gl.deleteShader(resource);
} else if (resource instanceof WebGLTexture) {
gl.deleteTexture(resource);
} else if (resource instanceof WebGLBuffer) {
gl.deleteBuffer(resource);
}
}
this.#gl = null;
}
if (this.#$canvas.isConnected) {
this.#$canvas.parentElement?.removeChild(this.#$canvas);
}
this.#$canvas.width = 1;
this.#$canvas.height = 1;
}
}

View File

@@ -0,0 +1,268 @@
import { CE } from "@/utils/html";
import { WebGL2Player } from "./player/webgl2-player";
import { getPref, PrefKey } from "@/utils/preferences";
import { Screenshot } from "@/utils/screenshot";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { STATES } from "@/utils/global";
export type StreamPlayerOptions = Partial<{
processing: string,
sharpness: number,
saturation: number,
contrast: number,
brightness: number,
}>;
export class StreamPlayer {
#$video: HTMLVideoElement;
#playerType: StreamPlayerType = StreamPlayerType.VIDEO;
#options: StreamPlayerOptions = {};
#webGL2Player: WebGL2Player | null = null;
#$videoCss: HTMLStyleElement | null = null;
#$usmMatrix: SVGFEConvolveMatrixElement | null = null;
constructor($video: HTMLVideoElement, type: StreamPlayerType, options: StreamPlayerOptions) {
this.#setupVideoElements();
this.#$video = $video;
this.#options = options || {};
this.setPlayerType(type);
}
#setupVideoElements() {
this.#$videoCss = document.getElementById('bx-video-css') as HTMLStyleElement;
if (this.#$videoCss) {
this.#$usmMatrix = this.#$videoCss.querySelector('#bx-filter-usm-matrix') as any;
return;
}
const $fragment = document.createDocumentFragment();
this.#$videoCss = CE<HTMLStyleElement>('style', {id: 'bx-video-css'});
$fragment.appendChild(this.#$videoCss);
// 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-usm',
xmlns: 'http://www.w3.org/2000/svg',
}, this.#$usmMatrix = CE('feConvolveMatrix', {
id: 'bx-filter-usm-matrix',
order: '3',
xmlns: 'http://www.w3.org/2000/svg',
})),
),
);
$fragment.appendChild($svg);
document.documentElement.appendChild($fragment);
}
#getVideoPlayerFilterStyle() {
const filters = [];
const sharpness = this.#options.sharpness || 0;
if (sharpness != 0) {
const level = (7 - ((sharpness / 2) - 1) * 0.5).toFixed(1); // 5, 5.5, 6, 6.5, 7
const matrix = `0 -1 0 -1 ${level} -1 0 -1 0`;
this.#$usmMatrix?.setAttributeNS(null, 'kernelMatrix', matrix);
filters.push(`url(#bx-filter-usm)`);
}
const saturation = this.#options.saturation || 100;
if (saturation != 100) {
filters.push(`saturate(${saturation}%)`);
}
const contrast = this.#options.contrast || 100;
if (contrast != 100) {
filters.push(`contrast(${contrast}%)`);
}
const brightness = this.#options.brightness || 100;
if (brightness != 100) {
filters.push(`brightness(${brightness}%)`);
}
return filters.join(' ');
}
#resizePlayer() {
const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO);
const $video = this.#$video;
const isNativeTouchGame = STATES.currentStream.titleInfo?.details.hasNativeTouchSupport;
let $webGL2Canvas;
if (this.#playerType == StreamPlayerType.WEBGL2) {
$webGL2Canvas = this.#webGL2Player?.getCanvas()!;
}
let targetWidth;
let targetHeight;
let targetObjectFit;
if (PREF_RATIO.includes(':')) {
const tmp = PREF_RATIO.split(':');
// Get preferred ratio
const videoRatio = parseFloat(tmp[0]) / parseFloat(tmp[1]);
let width = 0;
let height = 0;
// Get parent's ratio
const parentRect = $video.parentElement!.getBoundingClientRect();
const parentRatio = parentRect.width / parentRect.height;
// Get target width & height
if (parentRatio > videoRatio) {
height = parentRect.height;
width = height * videoRatio;
} else {
width = parentRect.width;
height = width / videoRatio;
}
// Prevent floating points
width = Math.ceil(Math.min(parentRect.width, width));
height = Math.ceil(Math.min(parentRect.height, height));
$video.dataset.width = width.toString();
$video.dataset.height = height.toString();
// Update size
targetWidth = `${width}px`;
targetHeight = `${height}px`;
targetObjectFit = PREF_RATIO === '16:9' ? 'contain' : 'fill';
} else {
targetWidth = '100%';
targetHeight = '100%';
targetObjectFit = PREF_RATIO;
$video.dataset.width = window.innerWidth.toString();
$video.dataset.height = window.innerHeight.toString();
}
$video.style.width = targetWidth;
$video.style.height = targetHeight;
$video.style.objectFit = targetObjectFit;
// $video.style.padding = padding;
if ($webGL2Canvas) {
$webGL2Canvas.style.width = targetWidth;
$webGL2Canvas.style.height = targetHeight;
$webGL2Canvas.style.objectFit = targetObjectFit;
}
// Update video dimensions
if (isNativeTouchGame && this.#playerType == StreamPlayerType.WEBGL2) {
window.BX_EXPOSED.streamSession.updateDimensions();
}
}
setPlayerType(type: StreamPlayerType, refreshPlayer: boolean = false) {
if (this.#playerType !== type) {
// Switch from Video -> WebGL2
if (type === StreamPlayerType.WEBGL2) {
// Initialize WebGL2 player
if (!this.#webGL2Player) {
this.#webGL2Player = new WebGL2Player(this.#$video);
} else {
this.#webGL2Player.resume();
}
this.#$videoCss!.textContent = '';
this.#$video.classList.add('bx-pixel');
} else {
// Cleanup WebGL2 Player
this.#webGL2Player?.stop();
this.#$video.classList.remove('bx-pixel');
}
}
this.#playerType = type;
refreshPlayer && this.refreshPlayer();
}
setOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) {
this.#options = options;
refreshPlayer && this.refreshPlayer();
}
updateOptions(options: StreamPlayerOptions, refreshPlayer: boolean = false) {
this.#options = Object.assign(this.#options, options);
refreshPlayer && this.refreshPlayer();
}
getPlayerElement(playerType?: StreamPlayerType) {
if (typeof playerType === 'undefined') {
playerType = this.#playerType;
}
if (playerType === StreamPlayerType.WEBGL2) {
return this.#webGL2Player?.getCanvas();
}
return this.#$video;
}
getWebGL2Player() {
return this.#webGL2Player;
}
refreshPlayer() {
if (this.#playerType === StreamPlayerType.WEBGL2) {
const options = this.#options;
const webGL2Player = this.#webGL2Player!;
if (options.processing === StreamVideoProcessing.USM) {
webGL2Player.setFilter(1);
} else {
webGL2Player.setFilter(2);
}
Screenshot.updateCanvasFilters('none');
webGL2Player.setSharpness(options.sharpness || 0);
webGL2Player.setSaturation(options.saturation || 100);
webGL2Player.setContrast(options.contrast || 100);
webGL2Player.setBrightness(options.brightness || 100);
} else {
let filters = this.#getVideoPlayerFilterStyle();
let videoCss = '';
if (filters) {
videoCss += `filter: ${filters} !important;`;
}
// Apply video filters to screenshots
if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
Screenshot.updateCanvasFilters(filters);
}
let css = '';
if (videoCss) {
css = `#game-stream video { ${videoCss} }`;
}
this.#$videoCss!.textContent = css;
}
this.#resizePlayer();
}
destroy() {
// Cleanup WebGL2 Player
this.#webGL2Player?.destroy();
this.#webGL2Player = null;
}
}

View File

@@ -161,7 +161,12 @@ export class StreamStats {
const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime; const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime;
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded;
const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000; const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000;
this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`;
if (isNaN(currentDecodeTime)) {
this.#$dt!.textContent = '??ms';
} else {
this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`;
}
if (PREF_STATS_CONDITIONAL_FORMATTING) { if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : ''; grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : '';

View File

@@ -1,11 +1,12 @@
import { STATES, AppInterface, SCRIPT_HOME, SCRIPT_VERSION } from "@utils/global"; import { STATES, AppInterface, SCRIPT_VERSION } from "@utils/global";
import { CE, createButton, ButtonStyle } from "@utils/html"; import { CE, createButton, ButtonStyle } from "@utils/html";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { getPreferredServerRegion } from "@utils/region"; import { getPreferredServerRegion } from "@utils/region";
import { UserAgent, UserAgentProfile } from "@utils/user-agent"; import { UserAgent } from "@utils/user-agent";
import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences"; import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences";
import { t, Translations } from "@utils/translation"; import { t, Translations } from "@utils/translation";
import { PatcherCache } from "../patcher"; import { PatcherCache } from "../patcher";
import { UserAgentProfile } from "@enums/user-agent";
const SETTINGS_UI = { const SETTINGS_UI = {
'Better xCloud': { 'Better xCloud': {
@@ -62,8 +63,8 @@ const SETTINGS_UI = {
}, },
[t('touch-controller')]: { [t('touch-controller')]: {
note: !STATES.hasTouchSupport ? '⚠️ ' + t('device-unsupported-touch') : null, note: !STATES.userAgentHasTouchSupport ? '⚠️ ' + t('device-unsupported-touch') : null,
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
items: [ items: [
PrefKey.STREAM_TOUCH_CONTROLLER, PrefKey.STREAM_TOUCH_CONTROLLER,
PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF, PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF,
@@ -130,7 +131,7 @@ export function setupSettingsUi() {
CE<HTMLElement>('div', {'class': 'bx-settings-title-wrapper'}, CE<HTMLElement>('div', {'class': 'bx-settings-title-wrapper'},
CE('a', { CE('a', {
'class': 'bx-settings-title', 'class': 'bx-settings-title',
'href': SCRIPT_HOME, 'href': 'https://github.com/redphx/better-xcloud/releases',
'target': '_blank', 'target': '_blank',
}, 'Better xCloud ' + SCRIPT_VERSION), }, 'Better xCloud ' + SCRIPT_VERSION),
createButton({ createButton({
@@ -143,14 +144,14 @@ export function setupSettingsUi() {
); );
$updateAvailable = CE('a', { $updateAvailable = CE('a', {
'class': 'bx-settings-update bx-gone', 'class': 'bx-settings-update bx-gone',
'href': 'https://github.com/redphx/better-xcloud/releases', 'href': 'https://github.com/redphx/better-xcloud/releases/latest',
'target': '_blank', 'target': '_blank',
}); });
$wrapper.appendChild($updateAvailable); $wrapper.appendChild($updateAvailable);
// Show new version indicator // Show new version indicator
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) { if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) {
$updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`; $updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`;
$updateAvailable.classList.remove('bx-gone'); $updateAvailable.classList.remove('bx-gone');
} }

View File

@@ -48,7 +48,7 @@ function injectSettingsButton($parent?: HTMLElement) {
}); });
// Show new update status // Show new update status
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) { if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) {
$settingsBtn.setAttribute('data-update-available', 'true'); $settingsBtn.setAttribute('data-update-available', 'true');
} }

View File

@@ -1,10 +1,9 @@
import { AppInterface, STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html"; import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { UserAgent } from "@utils/user-agent";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { MkbRemapper } from "@modules/mkb/mkb-remapper"; import { MkbRemapper } from "@modules/mkb/mkb-remapper";
import { getPref, Preferences, PrefKey, toPrefElement } from "@utils/preferences"; import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences";
import { StreamStats } from "@modules/stream/stream-stats"; import { StreamStats } from "@modules/stream/stream-stats";
import { TouchController } from "@modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
@@ -13,6 +12,9 @@ import { Screenshot } from "@/utils/screenshot";
import { ControllerShortcut } from "../controller-shortcut"; import { ControllerShortcut } from "../controller-shortcut";
import { SoundShortcut } from "../shortcuts/shortcut-sound"; import { SoundShortcut } from "../shortcuts/shortcut-sound";
import { NativeMkbHandler } from "../mkb/native-mkb-handler"; import { NativeMkbHandler } from "../mkb/native-mkb-handler";
import { UserAgent } from "@/utils/user-agent";
import type { StreamPlayerOptions } from "../stream-player";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
export function localRedirect(path: string) { export function localRedirect(path: string) {
@@ -38,40 +40,7 @@ export function localRedirect(path: string) {
$anchor.click(); $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 setupStreamSettingsDialog() { function setupStreamSettingsDialog() {
const isSafari = UserAgent.isSafari();
const SETTINGS_UI = [ const SETTINGS_UI = [
{ {
icon: BxIcon.DISPLAY, icon: BxIcon.DISPLAY,
@@ -109,29 +78,38 @@ function setupStreamSettingsDialog() {
help_url: 'https://better-xcloud.github.io/ingame-features/#video', help_url: 'https://better-xcloud.github.io/ingame-features/#video',
items: [ items: [
{ {
pref: PrefKey.VIDEO_RATIO, pref: PrefKey.VIDEO_PLAYER_TYPE,
onChange: updateVideoPlayerCss, onChange: onChangeVideoPlayerType,
}, },
{ {
pref: PrefKey.VIDEO_CLARITY, pref: PrefKey.VIDEO_RATIO,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
unsupported: isSafari, },
{
pref: PrefKey.VIDEO_PROCESSING,
onChange: updateVideoPlayer,
},
{
pref: PrefKey.VIDEO_SHARPNESS,
onChange: updateVideoPlayer,
}, },
{ {
pref: PrefKey.VIDEO_SATURATION, pref: PrefKey.VIDEO_SATURATION,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
}, },
{ {
pref: PrefKey.VIDEO_CONTRAST, pref: PrefKey.VIDEO_CONTRAST,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
}, },
{ {
pref: PrefKey.VIDEO_BRIGHTNESS, pref: PrefKey.VIDEO_BRIGHTNESS,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
}, },
], ],
}, },
@@ -167,7 +145,7 @@ function setupStreamSettingsDialog() {
], ],
}, },
STATES.hasTouchSupport && { STATES.userAgentHasTouchSupport && {
group: 'touch-controller', group: 'touch-controller',
label: t('touch-controller'), label: t('touch-controller'),
items: [ items: [
@@ -436,94 +414,51 @@ function setupStreamSettingsDialog() {
} }
export function updateVideoPlayerCss() { function onChangeVideoPlayerType() {
let $elm = document.getElementById('bx-video-css'); const playerType = getPref(PrefKey.VIDEO_PLAYER_TYPE);
if (!$elm) { const $videoProcessing = document.getElementById('bx_setting_video_processing') as HTMLSelectElement;
const $fragment = document.createDocumentFragment(); const $videoSharpness = document.getElementById('bx_setting_video_sharpness') as HTMLElement;
$elm = CE<HTMLStyleElement>('style', {id: 'bx-video-css'});
$fragment.appendChild($elm);
// Setup SVG filters let isDisabled = false;
const $svg = CE('svg', {
'id': 'bx-video-filters', if (playerType === StreamPlayerType.WEBGL2) {
'xmlns': 'http://www.w3.org/2000/svg', ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = false;
'class': 'bx-gone', } else {
}, CE('defs', {'xmlns': 'http://www.w3.org/2000/svg'}, // Only allow USM when player type is Video
CE('filter', {'id': 'bx-filter-clarity', 'xmlns': 'http://www.w3.org/2000/svg'}, $videoProcessing.value = StreamVideoProcessing.USM;
CE('feConvolveMatrix', {'id': 'bx-filter-clarity-matrix', 'order': '3', 'xmlns': 'http://www.w3.org/2000/svg'})) setPref(PrefKey.VIDEO_PROCESSING, StreamVideoProcessing.USM);
)
); ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = true;
$fragment.appendChild($svg);
document.documentElement.appendChild($fragment); if (UserAgent.isSafari()) {
isDisabled = true;
}
} }
let filters = getVideoPlayerFilterStyle(); $videoProcessing.disabled = isDisabled;
let videoCss = ''; $videoSharpness.dataset.disabled = isDisabled.toString();
if (filters) {
videoCss += `filter: ${filters} !important;`;
}
// Apply video filters to screenshots updateVideoPlayer();
if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
Screenshot.updateCanvasFilters(filters);
}
let css = '';
if (videoCss) {
css = `
#game-stream video {
${videoCss}
}
`;
}
$elm.textContent = css;
resizeVideoPlayer();
} }
function resizeVideoPlayer() {
const $video = STATES.currentStream.$video; export function updateVideoPlayer() {
if (!$video || !$video.parentElement) { const streamPlayer = STATES.currentStream.streamPlayer;
if (!streamPlayer) {
return; return;
} }
const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO); const options = {
if (PREF_RATIO.includes(':')) { processing: getPref(PrefKey.VIDEO_PROCESSING),
const tmp = PREF_RATIO.split(':'); sharpness: getPref(PrefKey.VIDEO_SHARPNESS),
saturation: getPref(PrefKey.VIDEO_SATURATION),
contrast: getPref(PrefKey.VIDEO_CONTRAST),
brightness: getPref(PrefKey.VIDEO_BRIGHTNESS),
} satisfies StreamPlayerOptions;
// Get preferred ratio streamPlayer.setPlayerType(getPref(PrefKey.VIDEO_PLAYER_TYPE));
const videoRatio = parseFloat(tmp[0]) / parseFloat(tmp[1]); streamPlayer.updateOptions(options);
streamPlayer.refreshPlayer();
let width = 0;
let height = 0;
// Get parent's ratio
const parentRect = $video.parentElement.getBoundingClientRect();
const parentRatio = parentRect.width / parentRect.height;
// Get target width & height
if (parentRatio > videoRatio) {
height = parentRect.height;
width = height * videoRatio;
} else {
width = parentRect.width;
height = width / videoRatio;
}
// Prevent floating points
width = Math.min(parentRect.width, Math.ceil(width));
height = Math.min(parentRect.height, Math.ceil(height));
// Update size
$video.style.width = `${width}px`;
$video.style.height = `${height}px`;
$video.style.objectFit = PREF_RATIO === '16:9' ? 'contain' : 'fill';
} else {
$video.style.width = '100%';
$video.style.height = '100%';
$video.style.objectFit = PREF_RATIO;
}
} }
@@ -545,11 +480,11 @@ export function setupStreamUi() {
if (!document.querySelector('.bx-stream-settings-dialog')) { if (!document.querySelector('.bx-stream-settings-dialog')) {
preloadFonts(); preloadFonts();
window.addEventListener('resize', updateVideoPlayerCss); window.addEventListener('resize', updateVideoPlayer);
setupStreamSettingsDialog(); setupStreamSettingsDialog();
Screenshot.setup(); Screenshot.setup();
} }
updateVideoPlayerCss(); onChangeVideoPlayerType();
} }

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

@@ -28,7 +28,7 @@ type BxStates = {
appContext: any | null; appContext: any | null;
serverRegions: any; serverRegions: any;
hasTouchSupport: boolean; userAgentHasTouchSupport: boolean;
browserHasTouchSupport: boolean; browserHasTouchSupport: boolean;
currentStream: Partial<{ currentStream: Partial<{
@@ -37,7 +37,7 @@ type BxStates = {
productId: string; productId: string;
titleInfo: XcloudTitleInfo; titleInfo: XcloudTitleInfo;
$video: HTMLVideoElement | null; streamPlayer: StreamPlayer | null;
peerConnection: RTCPeerConnection; peerConnection: RTCPeerConnection;
audioContext: AudioContext | null; audioContext: AudioContext | null;
@@ -51,6 +51,8 @@ type BxStates = {
serverId: string; serverId: string;
}; };
}>; }>;
pointerServerPort: number;
} }
type DualEnum = {[index: string]: number} & {[index: number]: string}; type DualEnum = {[index: string]: number} & {[index: number]: string};
@@ -60,6 +62,7 @@ type XcloudTitleInfo = {
productId: string; productId: string;
supportedInputTypes: InputType[]; supportedInputTypes: InputType[];
supportedTabs: any[]; supportedTabs: any[];
hasNativeTouchSupport: boolean;
hasTouchSupport: boolean; hasTouchSupport: boolean;
hasFakeTouchSupport: boolean; hasFakeTouchSupport: boolean;
hasMkbSupport: boolean; hasMkbSupport: boolean;
@@ -76,6 +79,9 @@ declare module '*.js';
declare module '*.svg'; declare module '*.svg';
declare module '*.styl'; declare module '*.styl';
declare module '*.fs';
declare module '*.vert';
type MkbMouseMove = { type MkbMouseMove = {
movementX: number; movementX: number;
movementY: number; movementY: number;

2
src/types/mkb.d.ts vendored
View File

@@ -1,4 +1,4 @@
import { MkbPresetKey } from "@modules/mkb/definitions"; import { MkbPresetKey } from "@enums/mkb";
type GamepadKeyNameType = {[index: string | number]: string[]}; type GamepadKeyNameType = {[index: string | number]: string[]};

View File

@@ -66,8 +66,8 @@ export namespace BxEvent {
} }
} }
AppInterface && AppInterface.onEvent(eventName);
target.dispatchEvent(event); target.dispatchEvent(event);
AppInterface && AppInterface.onEvent(eventName);
} }
} }

View File

@@ -23,7 +23,7 @@ export const BxExposed = {
let supportedInputTypes = titleInfo.details.supportedInputTypes; let supportedInputTypes = titleInfo.details.supportedInputTypes;
if (BX_FLAGS.ForceNativeMkbTitles.includes(titleInfo.details.productId)) { if (BX_FLAGS.ForceNativeMkbTitles?.includes(titleInfo.details.productId)) {
supportedInputTypes.push(InputType.MKB); supportedInputTypes.push(InputType.MKB);
} }
@@ -34,7 +34,7 @@ export const BxExposed = {
titleInfo.details.hasMkbSupport = supportedInputTypes.includes(InputType.MKB); titleInfo.details.hasMkbSupport = supportedInputTypes.includes(InputType.MKB);
if (STATES.hasTouchSupport) { if (STATES.userAgentHasTouchSupport) {
let touchControllerAvailability = getPref(PrefKey.STREAM_TOUCH_CONTROLLER); let touchControllerAvailability = getPref(PrefKey.STREAM_TOUCH_CONTROLLER);
// Disable touch control when gamepad found // Disable touch control when gamepad found
@@ -60,7 +60,8 @@ export const BxExposed = {
} }
// Pre-check supported input types // Pre-check supported input types
titleInfo.details.hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) || titleInfo.details.hasNativeTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH);
titleInfo.details.hasTouchSupport = titleInfo.details.hasNativeTouchSupport ||
supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) || supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) ||
supportedInputTypes.includes(InputType.GENERIC_TOUCH); supportedInputTypes.includes(InputType.GENERIC_TOUCH);

View File

@@ -8,6 +8,7 @@ type BxFlags = Partial<{
UseDevTouchLayout: boolean; UseDevTouchLayout: boolean;
ForceNativeMkbTitles: string[]; ForceNativeMkbTitles: string[];
FeatureGates: {[key: string]: boolean} | null,
}> }>
// Setup flags // Setup flags
@@ -21,9 +22,10 @@ const DEFAULT_FLAGS: BxFlags = {
UseDevTouchLayout: false, UseDevTouchLayout: false,
ForceNativeMkbTitles: [], ForceNativeMkbTitles: [],
FeatureGates: null,
} }
export const BX_FLAGS = Object.assign(DEFAULT_FLAGS, window.BX_FLAGS || {}); export const BX_FLAGS: BxFlags = Object.assign(DEFAULT_FLAGS, window.BX_FLAGS || {});
try { try {
delete window.BX_FLAGS; delete window.BX_FLAGS;
} catch (e) {} } catch (e) {}

View File

@@ -0,0 +1,20 @@
import { BX_FLAGS } from "./bx-flags";
import { getPref, PrefKey } from "./preferences";
export let FeatureGates: {[key: string]: boolean} = {
'PwaPrompt': false,
};
// Disable context menu in Home page
if (getPref(PrefKey.UI_HOME_CONTEXT_MENU_DISABLED)) {
FeatureGates['EnableHomeContextMenu'] = false;
}
// Disable chat feature
if (getPref(PrefKey.BLOCK_SOCIAL_FEATURES)) {
FeatureGates['EnableGuideChatTab'] = false;
}
if (BX_FLAGS.FeatureGates) {
FeatureGates = Object.assign(BX_FLAGS.FeatureGates, FeatureGates);
}

View File

@@ -1,7 +1,6 @@
import { UserAgent } from "./user-agent"; import { UserAgent } from "./user-agent";
export const SCRIPT_VERSION = Bun.env.SCRIPT_VERSION; export const SCRIPT_VERSION = Bun.env.SCRIPT_VERSION!;
export const SCRIPT_HOME = 'https://github.com/redphx/better-xcloud';
export const AppInterface = window.AppInterface; export const AppInterface = window.AppInterface;
@@ -11,15 +10,17 @@ const userAgent = window.navigator.userAgent.toLowerCase();
const isTv = userAgent.includes('smart-tv') || userAgent.includes('smarttv') || /\baft.*\b/.test(userAgent); const isTv = userAgent.includes('smart-tv') || userAgent.includes('smarttv') || /\baft.*\b/.test(userAgent);
const isVr = window.navigator.userAgent.includes('VR') && window.navigator.userAgent.includes('OculusBrowser'); const isVr = window.navigator.userAgent.includes('VR') && window.navigator.userAgent.includes('OculusBrowser');
const browserHasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoints > 0; const browserHasTouchSupport = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const hasTouchSupport = !isTv && !isVr && browserHasTouchSupport; const userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport;
export const STATES: BxStates = { export const STATES: BxStates = {
isPlaying: false, isPlaying: false,
appContext: {}, appContext: {},
serverRegions: {}, serverRegions: {},
hasTouchSupport: hasTouchSupport, userAgentHasTouchSupport: userAgentHasTouchSupport,
browserHasTouchSupport: browserHasTouchSupport, browserHasTouchSupport: browserHasTouchSupport,
currentStream: {}, currentStream: {},
remotePlay: {}, remotePlay: {},
pointerServerPort: 9269,
}; };

View File

@@ -3,6 +3,7 @@ import { getPref, PrefKey } from "@utils/preferences";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { patchSdpBitrate } from "./sdp"; import { patchSdpBitrate } from "./sdp";
import { StreamPlayer, type StreamPlayerOptions } from "@/modules/stream-player";
export function patchVideoApi() { export function patchVideoApi() {
const PREF_SKIP_SPLASH_VIDEO = getPref(PrefKey.SKIP_SPLASH_VIDEO); const PREF_SKIP_SPLASH_VIDEO = getPref(PrefKey.SKIP_SPLASH_VIDEO);
@@ -16,12 +17,22 @@ export function patchVideoApi() {
return; return;
} }
const playerOptions = {
processing: getPref(PrefKey.VIDEO_PROCESSING),
sharpness: getPref(PrefKey.VIDEO_SHARPNESS),
saturation: getPref(PrefKey.VIDEO_SATURATION),
contrast: getPref(PrefKey.VIDEO_CONTRAST),
brightness: getPref(PrefKey.VIDEO_BRIGHTNESS),
} satisfies StreamPlayerOptions;
STATES.currentStream.streamPlayer = new StreamPlayer(this, getPref(PrefKey.VIDEO_PLAYER_TYPE), playerOptions);
BxEvent.dispatch(window, BxEvent.STREAM_PLAYING, { BxEvent.dispatch(window, BxEvent.STREAM_PLAYING, {
$video: this, $video: this,
}); });
} }
const nativePlay = HTMLMediaElement.prototype.play; const nativePlay = HTMLMediaElement.prototype.play;
(HTMLMediaElement.prototype as any).nativePlay = nativePlay;
HTMLMediaElement.prototype.play = function() { HTMLMediaElement.prototype.play = function() {
if (this.className && this.className.startsWith('XboxSplashVideo')) { if (this.className && this.className.startsWith('XboxSplashVideo')) {
if (PREF_SKIP_SPLASH_VIDEO) { if (PREF_SKIP_SPLASH_VIDEO) {
@@ -35,12 +46,12 @@ export function patchVideoApi() {
return nativePlay.apply(this); return nativePlay.apply(this);
} }
if (!!this.src) { const $parent = this.parentElement!!;
return nativePlay.apply(this); // Video tag is stream player
if (!this.src && $parent.dataset.testid === 'media-container') {
this.addEventListener('playing', showFunc);
} }
this.addEventListener('playing', showFunc);
return nativePlay.apply(this); return nativePlay.apply(this);
}; };
} }
@@ -97,21 +108,23 @@ export function patchRtcPeerConnection() {
return dataChannel; return dataChannel;
} }
const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription; const maxVideoBitrate = getPref(PrefKey.BITRATE_VIDEO_MAX);
RTCPeerConnection.prototype.setLocalDescription = function(description?: RTCLocalSessionDescriptionInit): Promise<void> { if (maxVideoBitrate > 0) {
// set maximum bitrate const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription;
try { RTCPeerConnection.prototype.setLocalDescription = function(description?: RTCLocalSessionDescriptionInit): Promise<void> {
const maxVideoBitrate = getPref(PrefKey.BITRATE_VIDEO_MAX); // set maximum bitrate
if (maxVideoBitrate > 0) { try {
arguments[0].sdp = patchSdpBitrate(arguments[0].sdp, Math.round(maxVideoBitrate / 1000)); if (description) {
arguments[0].sdp = patchSdpBitrate(arguments[0].sdp, Math.round(maxVideoBitrate / 1000));
}
} catch (e) {
BxLogger.error('setLocalDescription', e);
} }
} catch (e) {
BxLogger.error('setLocalDescription', e);
}
// @ts-ignore // @ts-ignore
return nativeSetLocalDescription.apply(this, arguments); return nativeSetLocalDescription.apply(this, arguments);
}; };
}
const OrgRTCPeerConnection = window.RTCPeerConnection; const OrgRTCPeerConnection = window.RTCPeerConnection;
// @ts-ignore // @ts-ignore
@@ -132,6 +145,10 @@ export function patchAudioContext() {
// @ts-ignore // @ts-ignore
window.AudioContext = function(options?: AudioContextOptions | undefined): AudioContext { window.AudioContext = function(options?: AudioContextOptions | undefined): AudioContext {
if (options && options.latencyHint) {
options.latencyHint = 0;
}
const ctx = new OrgAudioContext(options); const ctx = new OrgAudioContext(options);
BxLogger.info('patchAudioContext', ctx, options); BxLogger.info('patchAudioContext', ctx, options);
@@ -160,7 +177,12 @@ export function patchMeControl() {
}; };
const MSA = { const MSA = {
MeControl: {}, MeControl: {
API: {
setDisplayMode: () => {},
setMobileState: () => {},
},
},
}; };
const MeControl = {}; const MeControl = {};
@@ -207,12 +229,13 @@ export function patchCanvasContext() {
HTMLCanvasElement.prototype.getContext = function(contextType: string, contextAttributes?: any) { HTMLCanvasElement.prototype.getContext = function(contextType: string, contextAttributes?: any) {
if (contextType.includes('webgl')) { if (contextType.includes('webgl')) {
contextAttributes = contextAttributes || {}; contextAttributes = contextAttributes || {};
if (!contextAttributes.isBx) {
contextAttributes.antialias = false;
contextAttributes.antialias = false; // Use low-power profile for touch controller
if (contextAttributes.powerPreference === 'high-performance') {
// Use low-power profile for touch controller contextAttributes.powerPreference = 'low-power';
if (contextAttributes.powerPreference === 'high-performance') { }
contextAttributes.powerPreference = 'low-power';
} }
} }

View File

@@ -7,8 +7,9 @@ import { StreamBadges } from "@modules/stream/stream-badges";
import { TouchController } from "@modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { getPreferredServerRegion } from "@utils/region"; import { getPreferredServerRegion } from "@utils/region";
import { GamePassCloudGallery } from "./gamepass-gallery"; import { GamePassCloudGallery } from "../enums/game-pass-gallery";
import { InputType } from "./bx-exposed"; import { InputType } from "./bx-exposed";
import { FeatureGates } from "./feature-gates";
enum RequestType { enum RequestType {
XCLOUD = 'xcloud', XCLOUD = 'xcloud',
@@ -440,7 +441,7 @@ class XcloudInterceptor {
let overrideMkb: boolean | null = null; let overrideMkb: boolean | null = null;
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || BX_FLAGS.ForceNativeMkbTitles.includes(STATES.currentStream.titleInfo!.details.productId)) { if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo!.details.productId)) {
overrideMkb = true; overrideMkb = true;
} }
@@ -578,14 +579,8 @@ export function interceptHttpRequests() {
const response = await NATIVE_FETCH(request, init); const response = await NATIVE_FETCH(request, init);
const json = await response.json(); const json = await response.json();
const overrideTreatments: {[key: string]: boolean} = {}; for (const key in FeatureGates) {
json.exp.treatments[key] = FeatureGates[key]
if (getPref(PrefKey.UI_HOME_CONTEXT_MENU_DISABLED)) {
overrideTreatments['EnableHomeContextMenu'] = false;
}
for (const key in overrideTreatments) {
json.exp.treatments[key] = overrideTreatments[key]
} }
response.json = () => Promise.resolve(json); response.json = () => Promise.resolve(json);
@@ -596,7 +591,7 @@ export function interceptHttpRequests() {
} }
// Add list of games with custom layouts to the official list // Add list of games with custom layouts to the official list
if (STATES.hasTouchSupport && url.includes('catalog.gamepass.com/sigls/')) { if (STATES.userAgentHasTouchSupport && url.includes('catalog.gamepass.com/sigls/')) {
const response = await NATIVE_FETCH(request, init); const response = await NATIVE_FETCH(request, init);
const obj = await response.clone().json(); const obj = await response.clone().json();

View File

@@ -1,10 +1,12 @@
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { SUPPORTED_LANGUAGES, t } from "@utils/translation"; import { SUPPORTED_LANGUAGES, t } from "@utils/translation";
import { SettingElement, SettingElementType } from "@utils/settings"; import { SettingElement, SettingElementType } from "@utils/settings";
import { UserAgent, UserAgentProfile } from "@utils/user-agent"; import { UserAgent } from "@utils/user-agent";
import { StreamStat } from "@modules/stream/stream-stats"; import { StreamStat } from "@modules/stream/stream-stats";
import type { PreferenceSetting, PreferenceSettings } from "@/types/preferences"; import type { PreferenceSetting, PreferenceSettings } from "@/types/preferences";
import { AppInterface, STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { UserAgentProfile } from "@/enums/user-agent";
export enum PrefKey { export enum PrefKey {
LAST_UPDATE_CHECK = 'version_last_check', LAST_UPDATE_CHECK = 'version_last_check',
@@ -70,7 +72,9 @@ export enum PrefKey {
UI_HOME_CONTEXT_MENU_DISABLED = 'ui_home_context_menu_disabled', UI_HOME_CONTEXT_MENU_DISABLED = 'ui_home_context_menu_disabled',
VIDEO_CLARITY = 'video_clarity', VIDEO_PLAYER_TYPE = 'video_player_type',
VIDEO_PROCESSING = 'video_processing',
VIDEO_SHARPNESS = 'video_sharpness',
VIDEO_RATIO = 'video_ratio', VIDEO_RATIO = 'video_ratio',
VIDEO_BRIGHTNESS = 'video_brightness', VIDEO_BRIGHTNESS = 'video_brightness',
VIDEO_CONTRAST = 'video_contrast', VIDEO_CONTRAST = 'video_contrast',
@@ -263,7 +267,7 @@ export class Preferences {
all: t('tc-all-games'), all: t('tc-all-games'),
off: t('off'), off: t('off'),
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
ready: (setting: PreferenceSetting) => { ready: (setting: PreferenceSetting) => {
if (setting.unsupported) { if (setting.unsupported) {
setting.default = 'default'; setting.default = 'default';
@@ -273,7 +277,7 @@ export class Preferences {
[PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: { [PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: {
label: t('tc-auto-off'), label: t('tc-auto-off'),
default: false, default: false,
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: { [PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: {
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
@@ -287,7 +291,7 @@ export class Preferences {
ticks: 10, ticks: 10,
hideSlider: true, hideSlider: true,
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: { [PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: {
label: t('tc-standard-layout-style'), label: t('tc-standard-layout-style'),
@@ -297,7 +301,7 @@ export class Preferences {
white: t('tc-all-white'), white: t('tc-all-white'),
muted: t('tc-muted-colors'), muted: t('tc-muted-colors'),
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: { [PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: {
label: t('tc-custom-layout-style'), label: t('tc-custom-layout-style'),
@@ -306,7 +310,7 @@ export class Preferences {
default: t('default'), default: t('default'),
muted: t('tc-muted-colors'), muted: t('tc-muted-colors'),
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_SIMPLIFY_MENU]: { [PrefKey.STREAM_SIMPLIFY_MENU]: {
@@ -544,7 +548,7 @@ export class Preferences {
[PrefKey.UI_HOME_CONTEXT_MENU_DISABLED]: { [PrefKey.UI_HOME_CONTEXT_MENU_DISABLED]: {
label: t('disable-home-context-menu'), label: t('disable-home-context-menu'),
default: false, default: STATES.browserHasTouchSupport,
}, },
[PrefKey.BLOCK_SOCIAL_FEATURES]: { [PrefKey.BLOCK_SOCIAL_FEATURES]: {
@@ -570,19 +574,35 @@ export class Preferences {
[UserAgentProfile.CUSTOM]: t('custom'), [UserAgentProfile.CUSTOM]: t('custom'),
}, },
}, },
[PrefKey.VIDEO_CLARITY]: { [PrefKey.VIDEO_PLAYER_TYPE]: {
label: t('clarity'), label: t('renderer'),
default: 'default',
options: {
[StreamPlayerType.VIDEO]: t('default'),
[StreamPlayerType.WEBGL2]: t('webgl2'),
},
},
[PrefKey.VIDEO_PROCESSING]: {
label: t('clarity-boost'),
default: StreamVideoProcessing.USM,
options: {
[StreamVideoProcessing.USM]: t('unsharp-masking'),
[StreamVideoProcessing.CAS]: t('amd-fidelity-cas'),
},
},
[PrefKey.VIDEO_SHARPNESS]: {
label: t('sharpness'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 0, default: 0,
min: 0, min: 0,
max: 5, max: 10,
params: { params: {
hideSlider: true, hideSlider: true,
}, },
}, },
[PrefKey.VIDEO_RATIO]: { [PrefKey.VIDEO_RATIO]: {
label: t('ratio'), label: t('aspect-ratio'),
note: t('stretch-note'), note: t('aspect-ratio-note'),
default: '16:9', default: '16:9',
options: { options: {
'16:9': '16:9', '16:9': '16:9',

View File

@@ -1,7 +1,7 @@
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { BxLogger } from "./bx-logger"; import { BxLogger } from "./bx-logger";
import { TouchController } from "@modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { GamePassCloudGallery } from "./gamepass-gallery"; import { GamePassCloudGallery } from "../enums/game-pass-gallery";
import { getPref, PrefKey } from "./preferences"; import { getPref, PrefKey } from "./preferences";
import { BX_FLAGS } from "./bx-flags"; import { BX_FLAGS } from "./bx-flags";
@@ -24,7 +24,7 @@ export function overridePreloadState() {
} }
// Add list of games with custom layouts to the official list // Add list of games with custom layouts to the official list
if (STATES.hasTouchSupport) { if (STATES.userAgentHasTouchSupport) {
try { try {
const sigls = state.xcloud.sigls; const sigls = state.xcloud.sigls;
if (GamePassCloudGallery.TOUCH in sigls) { if (GamePassCloudGallery.TOUCH in sigls) {

View File

@@ -1,5 +1,7 @@
import { StreamPlayerType } from "@enums/stream-player";
import { AppInterface, STATES } from "./global"; import { AppInterface, STATES } from "./global";
import { CE } from "./html"; import { CE } from "./html";
import { getPref, PrefKey } from "./preferences";
export class Screenshot { export class Screenshot {
@@ -31,23 +33,39 @@ export class Screenshot {
Screenshot.#canvasContext.filter = filters; Screenshot.#canvasContext.filter = filters;
} }
private static onAnimationEnd(e: Event) { static #onAnimationEnd(e: Event) {
(e.target as any).classList.remove('bx-taking-screenshot'); const $target = e.target as HTMLElement;
$target.classList.remove('bx-taking-screenshot');
} }
static takeScreenshot(callback?: any) { static takeScreenshot(callback?: any) {
const currentStream = STATES.currentStream; const currentStream = STATES.currentStream;
const $video = currentStream.$video; const streamPlayer = currentStream.streamPlayer;
const $canvas = Screenshot.#$canvas; const $canvas = Screenshot.#$canvas;
if (!$video || !$canvas) { if (!streamPlayer || !$canvas) {
return; return;
} }
$video.parentElement?.addEventListener('animationend', this.onAnimationEnd); let $player;
$video.parentElement?.classList.add('bx-taking-screenshot'); if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
$player = streamPlayer.getPlayerElement();
} else {
$player = streamPlayer.getPlayerElement(StreamPlayerType.VIDEO);
}
if (!$player || !$player.isConnected) {
return;
}
$player.parentElement!.addEventListener('animationend', this.#onAnimationEnd);
$player.parentElement!.classList.add('bx-taking-screenshot');
const canvasContext = Screenshot.#canvasContext; const canvasContext = Screenshot.#canvasContext;
canvasContext.drawImage($video, 0, 0, $canvas.width, $canvas.height);
if ($player instanceof HTMLCanvasElement) {
streamPlayer.getWebGL2Player().drawFrame();
}
canvasContext.drawImage($player, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app // Get data URL and pass to parent app
if (AppInterface) { if (AppInterface) {

View File

@@ -155,7 +155,7 @@ export class SettingElement {
return textContent; return textContent;
}; };
const $wrapper = CE('div', {'class': 'bx-number-stepper'}, const $wrapper = CE('div', {'class': 'bx-number-stepper', id: `bx_setting_${key}`},
$decBtn = CE('button', { $decBtn = CE('button', {
'data-type': 'dec', 'data-type': 'dec',
type: 'button', type: 'button',

View File

@@ -1,4 +1,5 @@
import { NATIVE_FETCH } from "./bx-flags"; import { NATIVE_FETCH } from "./bx-flags";
import { BxLogger } from "./bx-logger";
export const SUPPORTED_LANGUAGES = { export const SUPPORTED_LANGUAGES = {
'en-US': 'English (United States)', 'en-US': 'English (United States)',
@@ -26,8 +27,11 @@ const Texts = {
"activated": "Activated", "activated": "Activated",
"active": "Active", "active": "Active",
"advanced": "Advanced", "advanced": "Advanced",
"amd-fidelity-cas": "AMD FidelityFX CAS",
"android-app-settings": "Android app settings", "android-app-settings": "Android app settings",
"apply": "Apply", "apply": "Apply",
"aspect-ratio": "Aspect ratio",
"aspect-ratio-note": "Don't use with native touch games",
"audio": "Audio", "audio": "Audio",
"auto": "Auto", "auto": "Auto",
"back-to-home": "Back to home", "back-to-home": "Back to home",
@@ -48,7 +52,7 @@ const Texts = {
"can-stream-xbox-360-games": "Can stream Xbox 360 games", "can-stream-xbox-360-games": "Can stream Xbox 360 games",
"cancel": "Cancel", "cancel": "Cancel",
"cant-stream-xbox-360-games": "Can't stream Xbox 360 games", "cant-stream-xbox-360-games": "Can't stream Xbox 360 games",
"clarity": "Clarity", "clarity-boost": "Clarity boost",
"clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON", "clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON",
"clear": "Clear", "clear": "Clear",
"close": "Close", "close": "Close",
@@ -152,14 +156,14 @@ const Texts = {
, ,
(e: any) => `${e.key}: Funktion an-/ausschalten`, (e: any) => `${e.key}: Funktion an-/ausschalten`,
, ,
, (e: any) => `Pulsa ${e.key} para alternar esta función`,
(e: any) => `Appuyez sur ${e.key} pour activer cette fonctionnalité`, (e: any) => `Appuyez sur ${e.key} pour activer cette fonctionnalité`,
(e: any) => `Premi ${e.key} per attivare questa funzionalità`, (e: any) => `Premi ${e.key} per attivare questa funzionalità`,
(e: any) => `${e.key} でこの機能を切替`, (e: any) => `${e.key} でこの機能を切替`,
(e: any) => `${e.key} 키를 눌러 이 기능을 켜고 끄세요`,
(e: any) => `Naciśnij ${e.key} aby przełączyć tę funkcję`,
, ,
, (e: any) => `Нажмите ${e.key} для переключения этой функции`,
,
,
, ,
(e: any) => `Etkinleştirmek için ${e.key} tuşuna basın`, (e: any) => `Etkinleştirmek için ${e.key} tuşuna basın`,
(e: any) => `Натисніть ${e.key} щоб перемкнути цю функцію`, (e: any) => `Натисніть ${e.key} щоб перемкнути цю функцію`,
@@ -168,12 +172,12 @@ const Texts = {
], ],
"press-to-bind": "Press a key or do a mouse click to bind...", "press-to-bind": "Press a key or do a mouse click to bind...",
"prompt-preset-name": "Preset's name:", "prompt-preset-name": "Preset's name:",
"ratio": "Ratio",
"reduce-animations": "Reduce UI animations", "reduce-animations": "Reduce UI animations",
"region": "Region", "region": "Region",
"reload-stream": "Reload stream", "reload-stream": "Reload stream",
"remote-play": "Remote Play", "remote-play": "Remote Play",
"rename": "Rename", "rename": "Rename",
"renderer": "Renderer",
"right-click-to-unbind": "Right-click on a key to unbind it", "right-click-to-unbind": "Right-click on a key to unbind it",
"right-stick": "Right stick", "right-stick": "Right stick",
"rocket-always-hide": "Always hide", "rocket-always-hide": "Always hide",
@@ -191,6 +195,7 @@ const Texts = {
"settings": "Settings", "settings": "Settings",
"settings-reload": "Reload page to reflect changes", "settings-reload": "Reload page to reflect changes",
"settings-reloading": "Reloading...", "settings-reloading": "Reloading...",
"sharpness": "Sharpness",
"shortcut-keys": "Shortcut keys", "shortcut-keys": "Shortcut keys",
"show": "Show", "show": "Show",
"show-game-art": "Show game art", "show-game-art": "Show game art",
@@ -218,7 +223,6 @@ const Texts = {
"stream-settings": "Stream settings", "stream-settings": "Stream settings",
"stream-stats": "Stream stats", "stream-stats": "Stream stats",
"stretch": "Stretch", "stretch": "Stretch",
"stretch-note": "Don't use with native touch games",
"support-better-xcloud": "Support Better xCloud", "support-better-xcloud": "Support Better xCloud",
"swap-buttons": "Swap buttons", "swap-buttons": "Swap buttons",
"take-screenshot": "Take screenshot", "take-screenshot": "Take screenshot",
@@ -263,6 +267,7 @@ const Texts = {
"unknown": "Unknown", "unknown": "Unknown",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"unmuted": "Unmuted", "unmuted": "Unmuted",
"unsharp-masking": "Unsharp masking",
"use-mouse-absolute-position": "Use mouse's absolute position", "use-mouse-absolute-position": "Use mouse's absolute position",
"user-agent-profile": "User-Agent profile", "user-agent-profile": "User-Agent profile",
"vertical-scroll-sensitivity": "Vertical scroll sensitivity", "vertical-scroll-sensitivity": "Vertical scroll sensitivity",
@@ -278,6 +283,7 @@ const Texts = {
"volume": "Volume", "volume": "Volume",
"wait-time-countdown": "Countdown", "wait-time-countdown": "Countdown",
"wait-time-estimated": "Estimated finish time", "wait-time-estimated": "Estimated finish time",
"webgl2": "WebGL2",
}; };
export class Translations { export class Translations {
@@ -390,5 +396,9 @@ export class Translations {
} }
export const t = Translations.get; export const t = Translations.get;
export const ut = (text: string): string => {
BxLogger.warning('Untranslated text', text);
return text;
}
Translations.init(); Translations.init();

View File

@@ -1,19 +1,10 @@
import { UserAgentProfile } from "@enums/user-agent";
type UserAgentConfig = { type UserAgentConfig = {
profile: UserAgentProfile, profile: UserAgentProfile,
custom?: string, custom?: string,
}; };
export enum UserAgentProfile {
WINDOWS_EDGE = 'windows-edge',
MACOS_SAFARI = 'macos-safari',
SMARTTV_GENERIC = 'smarttv-generic',
SMARTTV_TIZEN = 'smarttv-tizen',
VR_OCULUS = 'vr-oculus',
ANDROID_KIWI_V123 = 'android-kiwi-v123',
DEFAULT = 'default',
CUSTOM = 'custom',
}
let CHROMIUM_VERSION = '123.0.0.0'; let CHROMIUM_VERSION = '123.0.0.0';
if (!!(window as any).chrome || window.navigator.userAgent.includes('Chrome')) { if (!!(window as any).chrome || window.navigator.userAgent.includes('Chrome')) {
// Get Chromium version in the original User-Agent value // Get Chromium version in the original User-Agent value
@@ -27,6 +18,10 @@ export class UserAgent {
static readonly STORAGE_KEY = 'better_xcloud_user_agent'; static readonly STORAGE_KEY = 'better_xcloud_user_agent';
static #config: UserAgentConfig; static #config: UserAgentConfig;
static #isMobile: boolean | null = null;
static #isSafari: boolean | null = null;
static #isSafariMobile: boolean | null = null;
static #USER_AGENTS: PartialRecord<UserAgentProfile, string> = { static #USER_AGENTS: PartialRecord<UserAgentProfile, string> = {
[UserAgentProfile.WINDOWS_EDGE]: `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.WINDOWS_EDGE]: `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.MACOS_SAFARI]: '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.MACOS_SAFARI]: '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',
@@ -79,20 +74,40 @@ export class UserAgent {
} }
} }
static isSafari(mobile=false): boolean { static isSafari(): boolean {
if (this.#isSafari !== null) {
return this.#isSafari;
}
const userAgent = UserAgent.getDefault().toLowerCase(); const userAgent = UserAgent.getDefault().toLowerCase();
let result = userAgent.includes('safari') && !userAgent.includes('chrom'); let result = userAgent.includes('safari') && !userAgent.includes('chrom');
if (result && mobile) { this.#isSafari = result;
result = userAgent.includes('mobile'); return result;
}
static isSafariMobile(): boolean {
if (this.#isSafariMobile !== null) {
return this.#isSafariMobile;
} }
const userAgent = UserAgent.getDefault().toLowerCase();
const result = this.isSafari() && userAgent.includes('mobile');
this.#isSafariMobile = result;
return result; return result;
} }
static isMobile(): boolean { static isMobile(): boolean {
if (this.#isMobile !== null) {
return this.#isMobile;
}
const userAgent = UserAgent.getDefault().toLowerCase(); const userAgent = UserAgent.getDefault().toLowerCase();
return /iphone|ipad|android/.test(userAgent); const result = /iphone|ipad|android/.test(userAgent);
this.#isMobile = result;
return result;
} }
static spoof() { static spoof() {

View File

@@ -7,6 +7,11 @@ import { Translations } from "./translation";
* Check for update * Check for update
*/ */
export function checkForUpdate() { export function checkForUpdate() {
// Don't check update for beta version
if (SCRIPT_VERSION.includes('beta')) {
return;
}
const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours
const currentVersion = getPref(PrefKey.CURRENT_VERSION); const currentVersion = getPref(PrefKey.CURRENT_VERSION);
@@ -42,7 +47,7 @@ export function disablePwa() {
} }
// Check if it's Safari on mobile // Check if it's Safari on mobile
if (!!AppInterface || UserAgent.isSafari(true)) { if (!!AppInterface || UserAgent.isSafariMobile()) {
// Disable the PWA prompt // Disable the PWA prompt
Object.defineProperty(window.navigator, 'standalone', { Object.defineProperty(window.navigator, 'standalone', {
value: true, value: true,

View File

@@ -11,6 +11,7 @@
"paths": { "paths": {
"@/*": ["./*"], "@/*": ["./*"],
"@assets/*": ["./assets/*"], "@assets/*": ["./assets/*"],
"@enums/*": ["./enums/*"],
"@macros/*": ["./macros/*"], "@macros/*": ["./macros/*"],
"@modules/*": ["./modules/*"], "@modules/*": ["./modules/*"],
"@utils/*": ["./utils/*"], "@utils/*": ["./utils/*"],