Compare commits

...

82 Commits

Author SHA1 Message Date
422442071e Bump version to 5.1.3 2024-07-09 18:31:18 +07:00
8d1ae0656c Update better-xcloud.user.js 2024-07-09 07:53:46 +07:00
a78de2ca37 Hide Stream settings when the Guide menu is shown (#441) 2024-07-09 07:53:36 +07:00
db1da22c0a Remove SMART_TV_UNIQUE_ID from SMART_TV_GENERIC profile 2024-07-09 07:48:05 +07:00
91ab57fa29 Remove website's version detection 2024-07-09 07:46:42 +07:00
416307e23a Fix "alwaysShowStreamHud" not working on non-TV devices 2024-07-08 20:05:20 +07:00
e7c94f3ece Update better-xcloud.user.js 2024-07-08 18:06:23 +07:00
ea9ad16770 Don't show negative packetLost 2024-07-08 18:02:07 +07:00
9a2e7de68d Update better-xcloud.user.js 2024-07-08 17:55:01 +07:00
962f4dec6d Minor update 2024-07-08 17:45:38 +07:00
10d0dedc0a Add "alwaysShowStreamHud" patch 2024-07-08 17:17:28 +07:00
c6acc251ae Update bun 2024-07-08 08:06:54 +07:00
a06d061409 Update better-xcloud.user.js 2024-07-08 07:32:33 +07:00
6b2412ff27 Disable Onboarding screen 2024-07-08 07:32:16 +07:00
0f360d4be1 Bump version to 5.1.2 2024-07-07 21:37:15 +07:00
900ab38153 Minor fixes 2024-07-07 19:20:58 +07:00
c03c63f3c3 Update better-xcloud.user.js 2024-07-07 18:23:37 +07:00
d4f4084991 Disable "Most popular" option 2024-07-07 18:22:41 +07:00
975549b4e7 Add option to hide "All games" section 2024-07-07 18:16:50 +07:00
345d0f78dc Add option to hide "News" section 2024-07-07 16:55:57 +07:00
938dfa6aaa Add option to hide "Friends" section 2024-07-07 16:43:56 +07:00
d7ed9e1603 Add "ignorePlayWithFriendsSection" patch 2024-07-07 16:14:08 +07:00
224e98829d Improve "enableXcloudLogger" patch 2024-07-07 15:28:33 +07:00
56a3f1d8c8 Bug fixes 2024-07-07 14:59:12 +07:00
d82a38c0f1 Update better-xcloud.user.js 2024-07-07 11:21:22 +07:00
77729789e3 Fix problems in the Guide menu #436 #438 2024-07-07 11:20:43 +07:00
5763701355 Update better-xcloud.user.js 2024-07-06 20:58:08 +07:00
cafeed1a3c Bug fixes 2024-07-06 20:48:27 +07:00
691f116ea0 Add "enableTvRoutes" patch 2024-07-06 17:14:02 +07:00
481b365e6e Add "IsSupportedTvBrowser" flag 2024-07-06 16:13:53 +07:00
2b63edb7eb Refactor browser & userAgent's capabilities 2024-07-06 15:53:01 +07:00
b6746598a3 Update better-xcloud.user.js 2024-07-13 12:26:53 +07:00
45bda4bb24 Fix script not being loaded after refreshing token 2024-07-13 12:19:07 +07:00
c93db035f3 Update ICE candidates 2024-07-06 11:08:41 +07:00
e75fa397ee Bump version to 5.1.1 2024-07-02 18:11:08 +07:00
98a9f4fc37 Update better-xcloud.user.js 2024-07-02 18:10:52 +07:00
dee8c9dbd0 Refactor buttons in guide-menu 2024-07-02 18:06:33 +07:00
d31a06be89 Use {once: true} in some event listeners 2024-07-02 17:20:23 +07:00
277c777121 Add "Show controller connection status" setting 2024-07-02 17:08:40 +07:00
385fd71e86 Update better-xcloud.user.js 2024-07-02 06:49:40 +07:00
986d9fe088 Show "Stream settings" and "App settings" in the Guide menu 2024-07-02 06:41:23 +07:00
6de235ce2f Fix overriding experimentation stopped working 2024-07-02 05:50:02 +07:00
f027565534 Bump version to 5.1.0 2024-07-01 18:08:46 +07:00
0213b860fd No longer need "Kiwi Browser v123" profile 2024-07-01 17:52:12 +07:00
13feb36aae Update better-xcloud.user.js 2024-07-01 17:44:49 +07:00
d83261d816 Dim Stream settings' overlay when not playing 2024-07-01 17:42:42 +07:00
c1502b5552 Prepare for webOS & Tizen support 2024-07-01 17:26:04 +07:00
64d60aedfa Create bun.lockb 2024-07-01 17:23:13 +07:00
889a97e56b Stop using setCodecPreferences() as it causes stuttering on Chromium 124+ 2024-07-01 17:22:24 +07:00
7aee4d5148 Compress CSS 2024-07-01 17:20:39 +07:00
2000d6d80e Update translations 2024-06-26 21:00:38 +07:00
297c0848d5 Bump version to 5.0.1 2024-06-26 18:14:57 +07:00
51ef9f9e8f Update package.json 2024-06-26 18:14:29 +07:00
9717315b79 Update README.md 2024-06-26 08:44:16 +07:00
e176ef6fc0 Update package.json 2024-06-23 17:59:24 +07:00
52694d8f8e Update better-xcloud.user.js 2024-06-23 17:30:00 +07:00
b7928ebe68 Fix stream badge showing "1h60m" instead of "2h" 2024-06-23 17:27:11 +07:00
05eddce11e Update better-xcloud.user.js 2024-06-22 16:43:22 +07:00
057da5b3ea Fix exception with navigator.vibrate() on start up 2024-06-22 16:43:18 +07:00
11ef014c74 Update build.ts 2024-06-22 16:30:22 +07:00
fa82f0ba95 Update better-xcloud.user.js 2024-06-22 16:30:02 +07:00
36db8db1e7 Minify syntax in dist file 2024-06-22 16:29:54 +07:00
d906de7803 Update better-xcloud.user.js 2024-06-22 10:36:27 +07:00
cf546123db Show "Off" when Sharpness is 0 2024-06-22 10:36:02 +07:00
d6a4d1741b Update NumberStepper 2024-06-22 10:35:45 +07:00
22e7400e06 Bump version to 5.0.0 2024-06-21 18:10:50 +07:00
f169c17e18 Add WebGL2 renderer 2024-06-21 17:45:43 +07:00
6150c2ea70 Update better-xcloud.user.js 2024-06-20 20:46:15 +07:00
2cdf92b159 Update better-xcloud.user.js 2024-06-19 18:17:42 +07:00
6f6a9e223e Show WS error in toast 2024-06-10 08:57:07 +07:00
f71904c30b Bump version to 4.7.1 2024-06-10 08:29:53 +07:00
3a16187504 Update Guide menu detection 2024-06-10 08:03:13 +07:00
00ebb3f672 Fix dispatching STREAM_PLAYING event when playing normal video 2024-06-09 18:31:57 +07:00
ebb4d3c141 Add FeatureGates 2024-06-09 18:31:15 +07:00
902918d7fb Update URLs 2024-06-09 15:56:32 +07:00
b780e4e63b Minor fix 2024-06-09 15:45:41 +07:00
32889e0cf1 Minor fix 2024-06-09 15:43:19 +07:00
d8b9fcc951 Update better-xcloud.user.js 2024-06-09 15:40:15 +07:00
c7734245ae Don't check update for beta version 2024-06-09 11:50:46 +07:00
35e7fdacb5 Disable context menu on devices with touch support by default 2024-06-09 11:48:12 +07:00
504f16b802 Rename "hasTouchSupport" to "userAgentHasTouchSupport" 2024-06-09 11:47:08 +07:00
a3a7a57b51 Get PointerServer's port from the app 2024-06-09 11:41:00 +07:00
69 changed files with 6090 additions and 7087 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

@ -5,7 +5,9 @@ Improve Xbox Cloud Gaming (xCloud) experience on [xbox.com/play](https://www.xbo
> The Android app is in development at [redphx/better-xcloud-android](https://github.com/redphx/better-xcloud-android) > The Android app is in development at [redphx/better-xcloud-android](https://github.com/redphx/better-xcloud-android)
> [!IMPORTANT] > [!IMPORTANT]
> I don't accept pull requests at the moment (except PR for custom touch controls) > I only accept pull requests for:
> - Custom touch controls
> - Bug fixes
**Supported platforms:** **Supported platforms:**
- Windows - Windows
@ -21,50 +23,15 @@ If you like this project please give it a 🌟. Thank you 🙏.
[![Total downloads](https://img.shields.io/github/downloads/redphx/better-xcloud/total?color=%23e15f2c)](https://github.com/redphx/better-xcloud/releases) [![Total downloads](https://img.shields.io/github/downloads/redphx/better-xcloud/total?color=%23e15f2c)](https://github.com/redphx/better-xcloud/releases)
[![Total stars](https://img.shields.io/github/stars/redphx/better-xcloud?color=%23cca400)](https://github.com/redphx/better-xcloud/stargazers) [![Total stars](https://img.shields.io/github/stars/redphx/better-xcloud?color=%23cca400)](https://github.com/redphx/better-xcloud/stargazers)
## How to install
Visit the [home page](https://better-xcloud.github.io) to know how to install Better xCloud on your device.
## Full documentations ## Full documentations
- For the full details please visit: https://better-xcloud.github.io - For the full details please visit: [**better-xcloud.github.io**](https://better-xcloud.github.io)
- [Demo video](https://youtu.be/hyp69Jrb2sQ) - [Demo video](https://youtu.be/hyp69Jrb2sQ)
⚠️ Please DO NOT report **Better xCloud**'s bugs on [/r/xcloud subreddit](https://reddit.com/r/xcloud/). Report bugs in [Issues](https://github.com/redphx/better-xcloud/issues) or [Telegram channel](https://t.me/betterxcloud) instead. ⚠️ Please DO NOT report **Better xCloud**'s bugs on [/r/xcloud subreddit](https://reddit.com/r/xcloud/). Report bugs in [Issues](https://github.com/redphx/better-xcloud/issues) or [Telegram channel](https://t.me/betterxcloud) instead.
## Table of Contents
- [**How to install**](#how-to-install)
- [**Features**](#features)
- [**Donation**](#donation)
- [**Acknowledgements**](#acknowledgements)
- [**Disclaimers**](#disclaimers)
## How to install
Visit [this page](https://better-xcloud.github.io/browsers) to know how to install Better xCloud on your device.
## Features
<img width="400" alt="Settings UI" src="https://github.com/redphx/better-xcloud/assets/96280/4bec2d62-31df-499c-9aad-2485626b6925">
<br>
<img width="400" alt="Remote Play dialog" src="https://github.com/redphx/better-xcloud/assets/96280/daf7f698-a228-4f9c-8f23-9669e061a64c">
<br>
<img width="600" alt="Stream HUD" src="https://github.com/redphx/better-xcloud/assets/96280/51bdb96c-79ab-402f-902a-a9e6229973b2">
<br>
<img width="600" alt="Stream settings" src="https://github.com/redphx/better-xcloud/assets/96280/ed513cb3-6e6c-4e8e-9e06-c62e71e41c90">
<br>
<img width="600" alt="Remapper" src="https://github.com/redphx/better-xcloud/assets/96280/f2e2bc51-f673-4b24-b127-c7169b86462b">
&nbsp;
**Demo video:** [https://youtu.be/oDr5Eddp55E ](https://youtu.be/AYb-EUcz72U)
- **🔥 Totally free and open-source**
- **🔥 Allow playing with [Mouse & Keyboard](https://better-xcloud.github.io/mouse-and-keyboard)**
- **🔥 Enable [Remote Play](https://better-xcloud.github.io/remote-play) support**
> 1080p resolution and can stream Xbox 360 games.
- **🔥 [Improve visual quality](https://better-xcloud.github.io/ingame-features/#improve-streams-clarity) of the stream**
> Similar to (but not as good as) the "Clarity Boost" of xCloud on Edge browser. [Demo video](https://youtu.be/ZhW2choAHUs).
- **🔥 Show [Stream stats](https://better-xcloud.github.io/stream-stats)**
- **🔥 [Screenshot capture](https://better-xcloud.github.io/screenshot-capture)**
- **🔥 [Touch controller](https://better-xcloud.github.io/features/#touch-controller)**
> Enable touch controller support for all games.
- [And more...](https://better-xcloud.github.io/features/)
## Donation ## Donation
If you think this project is useful and want to support future developments, please consider making a donate via [my Ko-fi page](https://ko-fi.com/redphx). If you think this project is useful and want to support future developments, please consider making a donate via [my Ko-fi page](https://ko-fi.com/redphx).
Or you can give this project a star, that's also helpful. Or you can give this project a star, that's also helpful.

View File

@ -4,6 +4,7 @@ import { parseArgs } from "node:util";
import { sys } from "typescript"; import { sys } from "typescript";
import txtScriptHeader from "./src/assets/header_script.txt" with { type: "text" }; import txtScriptHeader from "./src/assets/header_script.txt" with { type: "text" };
import txtMetaHeader from "./src/assets/header_meta.txt" with { type: "text" }; import txtMetaHeader from "./src/assets/header_meta.txt" with { type: "text" };
import { assert } from "node:console";
enum BuildTarget { enum BuildTarget {
ALL = 'all', ALL = 'all',
@ -24,6 +25,8 @@ const postProcess = (str: string): string => {
// Add ADDITIONAL CODE block // Add ADDITIONAL CODE block
str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS'); str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS');
assert(str.includes('/* ADDITIONAL CODE */'));
return str; return str;
} }
@ -45,6 +48,9 @@ const build = async (target: BuildTarget, version: string, config: any={}) => {
entrypoints: ['src/index.ts'], entrypoints: ['src/index.ts'],
outdir: outDir, outdir: outDir,
naming: outputScriptName, naming: outputScriptName,
minify: {
syntax: true,
},
define: { define: {
'Bun.env.BUILD_TARGET': JSON.stringify(target), 'Bun.env.BUILD_TARGET': JSON.stringify(target),
'Bun.env.SCRIPT_VERSION': JSON.stringify(version), 'Bun.env.SCRIPT_VERSION': JSON.stringify(version),
@ -99,5 +105,5 @@ console.log('Building: ', values['version']);
const config = {}; const config = {};
for (const target of buildTargets) { for (const target of buildTargets) {
await build(target, values['version'], config); await build(target, values['version']!!, config);
} }

BIN
bun.lockb Executable file

Binary file not shown.

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.1.3
// ==/UserScript== // ==/UserScript==

File diff suppressed because one or more lines are too long

View File

@ -6,12 +6,12 @@
"build": "build.ts" "build": "build.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.3", "@types/bun": "^1.1.6",
"@types/node": "^20.13.0", "@types/node": "^20.14.10",
"@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

@ -29,6 +29,13 @@
outline: none !important; outline: none !important;
} }
.bx-top-buttons {
.bx-button {
display: block;
margin-bottom: 8px;
}
}
.bx-settings-title-wrapper { .bx-settings-title-wrapper {
display: flex; display: flex;
margin-bottom: 10px; margin-bottom: 10px;
@ -41,16 +48,18 @@
text-decoration: none; text-decoration: none;
font-weight: bold; font-weight: bold;
display: block; display: block;
color: #5dc21e;
flex: 1; flex: 1;
text-transform: none;
span {
color: #5dc21e !important;
}
&:focus { &:focus {
color: #83f73a; span {
color: #83f73a !important;
} }
} }
.bx-button.bx-primary {
margin-top: 8px;
} }
a.bx-settings-update { a.bx-settings-update {

View File

@ -20,7 +20,6 @@
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
font-family: var(--bx-monospaced-font); font-family: var(--bx-monospaced-font);
color: #fff;
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -47,4 +46,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

@ -23,9 +23,10 @@
--bx-dialog-z-index: 9101; --bx-dialog-z-index: 9101;
--bx-dialog-overlay-z-index: 9100; --bx-dialog-overlay-z-index: 9100;
--bx-remote-play-popup-z-index: 9090; --bx-remote-play-popup-z-index: 9090;
--bx-stats-bar-z-index: 9001; --bx-stats-bar-z-index: 9010;
--bx-stream-settings-z-index: 9000; --bx-stream-settings-z-index: 9001;
--bx-mkb-pointer-lock-msg-z-index: 8999; --bx-mkb-pointer-lock-msg-z-index: 9000;
--bx-stream-settings-overlay-z-index: 8999;
--bx-game-bar-z-index: 8888; --bx-game-bar-z-index: 8888;
--bx-wait-time-box-z-index: 100; --bx-wait-time-box-z-index: 100;
--bx-screenshot-animation-z-index: 1; --bx-screenshot-animation-z-index: 1;
@ -79,6 +80,19 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
visibility: hidden !important; visibility: hidden !important;
} }
.bx-invisible {
opacity: 0;
}
.bx-unclickable {
pointer-events: none;
}
.bx-pixel {
width: 1px !important;
height: 1px !important;
}
.bx-no-margin { .bx-no-margin {
margin: 0 !important; margin: 0 !important;
} }
@ -91,6 +105,10 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
font-family: var(--bx-promptfont-font); font-family: var(--bx-promptfont-font);
} }
select[multiple] {
overflow: auto;
}
/* Hide UI elements */ /* Hide UI elements */
#headerArea, #uhfSkipToMain, .uhf-footer { #headerArea, #uhfSkipToMain, .uhf-footer {
display: none; display: none;
@ -107,3 +125,10 @@ div[class*=NotFocusedDialog] {
#game-stream video:not([src]) { #game-stream video:not([src]) {
visibility: hidden; visibility: hidden;
} }
/* Hide Controller icon in Game tiles */
div[class*=SupportedInputsBadge] {
&:not(:has(:nth-child(2))), svg:first-of-type {
display: none;
}
}

View File

@ -7,6 +7,20 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
.bx-stream-settings-overlay {
position: fixed;
background: #0b0b0be3;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--bx-stream-settings-overlay-z-index);
&[data-is-playing="true"] {
background: transparent;
}
}
.bx-stream-settings-tabs { .bx-stream-settings-tabs {
position: fixed; position: fixed;
top: 0; top: 0;
@ -95,7 +109,7 @@
margin-bottom: 16px; margin-bottom: 16px;
padding-bottom: 16px; padding-bottom: 16px;
label { > label {
font-size: 16px; font-size: 16px;
display: block; display: block;
text-align: left; text-align: left;
@ -113,6 +127,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

@ -7,6 +7,7 @@
@import 'toast.styl'; @import 'toast.styl';
@import 'loading-screen.styl'; @import 'loading-screen.styl';
@import 'remote-play.styl'; @import 'remote-play.styl';
@import 'web-components.styl';
@import 'stream.styl'; @import 'stream.styl';
@import 'number-stepper.styl'; @import 'number-stepper.styl';

View File

@ -0,0 +1,48 @@
.bx-select {
select {
display: none;
}
> div {
display: inline-block;
min-width: 110px;
text-align: center;
margin: 0 10px;
line-height: 24px;
vertical-align: middle;
background: #fff;
color: #000;
border-radius: 4px;
padding: 2px 4px;
input {
display: inline-block;
margin-right: 8px;
}
label {
margin-bottom: 0;
}
}
button {
border: none;
width: 24px;
height: 24px;
line-height: 24px;
color: #fff;
border-radius: 4px;
font-weight: bold;
font-size: 14px;
font-family: var(--bx-monospaced-font);
&.bx-inactive {
pointer-events: none;
opacity: 0.2;
}
span {
line-height: unset;
}
}
}

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',
}

6
src/enums/ui-sections.ts Normal file
View File

@ -0,0 +1,6 @@
export enum UiSection {
NEWS = 'news',
FRIENDS = 'friends',
MOST_POPULAR = 'most-popular',
ALL_GAMES = 'all-games',
}

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

@ -0,0 +1,9 @@
export enum UserAgentProfile {
WINDOWS_EDGE = 'windows-edge',
MACOS_SAFARI = 'macos-safari',
SMART_TV_GENERIC = 'smarttv-generic',
SMART_TV_TIZEN = 'smarttv-tizen',
VR_OCULUS = 'vr-oculus',
DEFAULT = 'default',
CUSTOM = 'custom',
}

View File

@ -9,9 +9,9 @@ import { showGamepadToast } from "@utils/gamepad";
import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler"; import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler";
import { StreamBadges } from "@modules/stream/stream-badges"; 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, preloadFonts } from "@utils/css";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { setupStreamUi, updateVideoPlayerCss } from "@modules/ui/ui"; import { setupStreamUi } 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";
@ -31,17 +31,26 @@ import { GameBar } from "./modules/game-bar/game-bar";
import { Screenshot } from "./utils/screenshot"; import { Screenshot } from "./utils/screenshot";
import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler"; import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler";
import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu"; import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu";
import { StreamSettings } from "./modules/stream/stream-settings";
import { updateVideoPlayer } from "./modules/stream/stream-settings-utils";
import { UiSection } from "./enums/ui-sections";
// Handle login page // Handle login page
if (window.location.pathname.includes('/auth/msa')) { if (window.location.pathname.includes('/auth/msa')) {
window.addEventListener('load', e => { const nativePushState = window.history['pushState'];
window.location.search.includes('loggedIn') && window.setTimeout(() => { window.history['pushState'] = function(...args: any[]) {
const location = window.location; const url = args[2];
if (url && (url.startsWith('/play') || url.substring(6).startsWith('/play'))) {
console.log('Redirecting to xbox.com/play');
window.stop();
window.location.href = 'https://www.xbox.com' + url;
return;
}
// @ts-ignore // @ts-ignore
location.pathname.includes('/play') && location.reload(true); return nativePushState.apply(this, arguments);
}, 2000); }
});
// Stop processing the script // Stop processing the script
throw new Error('[Better xCloud] Refreshing the page after logging in'); throw new Error('[Better xCloud] Refreshing the page after logging in');
} }
@ -92,8 +101,19 @@ window.addEventListener('load', e => {
window.location.reload(true); window.location.reload(true);
} }
}, 3000); }, 3000);
}); });
// Hide "Play with Friends" skeleton section
if (getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.FRIENDS)) {
document.addEventListener('readystatechange', e => {
if (document.readyState === 'interactive') {
const $parent = document.querySelector('div[class*=PlayWithFriendsSkeleton]')?.closest('div[class*=HomePage-module]') as HTMLElement;
$parent && ($parent.style.display = 'none');
}
})
}
window.BX_EXPOSED = BxExposed; window.BX_EXPOSED = BxExposed;
// Hide Settings UI when navigate to another page // Hide Settings UI when navigate to another page
@ -146,9 +166,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 +176,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,18 +195,15 @@ 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;
window.BX_EXPOSED.stopTakRendering = false; window.BX_EXPOSED.stopTakRendering = false;
const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog'); StreamSettings.getInstance().hide();
if ($streamSettingsDialog) {
$streamSettingsDialog.classList.add('bx-gone');
}
STATES.currentStream.audioGainNode = null;
STATES.currentStream.$video = null;
StreamStats.getInstance().onStoppedPlaying(); StreamStats.getInstance().onStoppedPlaying();
MouseCursorHider.stop(); MouseCursorHider.stop();
@ -219,6 +234,8 @@ 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')) {
// Make sure it's Guide dialog
if (document.querySelector('#gamepass-dialog-root div[class*=GuideDialog]')) {
// Find navigation bar // Find navigation bar
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true'); const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
if ($selectedTab) { if ($selectedTab) {
@ -233,6 +250,7 @@ function observeRootDialog($root: HTMLElement) {
} }
} }
} }
}
const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false; const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false;
if (shown !== currentShown) { if (shown !== currentShown) {
@ -277,7 +295,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.userAgent.capabilities.touch && TouchController.updateCustomList();
overridePreloadState(); overridePreloadState();
VibrationManager.initialSetup(); VibrationManager.initialSetup();
@ -287,9 +305,11 @@ function main() {
// Setup UI // Setup UI
addCss(); addCss();
preloadFonts();
Toast.setup(); Toast.setup();
(getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance(); (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance();
BX_FLAGS.PreloadUi && setupStreamUi(); BX_FLAGS.PreloadUi && setupStreamUi();
Screenshot.setup();
GuideMenu.observe(); GuideMenu.observe();
StreamBadges.setupEvents(); StreamBadges.setupEvents();
@ -301,8 +321,10 @@ function main() {
disablePwa(); disablePwa();
// Show a toast when connecting/disconecting controller // Show a toast when connecting/disconecting controller
if (getPref(PrefKey.CONTROLLER_SHOW_CONNECTION_STATUS)) {
window.addEventListener('gamepadconnected', e => showGamepadToast(e.gamepad)); window.addEventListener('gamepadconnected', e => showGamepadToast(e.gamepad));
window.addEventListener('gamepaddisconnected', e => showGamepadToast(e.gamepad)); window.addEventListener('gamepaddisconnected', e => showGamepadToast(e.gamepad));
}
// Preload Remote Play // Preload Remote Play
if (getPref(PrefKey.REMOTE_PLAY_ENABLED)) { if (getPref(PrefKey.REMOTE_PLAY_ENABLED)) {
@ -314,7 +336,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

@ -4,6 +4,7 @@ import cssStr from "@assets/css/styles.styl" with { type: "text" };
const generatedCss = await (stylus(cssStr, {}) const generatedCss = await (stylus(cssStr, {})
.set('filename', 'styles.css') .set('filename', 'styles.css')
.set('compress', true)
.include('src/assets/css/')) .include('src/assets/css/'))
.render(); .render();

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.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []),
new MicrophoneAction(), new MicrophoneAction(),
]; ];

View File

@ -109,7 +109,7 @@ export class LoadingScreen {
let $waitTimeBox = LoadingScreen.#$waitTimeBox; let $waitTimeBox = LoadingScreen.#$waitTimeBox;
if (!$waitTimeBox) { if (!$waitTimeBox) {
$waitTimeBox = CE<HTMLElement>('div', {'class': 'bx-wait-time-box'}, $waitTimeBox = CE('div', {'class': 'bx-wait-time-box'},
CE('label', {}, t('server')), CE('label', {}, t('server')),
CE('span', {}, getPreferredServerRegion()), CE('span', {}, getPreferredServerRegion()),
CE('label', {}, t('wait-time-estimated')), CE('label', {}, t('wait-time-estimated')),

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";
@ -8,13 +8,13 @@ import { t } from "@utils/translation";
import { LocalDb } from "@utils/local-db"; import { LocalDb } from "@utils/local-db";
import { KeyHelper } from "./key-helper"; import { KeyHelper } from "./key-helper";
import type { MkbStoredPreset } from "@/types/mkb"; import type { MkbStoredPreset } from "@/types/mkb";
import { showStreamSettings } from "@modules/stream/stream-ui";
import { AppInterface, STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { UserAgent } from "@utils/user-agent"; import { UserAgent } from "@utils/user-agent";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { PointerClient } from "./pointer-client"; import { PointerClient } from "./pointer-client";
import { NativeMkbHandler } from "./native-mkb-handler"; import { NativeMkbHandler } from "./native-mkb-handler";
import { MkbHandler, MouseDataProvider } from "./base-mkb-handler"; import { MkbHandler, MouseDataProvider } from "./base-mkb-handler";
import { StreamSettings } from "../stream/stream-settings";
const LOG_TAG = 'MkbHandler'; const LOG_TAG = 'MkbHandler';
@ -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');
@ -507,7 +507,7 @@ export class EmulatedMkbHandler extends MkbHandler {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
showStreamSettings('mkb'); StreamSettings.getInstance().show('mkb');
}, },
}), }),
), ),

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,8 @@ 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";
import { deepClone } from "@utils/global";
type MkbRemapperElements = { type MkbRemapperElements = {
@ -292,7 +292,7 @@ export class MkbRemapper {
this.#$.wrapper!.classList.toggle('bx-editing', this.#STATE.isEditing); this.#$.wrapper!.classList.toggle('bx-editing', this.#STATE.isEditing);
if (this.#STATE.isEditing) { if (this.#STATE.isEditing) {
this.#STATE.editingPresetData = structuredClone(this.#getCurrentPreset().data); this.#STATE.editingPresetData = deepClone(this.#getCurrentPreset().data);
} else { } else {
this.#STATE.editingPresetData = null; this.#STATE.editingPresetData = null;
} }
@ -511,7 +511,7 @@ export class MkbRemapper {
label: t('save'), label: t('save'),
style: ButtonStyle.PRIMARY, style: ButtonStyle.PRIMARY,
onClick: e => { onClick: e => {
const updatedPreset = structuredClone(this.#getCurrentPreset()); const updatedPreset = deepClone(this.#getCurrentPreset());
updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData; updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData;
LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => { LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => {

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,13 @@ 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";
import { UiSection } from "@/enums/ui-sections.js";
type PatchArray = (keyof typeof PATCHES)[]; type PatchArray = (keyof typeof PATCHES)[];
@ -187,12 +190,18 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
}, },
enableXcloudLogger(str: string) { enableXcloudLogger(str: string) {
const text = 'this.telemetryProvider=e}log(e,t,i){'; const text = 'this.telemetryProvider=e}log(e,t,r){';
if (!str.includes(text)) { if (!str.includes(text)) {
return false; return false;
} }
str = str.replaceAll(text, text + 'console.log(Array.from(arguments));'); const newCode = `
const [logTag, logLevel, logMessage] = Array.from(arguments);
const logFunc = [console.debug, console.log, console.warn, console.error][logLevel];
logFunc(logTag, '//', logMessage);
`;
str = str.replaceAll(text, text + newCode);
return str; return str;
}, },
@ -228,12 +237,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 +573,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);
@ -636,7 +630,151 @@ true` + text;
str = str.replace(text, text + 'return;'); str = str.replace(text, text + 'return;');
return str; return str;
},
// Fix crashing when RequestInfo.origin is empty
patchRequestInfoCrash(str: string) {
const text = 'if(!e)throw new Error("RequestInfo.origin is falsy");';
if (!str.includes(text)) {
return false;
} }
str = str.replace(text, 'if (!e) e = "https://www.xbox.com";');
return str;
},
exposeDialogRoutes(str: string) {
const text = 'return{goBack:function(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, 'return window.BX_EXPOSED.dialogRoutes = {goBack:function(){');
return str;
},
/*
(x.AW, {
path: V.LoginDeviceCode.path,
exact: !0,
render: () => (0, n.jsx)(qe, {
children: (0, n.jsx)(Et.R, {})
})
}, V.LoginDeviceCode.name),
const qe = e => {
let {
children: t
} = e;
const {
isTV: a,
isSupportedTVBrowser: r
} = (0, T.d)();
return a && r ? (0, n.jsx)(n.Fragment, {
children: t
}) : (0, n.jsx)(x.l_, {
to: V.Home.getLink()
})
};
*/
enableTvRoutes(str: string) {
let index = str.indexOf('.LoginDeviceCode.path,');
if (index < 0) {
return false;
}
// Find *qe* name
const match = /render:.*?jsx\)\(([^,]+),/.exec(str.substring(index, index + 100));
if (!match) {
return false;
}
const funcName = match[1];
// Replace *qe*'s return value
// `return a && r ?` => `return a && r || true ?`
index = str.indexOf(`const ${funcName}=e=>{`);
index > -1 && (index = str.indexOf('return ', index));
index > -1 && (index = str.indexOf('?', index));
if (index === -1) {
return false;
}
str = str.substring(0, index) + '|| true' + str.substring(index);
return str;
},
// Don't render "Play With Friends" sections
ignorePlayWithFriendsSection(str: string) {
let index = str.indexOf('location:"PlayWithFriendsRow",');
if (index === -1) {
return false;
}
index = str.indexOf('return', index - 50);
if (index === -1) {
return false;
}
str = str.substring(0, index) + 'return null;' + str.substring(index + 6);
return str;
},
// Don't render "All Games" sections
ignoreAllGamesSection(str: string) {
let index = str.indexOf('className:"AllGamesRow-module__allGamesRowContainer');
if (index === -1) {
return false;
}
index = str.indexOf('grid:!0,', index);
index > -1 && (index = str.indexOf('(0,', index - 70));
if (index === -1) {
return false;
}
str = str.substring(0, index) + 'true ? null :' + str.substring(index);
return str;
},
// Override Storage.getSettings()
overrideStorageGetSettings(str: string) {
const text = '}getSetting(e){';
if (!str.includes(text)) {
return false;
}
const newCode = `
// console.log('setting', this.baseStorageKey, e);
if (this.baseStorageKey in window.BX_EXPOSED.overrideSettings) {
const settings = window.BX_EXPOSED.overrideSettings[this.baseStorageKey];
if (e in settings) {
return settings[e];
}
}
`;
str = str.replace(text, text + newCode);
return str;
},
// game-stream.js 24.16.4
alwaysShowStreamHud(str: string) {
let index = str.indexOf(',{onShowStreamMenu:');
if (index === -1) {
return false;
}
index = str.indexOf('&&(0,', index - 100);
if (index === -1) {
return false;
}
const commaIndex = str.indexOf(',', index - 10);
str = str.substring(0, commaIndex) + ',true' + str.substring(index);
return str;
},
}; };
let PATCH_ORDERS: PatchArray = [ let PATCH_ORDERS: PatchArray = [
@ -647,16 +785,26 @@ let PATCH_ORDERS: PatchArray = [
'exposeInputSink', 'exposeInputSink',
] : []), ] : []),
'patchRequestInfoCrash',
'disableStreamGate', 'disableStreamGate',
'overrideSettings', 'overrideSettings',
'broadcastPollingMode', 'broadcastPollingMode',
'exposeStreamSession', 'exposeStreamSession',
'exposeDialogRoutes',
'enableTvRoutes',
'overrideStorageGetSettings',
getPref(PrefKey.UI_LAYOUT) !== 'default' && 'websiteLayout', getPref(PrefKey.UI_LAYOUT) !== 'default' && 'websiteLayout',
getPref(PrefKey.LOCAL_CO_OP_ENABLED) && 'supportLocalCoOp', getPref(PrefKey.LOCAL_CO_OP_ENABLED) && 'supportLocalCoOp',
getPref(PrefKey.GAME_FORTNITE_FORCE_CONSOLE) && 'forceFortniteConsole', getPref(PrefKey.GAME_FORTNITE_FORCE_CONSOLE) && 'forceFortniteConsole',
getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.FRIENDS) && 'ignorePlayWithFriendsSection',
getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.ALL_GAMES) && 'ignoreAllGamesSection',
...(getPref(PrefKey.BLOCK_TRACKING) ? [ ...(getPref(PrefKey.BLOCK_TRACKING) ? [
'disableAiTrack', 'disableAiTrack',
'disableTelemetry', 'disableTelemetry',
@ -672,7 +820,7 @@ let PATCH_ORDERS: PatchArray = [
'remotePlayKeepAlive', 'remotePlayKeepAlive',
'remotePlayDirectConnectUrl', 'remotePlayDirectConnectUrl',
'remotePlayDisableAchievementToast', 'remotePlayDisableAchievementToast',
STATES.hasTouchSupport && 'patchUpdateInputConfigurationAsync', STATES.userAgent.capabilities.touch && 'patchUpdateInputConfigurationAsync',
] : []), ] : []),
...(BX_FLAGS.EnableXcloudLogging ? [ ...(BX_FLAGS.EnableXcloudLogging ? [
@ -688,6 +836,8 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
'patchStreamHud', 'patchStreamHud',
'playVibration', 'playVibration',
'alwaysShowStreamHud',
// 'exposeEventTarget', // 'exposeEventTarget',
// Patch volume control for normal stream // Patch volume control for normal stream
@ -698,7 +848,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.userAgent.capabilities.touch ? [
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

@ -91,7 +91,7 @@ export class StreamBadges {
let batteryLevel = '100%'; let batteryLevel = '100%';
let batteryLevelInt = 100; let batteryLevelInt = 100;
let isCharging = false; let isCharging = false;
if ('getBattery' in navigator) { if (STATES.browser.capabilities.batteryApi) {
try { try {
const bm = await (navigator as NavigatorBattery).getBattery(); const bm = await (navigator as NavigatorBattery).getBattery();
isCharging = bm.charging; isCharging = bm.charging;
@ -158,19 +158,26 @@ export class StreamBadges {
} }
#secondsToHm(seconds: number) { #secondsToHm(seconds: number) {
const h = Math.floor(seconds / 3600); let h = Math.floor(seconds / 3600);
const m = Math.floor(seconds % 3600 / 60) + 1; let m = Math.floor(seconds % 3600 / 60) + 1;
const hDisplay = h > 0 ? `${h}h`: ''; if (m === 60) {
const mDisplay = m > 0 ? `${m}m`: ''; h += 1;
return hDisplay + mDisplay; m = 0;
}
const output = [];
h > 0 && output.push(`${h}h`);
m > 0 && output.push(`${m}m`);
return output.join(' ');
} }
// https://stackoverflow.com/a/20732091 // https://stackoverflow.com/a/20732091
#humanFileSize(size: number) { #humanFileSize(size: number) {
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; return (size / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
} }
@ -217,7 +224,7 @@ export class StreamBadges {
// Battery // Battery
let batteryLevel = ''; let batteryLevel = '';
if ('getBattery' in navigator) { if (STATES.browser.capabilities.batteryApi) {
batteryLevel = '100%'; batteryLevel = '100%';
} }
@ -331,7 +338,7 @@ export class StreamBadges {
// Get battery level // Get battery level
try { try {
'getBattery' in navigator && (navigator as NavigatorBattery).getBattery().then(bm => { STATES.browser.capabilities.batteryApi && (navigator as NavigatorBattery).getBattery().then(bm => {
streamBadges.startBatteryLevel = Math.round(bm.level * 100); streamBadges.startBatteryLevel = Math.round(bm.level * 100);
}); });
} catch(e) {} } catch(e) {}

View File

@ -0,0 +1,54 @@
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { STATES } from "@utils/global";
import { getPref, PrefKey, setPref } from "@utils/preferences";
import { UserAgent } from "@utils/user-agent";
import type { StreamPlayerOptions } from "../stream-player";
export function onChangeVideoPlayerType() {
const playerType = getPref(PrefKey.VIDEO_PLAYER_TYPE);
const $videoProcessing = document.getElementById('bx_setting_video_processing') as HTMLSelectElement;
const $videoSharpness = document.getElementById('bx_setting_video_sharpness') as HTMLElement;
let isDisabled = false;
if (playerType === StreamPlayerType.WEBGL2) {
($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = false;
} else {
// Only allow USM when player type is Video
$videoProcessing.value = StreamVideoProcessing.USM;
setPref(PrefKey.VIDEO_PROCESSING, StreamVideoProcessing.USM);
($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = true;
if (UserAgent.isSafari()) {
isDisabled = true;
}
}
$videoProcessing.disabled = isDisabled;
$videoSharpness.dataset.disabled = isDisabled.toString();
updateVideoPlayer();
}
export function updateVideoPlayer() {
const streamPlayer = STATES.currentStream.streamPlayer;
if (!streamPlayer) {
return;
}
const options = {
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;
streamPlayer.setPlayerType(getPref(PrefKey.VIDEO_PLAYER_TYPE));
streamPlayer.updateOptions(options);
streamPlayer.refreshPlayer();
}
window.addEventListener('resize', updateVideoPlayer);

View File

@ -0,0 +1,397 @@
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import { STATES, AppInterface } from "@utils/global";
import { ButtonStyle, CE, createButton, createSvgIcon } from "@utils/html";
import { PrefKey, Preferences, getPref, toPrefElement } from "@utils/preferences";
import { t } from "@utils/translation";
import { ControllerShortcut } from "../controller-shortcut";
import { MkbRemapper } from "../mkb/mkb-remapper";
import { NativeMkbHandler } from "../mkb/native-mkb-handler";
import { SoundShortcut } from "../shortcuts/shortcut-sound";
import { TouchController } from "../touch-controller";
import { VibrationManager } from "../vibration-manager";
import { StreamStats } from "./stream-stats";
import { BX_FLAGS } from "@/utils/bx-flags";
import { BxSelectElement } from "@/web-components/bx-select";
import { onChangeVideoPlayerType, updateVideoPlayer } from "./stream-settings-utils";
export class StreamSettings {
private static instance: StreamSettings;
public static getInstance(): StreamSettings {
if (!StreamSettings.instance) {
StreamSettings.instance = new StreamSettings();
}
return StreamSettings.instance;
}
private $container: HTMLElement | undefined;
private $overlay: HTMLElement | undefined;
readonly SETTINGS_UI = [{
icon: BxIcon.DISPLAY,
group: 'stream',
items: [{
group: 'audio',
label: t('audio'),
help_url: 'https://better-xcloud.github.io/ingame-features/#audio',
items: [{
pref: PrefKey.AUDIO_VOLUME,
onChange: (e: any, value: number) => {
SoundShortcut.setGainNodeVolume(value);
},
params: {
disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL),
},
onMounted: ($elm: HTMLElement) => {
const $range = $elm.querySelector('input[type=range') as HTMLInputElement;
window.addEventListener(BxEvent.GAINNODE_VOLUME_CHANGED, e => {
$range.value = (e as any).volume;
BxEvent.dispatch($range, 'input', {
ignoreOnChange: true,
});
});
},
}],
}, {
group: 'video',
label: t('video'),
help_url: 'https://better-xcloud.github.io/ingame-features/#video',
items: [{
pref: PrefKey.VIDEO_PLAYER_TYPE,
onChange: onChangeVideoPlayerType,
}, {
pref: PrefKey.VIDEO_RATIO,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_PROCESSING,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_SHARPNESS,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_SATURATION,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_CONTRAST,
onChange: updateVideoPlayer,
}, {
pref: PrefKey.VIDEO_BRIGHTNESS,
onChange: updateVideoPlayer,
}],
}],
}, {
icon: BxIcon.CONTROLLER,
group: 'controller',
items: [{
group: 'controller',
label: t('controller'),
help_url: 'https://better-xcloud.github.io/ingame-features/#controller',
items: [{
pref: PrefKey.CONTROLLER_ENABLE_VIBRATION,
unsupported: !VibrationManager.supportControllerVibration(),
onChange: () => VibrationManager.updateGlobalVars(),
}, {
pref: PrefKey.CONTROLLER_DEVICE_VIBRATION,
unsupported: !VibrationManager.supportDeviceVibration(),
onChange: () => VibrationManager.updateGlobalVars(),
}, (VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && {
pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY,
unsupported: !VibrationManager.supportDeviceVibration(),
onChange: () => VibrationManager.updateGlobalVars(),
}],
},
STATES.userAgent.capabilities.touch && {
group: 'touch-controller',
label: t('touch-controller'),
items: [{
label: t('layout'),
content: CE('select', {disabled: true}, CE('option', {}, t('default'))),
onMounted: ($elm: HTMLSelectElement) => {
$elm.addEventListener('change', e => {
TouchController.loadCustomLayout(STATES.currentStream?.xboxTitleId!, $elm.value, 1000);
});
window.addEventListener(BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, e => {
const data = (e as any).data;
if (STATES.currentStream?.xboxTitleId && ($elm as any).xboxTitleId === STATES.currentStream?.xboxTitleId) {
$elm.dispatchEvent(new Event('change'));
return;
}
($elm as any).xboxTitleId = STATES.currentStream?.xboxTitleId;
// Clear options
while ($elm.firstChild) {
$elm.removeChild($elm.firstChild);
}
$elm.disabled = !data;
if (!data) {
$elm.appendChild(CE('option', {value: ''}, t('default')));
$elm.value = '';
$elm.dispatchEvent(new Event('change'));
return;
}
// Add options
const $fragment = document.createDocumentFragment();
for (const key in data.layouts) {
const layout = data.layouts[key];
let name;
if (layout.author) {
name = `${layout.name} (${layout.author})`;
} else {
name = layout.name;
}
const $option = CE('option', {value: key}, name);
$fragment.appendChild($option);
}
$elm.appendChild($fragment);
$elm.value = data.default_layout;
$elm.dispatchEvent(new Event('change'));
});
},
}],
}],
},
getPref(PrefKey.MKB_ENABLED) && {
icon: BxIcon.VIRTUAL_CONTROLLER,
group: 'mkb',
items: [{
group: 'mkb',
label: t('virtual-controller'),
help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/',
content: MkbRemapper.INSTANCE.render(),
}],
},
AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && {
icon: BxIcon.NATIVE_MKB,
group: 'native-mkb',
items: [{
group: 'native-mkb',
label: t('native-mkb'),
items: [{
pref: PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY,
onChange: (e: any, value: number) => {
NativeMkbHandler.getInstance().setVerticalScrollMultiplier(value / 100);
},
}, {
pref: PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY,
onChange: (e: any, value: number) => {
NativeMkbHandler.getInstance().setHorizontalScrollMultiplier(value / 100);
},
}],
}],
}, {
icon: BxIcon.COMMAND,
group: 'shortcuts',
items: [{
group: 'shortcuts_controller',
label: t('controller-shortcuts'),
content: ControllerShortcut.renderSettings(),
}],
}, {
icon: BxIcon.STREAM_STATS,
group: 'stats',
items: [{
group: 'stats',
label: t('stream-stats'),
help_url: 'https://better-xcloud.github.io/stream-stats/',
items: [{
pref: PrefKey.STATS_SHOW_WHEN_PLAYING,
}, {
pref: PrefKey.STATS_QUICK_GLANCE,
onChange: (e: InputEvent) => {
const streamStats = StreamStats.getInstance();
(e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop();
},
}, {
pref: PrefKey.STATS_ITEMS,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_POSITION,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_TEXT_SIZE,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_OPACITY,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_TRANSPARENT,
onChange: StreamStats.refreshStyles,
}, {
pref: PrefKey.STATS_CONDITIONAL_FORMATTING,
onChange: StreamStats.refreshStyles,
},
],
}],
},
];
constructor() {
this.#setupDialog();
// Hide dialog when the Guide menu is shown
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, e => this.hide());
}
show(tabId?: string) {
const $container = this.$container!;
// Select tab
if (tabId) {
const $tab = $container.querySelector(`.bx-stream-settings-tabs svg[data-group=${tabId}]`);
$tab && $tab.dispatchEvent(new Event('click'));
}
this.$overlay!.classList.remove('bx-gone');
this.$overlay!.dataset.isPlaying = STATES.isPlaying.toString();
$container.classList.remove('bx-gone');
document.body.classList.add('bx-no-scroll');
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_SHOWN);
}
hide() {
this.$overlay!.classList.add('bx-gone');
this.$container!.classList.add('bx-gone');
document.body.classList.remove('bx-no-scroll');
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_DISMISSED);
}
#setupDialog() {
let $tabs: HTMLElement;
let $settings: HTMLElement;
const $overlay = CE('div', {'class': 'bx-stream-settings-overlay bx-gone'});
this.$overlay = $overlay;
const $container = CE('div', {'class': 'bx-stream-settings-dialog bx-gone'},
$tabs = CE('div', {'class': 'bx-stream-settings-tabs'}),
$settings = CE('div', {'class': 'bx-stream-settings-tab-contents'}),
);
this.$container = $container;
// Close dialog when clicking on the overlay
$overlay.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.hide();
});
for (const settingTab of this.SETTINGS_UI) {
if (!settingTab) {
continue;
}
const $svg = createSvgIcon(settingTab.icon);
$svg.addEventListener('click', e => {
// Switch tab
for (const $child of Array.from($settings.children)) {
if ($child.getAttribute('data-group') === settingTab.group) {
$child.classList.remove('bx-gone');
} else {
$child.classList.add('bx-gone');
}
}
// Highlight current tab button
for (const $child of Array.from($tabs.children)) {
$child.classList.remove('bx-active');
}
$svg.classList.add('bx-active');
});
$tabs.appendChild($svg);
const $group = CE('div', {'data-group': settingTab.group, 'class': 'bx-gone'});
for (const settingGroup of settingTab.items) {
if (!settingGroup) {
continue;
}
$group.appendChild(CE('h2', {},
CE('span', {}, settingGroup.label),
settingGroup.help_url && createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST,
url: settingGroup.help_url,
title: t('help'),
}),
));
if (settingGroup.note) {
if (typeof settingGroup.note === 'string') {
settingGroup.note = document.createTextNode(settingGroup.note);
}
$group.appendChild(settingGroup.note);
}
if (settingGroup.content) {
$group.appendChild(settingGroup.content);
continue;
}
if (!settingGroup.items) {
settingGroup.items = [];
}
for (const setting of settingGroup.items) {
if (!setting) {
continue;
}
const pref = setting.pref;
let $control;
if (setting.content) {
$control = setting.content;
} else if (!setting.unsupported) {
$control = toPrefElement(pref, setting.onChange, setting.params);
if ($control instanceof HTMLSelectElement && BX_FLAGS.ScriptUi === 'tv') {
$control = BxSelectElement.wrap($control);
}
}
const label = Preferences.SETTINGS[pref as PrefKey]?.label || setting.label;
const note = Preferences.SETTINGS[pref as PrefKey]?.note || setting.note;
const $content = CE('div', {'class': 'bx-stream-settings-row', 'data-type': settingGroup.group},
CE('label', {for: `bx_setting_${pref}`},
label,
note && CE('div', {'class': 'bx-stream-settings-dialog-note'}, note),
setting.unsupported && CE('div', {'class': 'bx-stream-settings-dialog-note'}, t('browser-unsupported-feature')),
),
!setting.unsupported && $control,
);
$group.appendChild($content);
setting.onMounted && setting.onMounted($control);
}
}
$settings.appendChild($group);
}
// Select first tab
$tabs.firstElementChild!.dispatchEvent(new Event('click'));
document.documentElement.appendChild($overlay);
document.documentElement.appendChild($container);
}
}

View File

@ -1,6 +1,5 @@
import { PrefKey } from "@utils/preferences" import { PrefKey, getPref } from "@utils/preferences"
import { BxEvent } from "@utils/bx-event" import { BxEvent } from "@utils/bx-event"
import { getPref } from "@utils/preferences"
import { CE } from "@utils/html" import { CE } from "@utils/html"
import { t } from "@utils/translation" import { t } from "@utils/translation"
import { STATES } from "@utils/global" import { STATES } from "@utils/global"
@ -39,6 +38,10 @@ export class StreamStats {
#quickGlanceObserver?: MutationObserver | null; #quickGlanceObserver?: MutationObserver | null;
constructor() {
this.#render();
}
start(glancing=false) { start(glancing=false) {
if (!this.isHidden() || (glancing && this.isGlancing())) { if (!this.isHidden() || (glancing && this.isGlancing())) {
return; return;
@ -85,11 +88,15 @@ export class StreamStats {
isGlancing = () => this.#$container && this.#$container.dataset.display === 'glancing'; isGlancing = () => this.#$container && this.#$container.dataset.display === 'glancing';
quickGlanceSetup() { quickGlanceSetup() {
if (this.#quickGlanceObserver) { if (!STATES.isPlaying || this.#quickGlanceObserver) {
return; return;
} }
const $uiContainer = document.querySelector('div[data-testid=ui-container]')!; const $uiContainer = document.querySelector('div[data-testid=ui-container]')!;
if (!$uiContainer) {
return;
}
this.#quickGlanceObserver = new MutationObserver((mutationList, observer) => { this.#quickGlanceObserver = new MutationObserver((mutationList, observer) => {
for (let record of mutationList) { for (let record of mutationList) {
if (record.attributeName && record.attributeName === 'aria-expanded') { if (record.attributeName && record.attributeName === 'aria-expanded') {
@ -135,10 +142,10 @@ export class StreamStats {
this.#$fps!.textContent = stat.framesPerSecond || 0; this.#$fps!.textContent = stat.framesPerSecond || 0;
// Packets Lost // Packets Lost
const packetsLost = stat.packetsLost; const packetsLost = Math.max(0, stat.packetsLost); // packetsLost can be negative, but we don't care about that
const packetsReceived = stat.packetsReceived; const packetsReceived = stat.packetsReceived;
const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2); const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2);
this.#$pl!.textContent = packetsLostPercentage === '0.00' ? packetsLost : `${packetsLost} (${packetsLostPercentage}%)`; this.#$pl!.textContent = packetsLostPercentage === '0.00' ? packetsLost.toString() : `${packetsLost} (${packetsLostPercentage}%)`;
// Frames dropped // Frames dropped
const framesDropped = stat.framesDropped; const framesDropped = stat.framesDropped;
@ -161,7 +168,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;
if (isNaN(currentDecodeTime)) {
this.#$dt!.textContent = '??ms';
} else {
this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`; 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' : '';
@ -207,10 +219,6 @@ export class StreamStats {
} }
#render() { #render() {
if (this.#$container) {
return;
}
const stats = { const stats = {
[StreamStat.PING]: [t('stat-ping'), this.#$ping = CE('span', {}, '0')], [StreamStat.PING]: [t('stat-ping'), this.#$ping = CE('span', {}, '0')],
[StreamStat.FPS]: [t('stat-fps'), this.#$fps = CE('span', {}, '0')], [StreamStat.FPS]: [t('stat-fps'), this.#$fps = CE('span', {}, '0')],
@ -241,10 +249,6 @@ export class StreamStats {
} }
static setupEvents() { static setupEvents() {
window.addEventListener(BxEvent.STREAM_LOADING, e => {
StreamStats.getInstance().#render();
});
window.addEventListener(BxEvent.STREAM_PLAYING, e => { window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE); const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE);
const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING); const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING);

View File

@ -5,6 +5,7 @@ import { BxEvent } from "@utils/bx-event.ts";
import { t } from "@utils/translation.ts"; import { t } from "@utils/translation.ts";
import { StreamBadges } from "./stream-badges.ts"; import { StreamBadges } from "./stream-badges.ts";
import { StreamStats } from "./stream-stats.ts"; import { StreamStats } from "./stream-stats.ts";
import { StreamSettings } from "./stream-settings.ts";
function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) { function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) {
@ -34,7 +35,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t
} }
}; };
if (STATES.browserHasTouchSupport) { if (STATES.browser.capabilities.touch) {
$container.addEventListener('transitionstart', onTransitionStart); $container.addEventListener('transitionstart', onTransitionStart);
$container.addEventListener('transitionend', onTransitionEnd); $container.addEventListener('transitionend', onTransitionEnd);
} }
@ -87,27 +88,6 @@ export function injectStreamMenuButtons() {
($screen as any).xObserving = true; ($screen as any).xObserving = true;
const $settingsDialog = document.querySelector('.bx-stream-settings-dialog')!;
const $parent = $screen.parentElement;
const hideSettingsFunc = (e?: MouseEvent | TouchEvent) => {
if (e) {
const $target = e.target as HTMLElement;
e.stopPropagation();
if ($target != $parent && $target.id !== 'MultiTouchSurface' && !$target.querySelector('#BabylonCanvasContainer-main')) {
return;
}
if ($target.id === 'MultiTouchSurface') {
$target.removeEventListener('touchstart', hideSettingsFunc);
}
}
// Hide Stream settings dialog
$settingsDialog.classList.add('bx-gone');
$parent?.removeEventListener('click', hideSettingsFunc);
// $parent.removeEventListener('touchstart', hideSettingsFunc);
}
let $btnStreamSettings: HTMLElement; let $btnStreamSettings: HTMLElement;
let $btnStreamStats: HTMLElement; let $btnStreamStats: HTMLElement;
const streamStats = StreamStats.getInstance(); const streamStats = StreamStats.getInstance();
@ -145,7 +125,7 @@ export function injectStreamMenuButtons() {
// Hide Stream Settings dialog when closing HUD // Hide Stream Settings dialog when closing HUD
$btnCloseHud.addEventListener('click', e => { $btnCloseHud.addEventListener('click', e => {
$settingsDialog.classList.add('bx-gone'); StreamSettings.getInstance().hide();
}); });
// Create Refresh button from the Close button // Create Refresh button from the Close button
@ -165,7 +145,6 @@ export function injectStreamMenuButtons() {
const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]');
$menu?.appendChild(await StreamBadges.getInstance().render()); $menu?.appendChild(await StreamBadges.getInstance().render());
hideSettingsFunc();
return; return;
} }
@ -205,13 +184,7 @@ export function injectStreamMenuButtons() {
e.preventDefault(); e.preventDefault();
// Show Stream Settings dialog // Show Stream Settings dialog
$settingsDialog.classList.remove('bx-gone'); StreamSettings.getInstance().show();
$parent?.addEventListener('click', hideSettingsFunc);
//$parent.addEventListener('touchstart', hideSettingsFunc);
const $touchSurface = document.getElementById('MultiTouchSurface');
$touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideSettingsFunc);
}); });
} }
@ -249,37 +222,3 @@ export function injectStreamMenuButtons() {
}); });
observer.observe($screen, {subtree: true, childList: true}); observer.observe($screen, {subtree: true, childList: true});
} }
export function showStreamSettings(tabId: string) {
const $wrapper = document.querySelector('.bx-stream-settings-dialog');
if (!$wrapper) {
return;
}
// Select tab
if (tabId) {
const $tab = $wrapper.querySelector(`.bx-stream-settings-tabs svg[data-group=${tabId}]`);
$tab && $tab.dispatchEvent(new Event('click'));
}
$wrapper.classList.remove('bx-gone');
const $screen = document.querySelector('#PageContent section[class*=PureScreens]');
if ($screen && $screen.parentElement) {
const $parent = $screen.parentElement;
if (!$parent || ($parent as any).bxClick) {
return;
}
($parent as any).bxClick = true;
const onClick = (e: Event) => {
$wrapper.classList.add('bx-gone');
($parent as any).bxClick = false;
$parent.removeEventListener('click', onClick);
};
$parent.addEventListener('click', onClick);
}
}

View File

@ -1,11 +1,15 @@
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";
import { BX_FLAGS } from "@/utils/bx-flags";
import { BxSelectElement } from "@/web-components/bx-select";
import { StreamSettings } from "../stream/stream-settings";
const SETTINGS_UI = { const SETTINGS_UI = {
'Better xCloud': { 'Better xCloud': {
@ -62,8 +66,8 @@ const SETTINGS_UI = {
}, },
[t('touch-controller')]: { [t('touch-controller')]: {
note: !STATES.hasTouchSupport ? '⚠️ ' + t('device-unsupported-touch') : null, note: !STATES.userAgent.capabilities.touch ? '⚠️ ' + t('device-unsupported-touch') : null,
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgent.capabilities.touch,
items: [ items: [
PrefKey.STREAM_TOUCH_CONTROLLER, PrefKey.STREAM_TOUCH_CONTROLLER,
PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF, PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF,
@ -85,17 +89,19 @@ const SETTINGS_UI = {
items: [ items: [
PrefKey.UI_LAYOUT, PrefKey.UI_LAYOUT,
PrefKey.UI_HOME_CONTEXT_MENU_DISABLED, PrefKey.UI_HOME_CONTEXT_MENU_DISABLED,
PrefKey.CONTROLLER_SHOW_CONNECTION_STATUS,
PrefKey.STREAM_SIMPLIFY_MENU, PrefKey.STREAM_SIMPLIFY_MENU,
PrefKey.SKIP_SPLASH_VIDEO, PrefKey.SKIP_SPLASH_VIDEO,
!AppInterface && PrefKey.UI_SCROLLBAR_HIDE, !AppInterface && PrefKey.UI_SCROLLBAR_HIDE,
PrefKey.HIDE_DOTS_ICON, PrefKey.HIDE_DOTS_ICON,
PrefKey.REDUCE_ANIMATIONS, PrefKey.REDUCE_ANIMATIONS,
PrefKey.BLOCK_SOCIAL_FEATURES,
PrefKey.UI_HIDE_SECTIONS,
], ],
}, },
[t('other')]: { [t('other')]: {
items: [ items: [
PrefKey.BLOCK_SOCIAL_FEATURES,
PrefKey.BLOCK_TRACKING, PrefKey.BLOCK_TRACKING,
], ],
}, },
@ -120,19 +126,18 @@ export function setupSettingsUi() {
let $btnReload: HTMLButtonElement; let $btnReload: HTMLButtonElement;
// Setup Settings UI // Setup Settings UI
const $container = CE<HTMLElement>('div', { const $container = CE('div', {
'class': 'bx-settings-container bx-gone', 'class': 'bx-settings-container bx-gone',
}); });
let $updateAvailable; const $wrapper = CE('div', {'class': 'bx-settings-wrapper'},
CE('div', {'class': 'bx-settings-title-wrapper'},
const $wrapper = CE<HTMLElement>('div', {'class': 'bx-settings-wrapper'}, createButton({
CE<HTMLElement>('div', {'class': 'bx-settings-title-wrapper'}, classes: ['bx-settings-title'],
CE('a', { style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST,
'class': 'bx-settings-title', label: 'Better xCloud ' + SCRIPT_VERSION,
'href': SCRIPT_HOME, url: 'https://github.com/redphx/better-xcloud/releases',
'target': '_blank', }),
}, 'Better xCloud ' + SCRIPT_VERSION),
createButton({ createButton({
icon: BxIcon.QUESTION, icon: BxIcon.QUESTION,
style: ButtonStyle.FOCUSABLE, style: ButtonStyle.FOCUSABLE,
@ -141,46 +146,61 @@ export function setupSettingsUi() {
}), }),
) )
); );
$updateAvailable = CE('a', {
'class': 'bx-settings-update bx-gone',
'href': 'https://github.com/redphx/better-xcloud/releases',
'target': '_blank',
});
$wrapper.appendChild($updateAvailable); const topButtons = [];
// "New version available" button
if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) {
// Show new version indicator // Show new version indicator
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) { topButtons.push(createButton({
$updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`; label: `🌟 Version ${PREF_LATEST_VERSION} available`,
$updateAvailable.classList.remove('bx-gone'); style: ButtonStyle.PRIMARY | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH,
url: 'https://github.com/redphx/better-xcloud/releases/latest',
}));
} }
// "Stream settings" button
topButtons.push(createButton({
label: t('stream-settings'),
icon: BxIcon.STREAM_SETTINGS,
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
StreamSettings.getInstance().show();
},
}));
// Buttons for Android app
if (AppInterface) { if (AppInterface) {
// Show Android app settings button // Show Android app settings button
const $btn = createButton({ topButtons.push(createButton({
label: t('android-app-settings'), label: t('android-app-settings'),
icon: BxIcon.STREAM_SETTINGS, icon: BxIcon.STREAM_SETTINGS,
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => { onClick: e => {
AppInterface.openAppSettings && AppInterface.openAppSettings(); AppInterface.openAppSettings && AppInterface.openAppSettings();
}, },
}); }));
$wrapper.appendChild($btn);
} else { } else {
// Show link to Android app // Show link to Android app
const userAgent = UserAgent.getDefault().toLowerCase(); const userAgent = UserAgent.getDefault().toLowerCase();
if (userAgent.includes('android')) { if (userAgent.includes('android')) {
const $btn = createButton({ topButtons.push(createButton({
label: '🔥 ' + t('install-android'), label: '🔥 ' + t('install-android'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
url: 'https://better-xcloud.github.io/android', url: 'https://better-xcloud.github.io/android',
}); }));
$wrapper.appendChild($btn);
} }
} }
if (topButtons.length) {
const $div = CE('div', {class: 'bx-top-buttons'});
for (const $button of topButtons) {
$div.appendChild($button);
}
$wrapper.appendChild($div);
}
const onChange = async (e: Event) => { const onChange = async (e: Event) => {
// Clear PatcherCache; // Clear PatcherCache;
PatcherCache.clear(); PatcherCache.clear();
@ -196,9 +216,12 @@ export function setupSettingsUi() {
Translations.refreshCurrentLocale(); Translations.refreshCurrentLocale();
await Translations.updateTranslations(); await Translations.updateTranslations();
// Don't refresh the page on TV
if (BX_FLAGS.ScriptUi !== 'tv') {
$btnReload.textContent = t('settings-reloading'); $btnReload.textContent = t('settings-reloading');
$btnReload.click(); $btnReload.click();
} }
}
}; };
// Render settings // Render settings
@ -257,7 +280,7 @@ export function setupSettingsUi() {
placeholder: defaultUserAgent, placeholder: defaultUserAgent,
'class': 'bx-settings-custom-user-agent', 'class': 'bx-settings-custom-user-agent',
}); });
$inpCustomUserAgent.addEventListener('change', e => { $inpCustomUserAgent.addEventListener('input', e => {
const profile = $control.value; const profile = $control.value;
const custom = (e.target as HTMLInputElement).value.trim(); const custom = (e.target as HTMLInputElement).value.trim();
@ -288,7 +311,7 @@ export function setupSettingsUi() {
}); });
$control.name = $control.id; $control.name = $control.id;
$control.addEventListener('change', (e: Event) => { $control.addEventListener('input', (e: Event) => {
setPref(settingId, (e.target as HTMLSelectElement).value); setPref(settingId, (e.target as HTMLSelectElement).value);
onChange(e); onChange(e);
}); });
@ -353,10 +376,20 @@ export function setupSettingsUi() {
if (settingNote) { if (settingNote) {
$label.appendChild(CE('b', {}, settingNote)); $label.appendChild(CE('b', {}, settingNote));
} }
const $elm = CE<HTMLElement>('div', {'class': 'bx-settings-row'},
let $elm: HTMLElement;
if ($control instanceof HTMLSelectElement && BX_FLAGS.ScriptUi === 'tv') {
$elm = CE('div', {'class': 'bx-settings-row'},
$label,
BxSelectElement.wrap($control),
);
} else {
$elm = CE('div', {'class': 'bx-settings-row'},
$label, $label,
$control, $control,
); );
}
$wrapper.appendChild($elm); $wrapper.appendChild($elm);
@ -365,7 +398,7 @@ export function setupSettingsUi() {
$wrapper.appendChild($inpCustomUserAgent!); $wrapper.appendChild($inpCustomUserAgent!);
// Trigger 'change' event // Trigger 'change' event
$control.disabled = true; $control.disabled = true;
$control.dispatchEvent(new Event('change')); $control.dispatchEvent(new Event('input'));
$control.disabled = false; $control.disabled = false;
} }
} }
@ -395,13 +428,6 @@ export function setupSettingsUi() {
}, `❤️ ${t('support-better-xcloud')}`); }, `❤️ ${t('support-better-xcloud')}`);
$wrapper.appendChild($donationLink); $wrapper.appendChild($donationLink);
// Show Game Pass app version
try {
const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content;
const appDate = new Date((document.querySelector('meta[name=gamepass-app-date]') as HTMLMetaElement).content).toISOString().substring(0, 10);
$wrapper.appendChild(CE<HTMLElement>('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`));
} catch (e) {}
$container.appendChild($wrapper); $container.appendChild($wrapper);
// Add Settings UI to the web page // Add Settings UI to the web page

View File

@ -1,33 +1,97 @@
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
import { AppInterface, STATES } from "@/utils/global"; import { AppInterface, STATES } from "@/utils/global";
import { createButton, ButtonStyle } from "@/utils/html"; import { createButton, ButtonStyle, CE } from "@/utils/html";
import { t } from "@/utils/translation"; import { t } from "@/utils/translation";
import { StreamSettings } from "../stream/stream-settings";
export enum GuideMenuTab { export enum GuideMenuTab {
HOME, HOME,
} }
export class GuideMenu { export class GuideMenu {
static #BUTTONS = {
streamSetting: createButton({
label: t('stream-settings'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
// Wait until the Guide dialog is closed
window.addEventListener(BxEvent.XCLOUD_DIALOG_DISMISSED, e => {
setTimeout(() => StreamSettings.getInstance().show(), 50);
}, {once: true});
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes.closeAll();
},
}),
appSettings: createButton({
label: t('android-app-settings'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes.closeAll();
AppInterface.openAppSettings && AppInterface.openAppSettings();
},
}),
closeApp: createButton({
label: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
}),
reloadStream: createButton({
label: t('reload-stream'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('confirm-reload-stream')) && window.location.reload();
},
}),
backToHome: createButton({
label: t('back-to-home'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
},
}),
}
static #renderButtons(buttons: HTMLElement[]) {
const $div = CE('div', {});
for (const $button of buttons) {
$div.appendChild($button);
}
return $div;
}
static #injectHome($root: HTMLElement) { static #injectHome($root: HTMLElement) {
// Find the last divider // Find the last divider
const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]'); const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]');
if (!$dividers) { if (!$dividers) {
return; return;
} }
const $lastDivider = $dividers[$dividers.length - 1];
// Add "Close app" button const buttons: HTMLElement[] = [];
// "Stream settings" button
buttons.push(GuideMenu.#BUTTONS.streamSetting);
// "App settings" & "Close app" buttons
if (AppInterface) { if (AppInterface) {
const $btnQuit = createButton({ buttons.push(GuideMenu.#BUTTONS.appSettings);
label: t('close-app'), buttons.push(GuideMenu.#BUTTONS.closeApp);
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
});
$lastDivider.insertAdjacentElement('afterend', $btnQuit);
} }
const $buttons = GuideMenu.#renderButtons(buttons);
const $lastDivider = $dividers[$dividers.length - 1];
$lastDivider.insertAdjacentElement('afterend', $buttons);
} }
static #injectHomePlaying($root: HTMLElement) { static #injectHomePlaying($root: HTMLElement) {
@ -36,25 +100,19 @@ export class GuideMenu {
return; return;
} }
// Add buttons const buttons: HTMLElement[] = [];
const $btnReload = createButton({
label: t('reload-stream'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('confirm-reload-stream')) && window.location.reload();
},
});
const $btnHome = createButton({ buttons.push(GuideMenu.#BUTTONS.streamSetting);
label: t('back-to-home'), AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
},
});
$btnQuit.insertAdjacentElement('afterend', $btnReload); // Reload stream
$btnReload.insertAdjacentElement('afterend', $btnHome); buttons.push(GuideMenu.#BUTTONS.reloadStream);
// Back to home
buttons.push(GuideMenu.#BUTTONS.backToHome);
const $buttons = GuideMenu.#renderButtons(buttons);
$btnQuit.insertAdjacentElement('afterend', $buttons);
// Hide xCloud's Home button // Hide xCloud's Home button
const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement; const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement;
@ -65,7 +123,8 @@ export class GuideMenu {
const where = (e as any).where as GuideMenuTab; const where = (e as any).where as GuideMenuTab;
if (where === GuideMenuTab.HOME) { if (where === GuideMenuTab.HOME) {
const $root = document.querySelector('#gamepass-dialog-root div[role=dialog]') as HTMLElement; const $root = document.querySelector('#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]') as HTMLElement;
if ($root) {
if (STATES.isPlaying) { if (STATES.isPlaying) {
GuideMenu.#injectHomePlaying($root); GuideMenu.#injectHomePlaying($root);
} else { } else {
@ -73,6 +132,7 @@ export class GuideMenu {
} }
} }
} }
}
static observe() { static observe() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown); window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown);

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,18 +1,6 @@
import { AppInterface, STATES } from "@utils/global"; import { CE } from "@utils/html";
import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html"; import { onChangeVideoPlayerType } from "../stream/stream-settings-utils";
import { BxIcon } from "@utils/bx-icon"; import { StreamSettings } from "../stream/stream-settings";
import { UserAgent } from "@utils/user-agent";
import { BxEvent } from "@utils/bx-event";
import { MkbRemapper } from "@modules/mkb/mkb-remapper";
import { getPref, Preferences, PrefKey, toPrefElement } from "@utils/preferences";
import { StreamStats } from "@modules/stream/stream-stats";
import { TouchController } from "@modules/touch-controller";
import { t } from "@utils/translation";
import { VibrationManager } from "@modules/vibration-manager";
import { Screenshot } from "@/utils/screenshot";
import { ControllerShortcut } from "../controller-shortcut";
import { SoundShortcut } from "../shortcuts/shortcut-sound";
import { NativeMkbHandler } from "../mkb/native-mkb-handler";
export function localRedirect(path: string) { export function localRedirect(path: string) {
@ -38,518 +26,10 @@ 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() {
const isSafari = UserAgent.isSafari();
const SETTINGS_UI = [
{
icon: BxIcon.DISPLAY,
group: 'stream',
items: [
{
group: 'audio',
label: t('audio'),
help_url: 'https://better-xcloud.github.io/ingame-features/#audio',
items: [
{
pref: PrefKey.AUDIO_VOLUME,
onChange: (e: any, value: number) => {
SoundShortcut.setGainNodeVolume(value);
},
params: {
disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL),
},
onMounted: ($elm: HTMLElement) => {
const $range = $elm.querySelector('input[type=range') as HTMLInputElement;
window.addEventListener(BxEvent.GAINNODE_VOLUME_CHANGED, e => {
$range.value = (e as any).volume;
BxEvent.dispatch($range, 'input', {
ignoreOnChange: true,
});
});
},
},
],
},
{
group: 'video',
label: t('video'),
help_url: 'https://better-xcloud.github.io/ingame-features/#video',
items: [
{
pref: PrefKey.VIDEO_RATIO,
onChange: updateVideoPlayerCss,
},
{
pref: PrefKey.VIDEO_CLARITY,
onChange: updateVideoPlayerCss,
unsupported: isSafari,
},
{
pref: PrefKey.VIDEO_SATURATION,
onChange: updateVideoPlayerCss,
},
{
pref: PrefKey.VIDEO_CONTRAST,
onChange: updateVideoPlayerCss,
},
{
pref: PrefKey.VIDEO_BRIGHTNESS,
onChange: updateVideoPlayerCss,
},
],
},
],
},
{
icon: BxIcon.CONTROLLER,
group: 'controller',
items: [
{
group: 'controller',
label: t('controller'),
help_url: 'https://better-xcloud.github.io/ingame-features/#controller',
items: [
{
pref: PrefKey.CONTROLLER_ENABLE_VIBRATION,
unsupported: !VibrationManager.supportControllerVibration(),
onChange: VibrationManager.updateGlobalVars,
},
{
pref: PrefKey.CONTROLLER_DEVICE_VIBRATION,
unsupported: !VibrationManager.supportDeviceVibration(),
onChange: VibrationManager.updateGlobalVars,
},
(VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && {
pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY,
unsupported: !VibrationManager.supportDeviceVibration(),
onChange: VibrationManager.updateGlobalVars,
},
],
},
STATES.hasTouchSupport && {
group: 'touch-controller',
label: t('touch-controller'),
items: [
{
label: t('layout'),
content: CE('select', {disabled: true}, CE('option', {}, t('default'))),
onMounted: ($elm: HTMLSelectElement) => {
$elm.addEventListener('change', e => {
TouchController.loadCustomLayout(STATES.currentStream?.xboxTitleId!, $elm.value, 1000);
});
window.addEventListener(BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, e => {
const data = (e as any).data;
if (STATES.currentStream?.xboxTitleId && ($elm as any).xboxTitleId === STATES.currentStream?.xboxTitleId) {
$elm.dispatchEvent(new Event('change'));
return;
}
($elm as any).xboxTitleId = STATES.currentStream?.xboxTitleId;
// Clear options
while ($elm.firstChild) {
$elm.removeChild($elm.firstChild);
}
$elm.disabled = !data;
if (!data) {
$elm.appendChild(CE('option', {value: ''}, t('default')));
$elm.value = '';
$elm.dispatchEvent(new Event('change'));
return;
}
// Add options
const $fragment = document.createDocumentFragment();
for (const key in data.layouts) {
const layout = data.layouts[key];
let name;
if (layout.author) {
name = `${layout.name} (${layout.author})`;
} else {
name = layout.name;
}
const $option = CE('option', {value: key}, name);
$fragment.appendChild($option);
}
$elm.appendChild($fragment);
$elm.value = data.default_layout;
$elm.dispatchEvent(new Event('change'));
});
},
},
],
}
],
},
getPref(PrefKey.MKB_ENABLED) && {
icon: BxIcon.VIRTUAL_CONTROLLER,
group: 'mkb',
items: [
{
group: 'mkb',
label: t('virtual-controller'),
help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/',
content: MkbRemapper.INSTANCE.render(),
},
],
},
AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && {
icon: BxIcon.NATIVE_MKB,
group: 'native-mkb',
items: [
{
group: 'native-mkb',
label: t('native-mkb'),
items: [
{
pref: PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY,
onChange: (e: any, value: number) => {
NativeMkbHandler.getInstance().setVerticalScrollMultiplier(value / 100);
},
},
{
pref: PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY,
onChange: (e: any, value: number) => {
NativeMkbHandler.getInstance().setHorizontalScrollMultiplier(value / 100);
},
},
],
},
],
},
{
icon: BxIcon.COMMAND,
group: 'shortcuts',
items: [
{
group: 'shortcuts_controller',
label: t('controller-shortcuts'),
content: ControllerShortcut.renderSettings(),
},
],
},
{
icon: BxIcon.STREAM_STATS,
group: 'stats',
items: [
{
group: 'stats',
label: t('stream-stats'),
help_url: 'https://better-xcloud.github.io/stream-stats/',
items: [
{
pref: PrefKey.STATS_SHOW_WHEN_PLAYING,
},
{
pref: PrefKey.STATS_QUICK_GLANCE,
onChange: (e: InputEvent) => {
const streamStats = StreamStats.getInstance();
(e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop();
},
},
{
pref: PrefKey.STATS_ITEMS,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_POSITION,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_TEXT_SIZE,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_OPACITY,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_TRANSPARENT,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_CONDITIONAL_FORMATTING,
onChange: StreamStats.refreshStyles,
},
],
},
],
},
];
let $tabs: HTMLElement;
let $settings: HTMLElement;
const $wrapper = CE<HTMLElement>('div', {'class': 'bx-stream-settings-dialog bx-gone'},
$tabs = CE<HTMLElement>('div', {'class': 'bx-stream-settings-tabs'}),
$settings = CE<HTMLElement>('div', {'class': 'bx-stream-settings-tab-contents'}),
);
for (const settingTab of SETTINGS_UI) {
if (!settingTab) {
continue;
}
const $svg = createSvgIcon(settingTab.icon);
$svg.addEventListener('click', e => {
// Switch tab
for (const $child of Array.from($settings.children)) {
if ($child.getAttribute('data-group') === settingTab.group) {
$child.classList.remove('bx-gone');
} else {
$child.classList.add('bx-gone');
}
}
// Highlight current tab button
for (const $child of Array.from($tabs.children)) {
$child.classList.remove('bx-active');
}
$svg.classList.add('bx-active');
});
$tabs.appendChild($svg);
const $group = CE<HTMLElement>('div', {'data-group': settingTab.group, 'class': 'bx-gone'});
for (const settingGroup of settingTab.items) {
if (!settingGroup) {
continue;
}
$group.appendChild(CE('h2', {},
CE('span', {}, settingGroup.label),
settingGroup.help_url && createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST,
url: settingGroup.help_url,
title: t('help'),
}),
));
if (settingGroup.note) {
if (typeof settingGroup.note === 'string') {
settingGroup.note = document.createTextNode(settingGroup.note);
}
$group.appendChild(settingGroup.note);
}
if (settingGroup.content) {
$group.appendChild(settingGroup.content);
continue;
}
if (!settingGroup.items) {
settingGroup.items = [];
}
for (const setting of settingGroup.items) {
if (!setting) {
continue;
}
const pref = setting.pref;
let $control;
if (setting.content) {
$control = setting.content;
} else if (!setting.unsupported) {
$control = toPrefElement(pref, setting.onChange, setting.params);
}
const label = Preferences.SETTINGS[pref as PrefKey]?.label || setting.label;
const note = Preferences.SETTINGS[pref as PrefKey]?.note || setting.note;
const $content = CE('div', {'class': 'bx-stream-settings-row', 'data-type': settingGroup.group},
CE('label', {for: `bx_setting_${pref}`},
label,
note && CE('div', {'class': 'bx-stream-settings-dialog-note'}, note),
setting.unsupported && CE('div', {'class': 'bx-stream-settings-dialog-note'}, t('browser-unsupported-feature')),
),
!setting.unsupported && $control,
);
$group.appendChild($content);
setting.onMounted && setting.onMounted($control);
}
}
$settings.appendChild($group);
}
// Select first tab
$tabs.firstElementChild!.dispatchEvent(new Event('click'));
document.documentElement.appendChild($wrapper);
}
export function updateVideoPlayerCss() {
let $elm = document.getElementById('bx-video-css');
if (!$elm) {
const $fragment = document.createDocumentFragment();
$elm = CE<HTMLStyleElement>('style', {id: 'bx-video-css'});
$fragment.appendChild($elm);
// Setup SVG filters
const $svg = CE('svg', {
'id': 'bx-video-filters',
'xmlns': 'http://www.w3.org/2000/svg',
'class': 'bx-gone',
}, CE('defs', {'xmlns': 'http://www.w3.org/2000/svg'},
CE('filter', {'id': 'bx-filter-clarity', 'xmlns': 'http://www.w3.org/2000/svg'},
CE('feConvolveMatrix', {'id': 'bx-filter-clarity-matrix', 'order': '3', 'xmlns': 'http://www.w3.org/2000/svg'}))
)
);
$fragment.appendChild($svg);
document.documentElement.appendChild($fragment);
}
let filters = getVideoPlayerFilterStyle();
let videoCss = '';
if (filters) {
videoCss += `filter: ${filters} !important;`;
}
// Apply video filters to screenshots
if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
Screenshot.updateCanvasFilters(filters);
}
let css = '';
if (videoCss) {
css = `
#game-stream video {
${videoCss}
}
`;
}
$elm.textContent = css;
resizeVideoPlayer();
}
function resizeVideoPlayer() {
const $video = STATES.currentStream.$video;
if (!$video || !$video.parentElement) {
return;
}
const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO);
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.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;
}
}
function preloadFonts() {
const $link = CE<HTMLLinkElement>('link', {
rel: 'preload',
href: 'https://redphx.github.io/better-xcloud/fonts/promptfont.otf',
as: 'font',
type: 'font/otf',
crossorigin: '',
});
document.querySelector('head')?.appendChild($link);
}
export function setupStreamUi() { export function setupStreamUi() {
// Prevent initializing multiple times StreamSettings.getInstance();
if (!document.querySelector('.bx-stream-settings-dialog')) { onChangeVideoPlayerType();
preloadFonts();
window.addEventListener('resize', updateVideoPlayerCss);
setupStreamSettingsDialog();
Screenshot.setup();
}
updateVideoPlayerCss();
} }
(window as any).localRedirect = localRedirect;

View File

@ -53,7 +53,7 @@ export class VibrationManager {
return !!window.navigator.vibrate; return !!window.navigator.vibrate;
} }
static updateGlobalVars() { static updateGlobalVars(stopVibration: boolean = true) {
window.BX_ENABLE_CONTROLLER_VIBRATION = VibrationManager.supportControllerVibration() ? getPref(PrefKey.CONTROLLER_ENABLE_VIBRATION) : false; window.BX_ENABLE_CONTROLLER_VIBRATION = VibrationManager.supportControllerVibration() ? getPref(PrefKey.CONTROLLER_ENABLE_VIBRATION) : false;
window.BX_VIBRATION_INTENSITY = getPref(PrefKey.CONTROLLER_VIBRATION_INTENSITY) / 100; window.BX_VIBRATION_INTENSITY = getPref(PrefKey.CONTROLLER_VIBRATION_INTENSITY) / 100;
@ -63,7 +63,7 @@ export class VibrationManager {
} }
// Stop vibration // Stop vibration
window.navigator.vibrate(0); stopVibration && window.navigator.vibrate(0);
const value = getPref(PrefKey.CONTROLLER_DEVICE_VIBRATION); const value = getPref(PrefKey.CONTROLLER_DEVICE_VIBRATION);
let enabled; let enabled;
@ -134,10 +134,10 @@ export class VibrationManager {
} }
static initialSetup() { static initialSetup() {
window.addEventListener('gamepadconnected', VibrationManager.updateGlobalVars); window.addEventListener('gamepadconnected', e => VibrationManager.updateGlobalVars());
window.addEventListener('gamepaddisconnected', VibrationManager.updateGlobalVars); window.addEventListener('gamepaddisconnected', e => VibrationManager.updateGlobalVars());
VibrationManager.updateGlobalVars(); VibrationManager.updateGlobalVars(false);
window.addEventListener(BxEvent.DATA_CHANNEL_CREATED, e => { window.addEventListener(BxEvent.DATA_CHANNEL_CREATED, e => {
const dataChannel = (e as any).dataChannel; const dataChannel = (e as any).dataChannel;

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

@ -28,8 +28,19 @@ type BxStates = {
appContext: any | null; appContext: any | null;
serverRegions: any; serverRegions: any;
hasTouchSupport: boolean; browser: {
browserHasTouchSupport: boolean; capabilities: {
touch: boolean;
batteryApi: boolean;
};
};
userAgent: {
isTv: boolean;
capabilities: {
touch: boolean;
};
};
currentStream: Partial<{ currentStream: Partial<{
titleId: string; titleId: string;
@ -37,7 +48,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 +62,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 +73,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 +90,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

@ -1,6 +1,6 @@
import { ControllerShortcut } from "@/modules/controller-shortcut"; import { ControllerShortcut } from "@/modules/controller-shortcut";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { STATES } from "@utils/global"; import { deepClone, STATES } from "@utils/global";
import { getPref, PrefKey } from "@utils/preferences"; import { getPref, PrefKey } from "@utils/preferences";
import { BxLogger } from "./bx-logger"; import { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags"; import { BX_FLAGS } from "./bx-flags";
@ -19,11 +19,11 @@ export const BxExposed = {
modifyTitleInfo: (titleInfo: XcloudTitleInfo): XcloudTitleInfo => { modifyTitleInfo: (titleInfo: XcloudTitleInfo): XcloudTitleInfo => {
// Clone the object since the original is read-only // Clone the object since the original is read-only
titleInfo = structuredClone(titleInfo); titleInfo = deepClone(titleInfo);
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.userAgent.capabilities.touch) {
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);
@ -108,4 +109,10 @@ export const BxExposed = {
handleControllerShortcut: ControllerShortcut.handle, handleControllerShortcut: ControllerShortcut.handle,
resetControllerShortcut: ControllerShortcut.reset, resetControllerShortcut: ControllerShortcut.reset,
overrideSettings: {
'Tv_settings': {
hasCompletedOnboarding: true,
},
},
}; };

View File

@ -8,6 +8,11 @@ type BxFlags = Partial<{
UseDevTouchLayout: boolean; UseDevTouchLayout: boolean;
ForceNativeMkbTitles: string[]; ForceNativeMkbTitles: string[];
FeatureGates: {[key: string]: boolean} | null,
ScriptUi: 'default' | 'tv',
IsSupportedTvBrowser: boolean,
}> }>
// Setup flags // Setup flags
@ -21,9 +26,12 @@ const DEFAULT_FLAGS: BxFlags = {
UseDevTouchLayout: false, UseDevTouchLayout: false,
ForceNativeMkbTitles: [], ForceNativeMkbTitles: [],
FeatureGates: null,
ScriptUi: 'default',
} }
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

@ -1,22 +1,33 @@
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { renderStylus } from "@macros/build" with {type: "macro"}; import { renderStylus } from "@macros/build" with {type: "macro"};
import { UiSection } from "@/enums/ui-sections";
export function addCss() { export function addCss() {
let css = renderStylus(); const STYLUS_CSS = renderStylus();
let css = STYLUS_CSS;
const PREF_HIDE_SECTIONS = getPref(PrefKey.UI_HIDE_SECTIONS);
const selectorToHide = [];
// Hide "News" section
if (PREF_HIDE_SECTIONS.includes(UiSection.NEWS)) {
selectorToHide.push('#BodyContent > div[class*=CarouselRow-module]');
}
// Hide "All games" section
if (PREF_HIDE_SECTIONS.includes(UiSection.ALL_GAMES)) {
selectorToHide.push('#BodyContent div[class*=AllGamesRow-module__gridContainer]');
}
// Hide "Start a party" button in the Guide menu
if (getPref(PrefKey.BLOCK_SOCIAL_FEATURES)) { if (getPref(PrefKey.BLOCK_SOCIAL_FEATURES)) {
css += ` selectorToHide.push('#gamepass-dialog-root div[class^=AchievementsPreview-module__container] + button[class*=HomeLandingPage-module__button]');
/* Hide "Play with friends" section */ }
div[class^=HomePage-module__bottomSpacing]:has(button[class*=SocialEmptyCard]),
button[class*=SocialEmptyCard], if (selectorToHide) {
/* Hide "Start a party" button in the Guide menu */ css += selectorToHide.join(',') + '{ display: none; }';
#gamepass-dialog-root div[class^=AchievementsPreview-module__container] + button[class*=HomeLandingPage-module__button]
{
display: none;
}
`;
} }
// Reduce animations // Reduce animations
@ -139,3 +150,16 @@ body::-webkit-scrollbar {
const $style = CE('style', {}, css); const $style = CE('style', {}, css);
document.documentElement.appendChild($style); document.documentElement.appendChild($style);
} }
export function preloadFonts() {
const $link = CE<HTMLLinkElement>('link', {
rel: 'preload',
href: 'https://redphx.github.io/better-xcloud/fonts/promptfont.otf',
as: 'font',
type: 'font/otf',
crossorigin: '',
});
document.querySelector('head')?.appendChild($link);
}

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,41 @@ 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,
browserHasTouchSupport: browserHasTouchSupport, browser: {
capabilities: {
touch: browserHasTouchSupport,
batteryApi: 'getBattery' in window.navigator,
},
},
userAgent: {
isTv: isTv,
capabilities: {
touch: userAgentHasTouchSupport,
}
},
currentStream: {}, currentStream: {},
remotePlay: {}, remotePlay: {},
pointerServerPort: 9269,
}; };
export function deepClone(obj: any): any {
if ('structuredClone' in window) {
return structuredClone(obj);
}
if (!obj) {
return {};
}
return JSON.parse(JSON.stringify(obj));
}

View File

@ -9,6 +9,7 @@ type BxButton = {
title?: string; title?: string;
disabled?: boolean; disabled?: boolean;
onClick?: EventListener; onClick?: EventListener;
attributes?: {[key: string]: any},
} }
type ButtonStyle = {[index: string]: number} & {[index: number]: string}; type ButtonStyle = {[index: string]: number} & {[index: number]: string};
@ -94,6 +95,12 @@ export const createButton = <T=HTMLButtonElement>(options: BxButton): T => {
options.disabled && (($btn as HTMLButtonElement).disabled = true); options.disabled && (($btn as HTMLButtonElement).disabled = true);
options.onClick && $btn.addEventListener('click', options.onClick); options.onClick && $btn.addEventListener('click', options.onClick);
for (const key in options.attributes) {
if (!$btn.hasOwnProperty(key)) {
$btn.setAttribute(key, options.attributes[key]);
}
}
return $btn as T; return $btn as T;
} }

View File

@ -2,7 +2,8 @@ import { BxEvent } from "@utils/bx-event";
import { getPref, PrefKey } from "@utils/preferences"; 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, setCodecPreferences } 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);
@ -10,18 +11,26 @@ export function patchVideoApi() {
// Show video player when it's ready // Show video player when it's ready
const showFunc = function(this: HTMLVideoElement) { const showFunc = function(this: HTMLVideoElement) {
this.style.visibility = 'visible'; this.style.visibility = 'visible';
this.removeEventListener('playing', showFunc);
if (!this.videoWidth) { if (!this.videoWidth) {
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 +44,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('loadedmetadata', showFunc, {once: true});
} }
this.addEventListener('playing', showFunc);
return nativePlay.apply(this); return nativePlay.apply(this);
}; };
} }
@ -55,33 +64,6 @@ export function patchRtcCodecs() {
if (typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) { if (typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) {
return false; return false;
} }
const profilePrefix = codecProfile === 'high' ? '4d' : (codecProfile === 'low' ? '420' : '42e');
const profileLevelId = `profile-level-id=${profilePrefix}`;
const nativeSetCodecPreferences = RTCRtpTransceiver.prototype.setCodecPreferences;
RTCRtpTransceiver.prototype.setCodecPreferences = function(codecs) {
// Use the same codecs as desktop
const newCodecs = codecs.slice();
let pos = 0;
newCodecs.forEach((codec, i) => {
// Find high-quality codecs
if (codec.sdpFmtpLine && codec.sdpFmtpLine.includes(profileLevelId)) {
// Move it to the top of the array
newCodecs.splice(i, 1);
newCodecs.splice(pos, 0, codec);
++pos;
}
});
try {
nativeSetCodecPreferences.apply(this, [newCodecs]);
} catch (e) {
// Didn't work -> use default codecs
BxLogger.error('setCodecPreferences', e);
nativeSetCodecPreferences.apply(this, [codecs]);
}
}
} }
export function patchRtcPeerConnection() { export function patchRtcPeerConnection() {
@ -97,21 +79,32 @@ export function patchRtcPeerConnection() {
return dataChannel; return dataChannel;
} }
const maxVideoBitrate = getPref(PrefKey.BITRATE_VIDEO_MAX);
const codec = getPref(PrefKey.STREAM_CODEC_PROFILE);
if (codec !== 'default' || maxVideoBitrate > 0) {
const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription; const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription;
RTCPeerConnection.prototype.setLocalDescription = function(description?: RTCLocalSessionDescriptionInit): Promise<void> { RTCPeerConnection.prototype.setLocalDescription = function(description?: RTCLocalSessionDescriptionInit): Promise<void> {
// Set preferred codec profile
if (codec !== 'default') {
arguments[0].sdp = setCodecPreferences(arguments[0].sdp, codec);
}
// set maximum bitrate // set maximum bitrate
try { try {
const maxVideoBitrate = getPref(PrefKey.BITRATE_VIDEO_MAX); if (maxVideoBitrate > 0 && description) {
if (maxVideoBitrate > 0) {
arguments[0].sdp = patchSdpBitrate(arguments[0].sdp, Math.round(maxVideoBitrate / 1000)); arguments[0].sdp = patchSdpBitrate(arguments[0].sdp, Math.round(maxVideoBitrate / 1000));
} }
} catch (e) { } catch (e) {
BxLogger.error('setLocalDescription', e); BxLogger.error('setLocalDescription', e);
} }
BxLogger.info('setLocalDescription', arguments[0].sdp);
// @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 +125,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 +157,14 @@ export function patchMeControl() {
}; };
const MSA = { const MSA = {
MeControl: {}, MeControl: {
API: {
setDisplayMode: () => {},
setMobileState: () => {},
addEventListener: () => {},
removeEventListener: () => {},
},
},
}; };
const MeControl = {}; const MeControl = {};
@ -207,7 +211,7 @@ 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 // Use low-power profile for touch controller
@ -215,6 +219,7 @@ export function patchCanvasContext() {
contextAttributes.powerPreference = 'low-power'; contextAttributes.powerPreference = 'low-power';
} }
} }
}
return nativeGetContext.apply(this, [contextType, contextAttributes]); return nativeGetContext.apply(this, [contextType, contextAttributes]);
} }

View File

@ -7,8 +7,10 @@ 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";
import { BxLogger } from "./bx-logger";
enum RequestType { enum RequestType {
XCLOUD = 'xcloud', XCLOUD = 'xcloud',
@ -94,7 +96,7 @@ function updateIceCandidates(candidates: any, options: any) {
newCandidates.push(newCandidate('a=end-of-candidates')); newCandidates.push(newCandidate('a=end-of-candidates'));
console.log(newCandidates); BxLogger.info('ICE Candidates', newCandidates);
return newCandidates; return newCandidates;
} }
@ -156,6 +158,10 @@ class XhomeInterceptor {
console.log(obj); console.log(obj);
const serverDetails = obj.serverDetails; const serverDetails = obj.serverDetails;
if (serverDetails.ipAddress) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipAddress] = serverDetails.port;
}
if (serverDetails.ipV4Address) { if (serverDetails.ipV4Address) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipV4Address] = serverDetails.ipV4Port; XhomeInterceptor.#consoleAddrs[serverDetails.ipV4Address] = serverDetails.ipV4Port;
} }
@ -440,7 +446,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' || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
overrideMkb = true; overrideMkb = true;
} }
@ -455,9 +461,6 @@ class XcloudInterceptor {
}); });
} }
overrides.videoConfiguration = overrides.videoConfiguration || {};
overrides.videoConfiguration.setCodecPreferences = true;
// Enable touch controller // Enable touch controller
if (TouchController.isEnabled()) { if (TouchController.isEnabled()) {
overrides.inputConfiguration.enableTouchInput = true; overrides.inputConfiguration.enableTouchInput = true;
@ -578,14 +581,10 @@ 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} = {}; if (json && json.exp && json.exp.treatments) {
for (const key in FeatureGates) {
if (getPref(PrefKey.UI_HOME_CONTEXT_MENU_DISABLED)) { json.exp.treatments[key] = FeatureGates[key]
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 +595,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.userAgent.capabilities.touch && 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,13 @@
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";
import { UiSection } from "@/enums/ui-sections";
export enum PrefKey { export enum PrefKey {
LAST_UPDATE_CHECK = 'version_last_check', LAST_UPDATE_CHECK = 'version_last_check',
@ -43,6 +46,7 @@ export enum PrefKey {
CONTROLLER_ENABLE_VIBRATION = 'controller_enable_vibration', CONTROLLER_ENABLE_VIBRATION = 'controller_enable_vibration',
CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration', CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration',
CONTROLLER_VIBRATION_INTENSITY = 'controller_vibration_intensity', CONTROLLER_VIBRATION_INTENSITY = 'controller_vibration_intensity',
CONTROLLER_SHOW_CONNECTION_STATUS = 'controller_show_connection_status',
NATIVE_MKB_ENABLED = 'native_mkb_enabled', NATIVE_MKB_ENABLED = 'native_mkb_enabled',
NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY = 'native_mkb_scroll_x_sensitivity', NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY = 'native_mkb_scroll_x_sensitivity',
@ -67,10 +71,13 @@ export enum PrefKey {
UI_LAYOUT = 'ui_layout', UI_LAYOUT = 'ui_layout',
UI_SCROLLBAR_HIDE = 'ui_scrollbar_hide', UI_SCROLLBAR_HIDE = 'ui_scrollbar_hide',
UI_HIDE_SECTIONS = 'ui_hide_sections',
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',
@ -166,7 +173,7 @@ export class Preferences {
default: t('default'), default: t('default'),
}; };
if (!('getCapabilities' in RTCRtpReceiver) || typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) { if (!('getCapabilities' in RTCRtpReceiver)) {
return options; return options;
} }
@ -263,7 +270,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.userAgent.capabilities.touch,
ready: (setting: PreferenceSetting) => { ready: (setting: PreferenceSetting) => {
if (setting.unsupported) { if (setting.unsupported) {
setting.default = 'default'; setting.default = 'default';
@ -273,7 +280,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.userAgent.capabilities.touch,
}, },
[PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: { [PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: {
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
@ -287,7 +294,7 @@ export class Preferences {
ticks: 10, ticks: 10,
hideSlider: true, hideSlider: true,
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgent.capabilities.touch,
}, },
[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 +304,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.userAgent.capabilities.touch,
}, },
[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 +313,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.userAgent.capabilities.touch,
}, },
[PrefKey.STREAM_SIMPLIFY_MENU]: { [PrefKey.STREAM_SIMPLIFY_MENU]: {
@ -380,6 +387,11 @@ export class Preferences {
}, },
*/ */
[PrefKey.CONTROLLER_SHOW_CONNECTION_STATUS]: {
label: t('show-controller-connection-status'),
default: true,
},
[PrefKey.CONTROLLER_ENABLE_SHORTCUTS]: { [PrefKey.CONTROLLER_ENABLE_SHORTCUTS]: {
default: false, default: false,
}, },
@ -544,7 +556,21 @@ 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.browser.capabilities.touch,
},
[PrefKey.UI_HIDE_SECTIONS]: {
label: t('hide-sections'),
default: [],
multipleOptions: {
[UiSection.NEWS]: t('section-news'),
[UiSection.FRIENDS]: t('section-play-with-friends'),
// [UiSection.MOST_POPULAR]: t('section-most-popular'),
[UiSection.ALL_GAMES]: t('section-all-games'),
},
params: {
size: 3,
},
}, },
[PrefKey.BLOCK_SOCIAL_FEATURES]: { [PrefKey.BLOCK_SOCIAL_FEATURES]: {
@ -563,26 +589,45 @@ export class Preferences {
[UserAgentProfile.DEFAULT]: t('default'), [UserAgentProfile.DEFAULT]: t('default'),
[UserAgentProfile.WINDOWS_EDGE]: 'Edge + Windows', [UserAgentProfile.WINDOWS_EDGE]: 'Edge + Windows',
[UserAgentProfile.MACOS_SAFARI]: 'Safari + macOS', [UserAgentProfile.MACOS_SAFARI]: 'Safari + macOS',
[UserAgentProfile.SMARTTV_GENERIC]: 'Smart TV', [UserAgentProfile.SMART_TV_GENERIC]: 'Smart TV',
[UserAgentProfile.SMARTTV_TIZEN]: 'Samsung Smart TV', [UserAgentProfile.SMART_TV_TIZEN]: 'Samsung Smart TV',
[UserAgentProfile.VR_OCULUS]: 'Meta Quest VR', [UserAgentProfile.VR_OCULUS]: 'Meta Quest VR',
[UserAgentProfile.ANDROID_KIWI_V123]: 'Kiwi Browser v123',
[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,
customTextValue: (value: any) => {
value = parseInt(value);
return value === 0 ? t('off') : value.toString();
},
}, },
}, },
[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 { deepClone, 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.userAgent.capabilities.touch) {
try { try {
const sigls = state.xcloud.sigls; const sigls = state.xcloud.sigls;
if (GamePassCloudGallery.TOUCH in sigls) { if (GamePassCloudGallery.TOUCH in sigls) {
@ -59,7 +59,7 @@ export function overridePreloadState() {
// @ts-ignore // @ts-ignore
_state = state; _state = state;
STATES.appContext = structuredClone(state.appContext); STATES.appContext = deepClone(state.appContext);
} }
}); });
} }

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, { once: true });
$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

@ -1,5 +1,59 @@
export function setCodecPreferences(sdp: string, preferredCodec: string) {
const h264Pattern = /a=fmtp:(\d+).*profile-level-id=([0-9a-f]{6})/g;
const profilePrefix = preferredCodec === 'high' ? '4d' : (preferredCodec === 'low' ? '420' : '42e');
const preferredCodecIds: string[] = [];
// Find all H.264 codec profile IDs
const matches = sdp.matchAll(h264Pattern) || [];
for (const match of matches) {
const id = match[1];
const profileId = match[2];
if (profileId.startsWith(profilePrefix)) {
preferredCodecIds.push(id);
}
}
// No preferred IDs found
if (!preferredCodecIds.length) {
return sdp;
}
const lines = sdp.split('\r\n');
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
if (!line.startsWith('m=video')) {
continue;
}
// https://datatracker.ietf.org/doc/html/rfc4566#section-5.14
// m=<media> <port> <proto> <fmt>
// m=video 9 UDP/TLS/RTP/SAVPF 127 39 102 104 106 108
const tmp = line.trim().split(' ');
// Get array of <fmt>
// ['127', '39', '102', '104', '106', '108']
let ids = tmp.slice(3);
// Remove preferred IDs in the original array
ids = ids.filter(item => !preferredCodecIds.includes(item));
// Put preferred IDs at the beginning
ids = preferredCodecIds.concat(ids);
// Update line's content
lines[lineIndex] = tmp.slice(0, 3).concat(ids).join(' ');
break;
}
return lines.join('\r\n');
}
export function patchSdpBitrate(sdp: string, video?: number, audio?: number) { export function patchSdpBitrate(sdp: string, video?: number, audio?: number) {
const lines = sdp.split('\n'); const lines = sdp.split('\r\n');
const mediaSet: Set<string> = new Set(); const mediaSet: Set<string> = new Set();
!!video && mediaSet.add('video'); !!video && mediaSet.add('video');
@ -57,5 +111,5 @@ export function patchSdpBitrate(sdp: string, video?: number, audio?: number) {
} }
} }
return lines.join('\n'); return lines.join('\r\n');
} }

View File

@ -27,7 +27,7 @@ export enum SettingElementType {
export class SettingElement { export class SettingElement {
static #renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) { static #renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) {
const $control = CE<HTMLSelectElement>('select', { const $control = CE<HTMLSelectElement>('select', {
title: setting.label, // title: setting.label,
tabindex: 0, tabindex: 0,
}) as HTMLSelectElement; }) as HTMLSelectElement;
for (let value in setting.options) { for (let value in setting.options) {
@ -38,7 +38,7 @@ export class SettingElement {
} }
$control.value = currentValue; $control.value = currentValue;
onChange && $control.addEventListener('change', e => { onChange && $control.addEventListener('input', e => {
const target = e.target as HTMLSelectElement; const target = e.target as HTMLSelectElement;
const value = (setting.type && setting.type === 'number') ? parseInt(target.value) : target.value; const value = (setting.type && setting.type === 'number') ? parseInt(target.value) : target.value;
onChange(e, value); onChange(e, value);
@ -54,7 +54,7 @@ export class SettingElement {
static #renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}) { static #renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}) {
const $control = CE<HTMLSelectElement>('select', { const $control = CE<HTMLSelectElement>('select', {
title: setting.label, // title: setting.label,
multiple: true, multiple: true,
tabindex: 0, tabindex: 0,
}); });
@ -76,7 +76,7 @@ export class SettingElement {
const $parent = target.parentElement!; const $parent = target.parentElement!;
$parent.focus(); $parent.focus();
$parent.dispatchEvent(new Event('change')); $parent.dispatchEvent(new Event('input'));
}); });
$control.appendChild($option); $control.appendChild($option);
@ -90,7 +90,7 @@ export class SettingElement {
$control.addEventListener('mousemove', e => e.preventDefault()); $control.addEventListener('mousemove', e => e.preventDefault());
onChange && $control.addEventListener('change', (e: Event) => { onChange && $control.addEventListener('input', (e: Event) => {
const target = e.target as HTMLSelectElement const target = e.target as HTMLSelectElement
const values = Array.from(target.selectedOptions).map(i => i.value); const values = Array.from(target.selectedOptions).map(i => i.value);
onChange(e, values); onChange(e, values);
@ -132,10 +132,12 @@ export class SettingElement {
options.hideSlider = !!options.hideSlider; options.hideSlider = !!options.hideSlider;
let $text: HTMLSpanElement; let $text: HTMLSpanElement;
let $decBtn: HTMLButtonElement; let $btnDec: HTMLButtonElement;
let $incBtn: HTMLButtonElement; let $btnInc: HTMLButtonElement;
let $range: HTMLInputElement; let $range: HTMLInputElement;
let controlValue = value;
const MIN = setting.min!; const MIN = setting.min!;
const MAX = setting.max!; const MAX = setting.max!;
const STEPS = Math.max(setting.steps || 1, 1); const STEPS = Math.max(setting.steps || 1, 1);
@ -155,14 +157,19 @@ export class SettingElement {
return textContent; return textContent;
}; };
const $wrapper = CE('div', {'class': 'bx-number-stepper'}, const updateButtonsVisibility = () => {
$decBtn = CE('button', { $btnDec.classList.toggle('bx-hidden', controlValue === MIN);
$btnInc.classList.toggle('bx-hidden', controlValue === MAX);
}
const $wrapper = CE('div', {'class': 'bx-number-stepper', id: `bx_setting_${key}`},
$btnDec = CE('button', {
'data-type': 'dec', 'data-type': 'dec',
type: 'button', type: 'button',
tabindex: -1, tabindex: -1,
}, '-') as HTMLButtonElement, }, '-') as HTMLButtonElement,
$text = CE('span', {}, renderTextValue(value)) as HTMLSpanElement, $text = CE('span', {}, renderTextValue(value)) as HTMLSpanElement,
$incBtn = CE('button', { $btnInc = CE('button', {
'data-type': 'inc', 'data-type': 'inc',
type: 'button', type: 'button',
tabindex: -1, tabindex: -1,
@ -182,6 +189,9 @@ export class SettingElement {
$range.addEventListener('input', e => { $range.addEventListener('input', e => {
value = parseInt((e.target as HTMLInputElement).value); value = parseInt((e.target as HTMLInputElement).value);
controlValue = value;
updateButtonsVisibility();
$text.textContent = renderTextValue(value); $text.textContent = renderTextValue(value);
!(e as any).ignoreOnChange && onChange && onChange(e, value); !(e as any).ignoreOnChange && onChange && onChange(e, value);
}); });
@ -212,14 +222,16 @@ export class SettingElement {
} }
if (options.disabled) { if (options.disabled) {
$incBtn.disabled = true; $btnInc.disabled = true;
$incBtn.classList.add('bx-hidden'); $btnInc.classList.add('bx-hidden');
$decBtn.disabled = true; $btnDec.disabled = true;
$decBtn.classList.add('bx-hidden'); $btnDec.classList.add('bx-hidden');
return $wrapper; return $wrapper;
} }
updateButtonsVisibility();
let interval: number; let interval: number;
let isHolding = false; let isHolding = false;
@ -231,19 +243,19 @@ export class SettingElement {
return; return;
} }
let value: number; const $btn = e.target as HTMLElement;
if ($range) { let value = parseInt(controlValue);
value = parseInt($range.value);
} else { const btnType = $btn.dataset.type;
value = parseInt($text.textContent!);
}
const btnType = (e.target as HTMLElement).getAttribute('data-type');
if (btnType === 'dec') { if (btnType === 'dec') {
value = Math.max(MIN, value - STEPS); value = Math.max(MIN, value - STEPS);
} else { } else {
value = Math.min(MAX, value + STEPS); value = Math.min(MAX, value + STEPS);
} }
controlValue = value;
updateButtonsVisibility();
$text.textContent = renderTextValue(value); $text.textContent = renderTextValue(value);
$range && ($range.value = value.toString()); $range && ($range.value = value.toString());
@ -277,19 +289,21 @@ export class SettingElement {
// Custom method // Custom method
($wrapper as any).setValue = (value: any) => { ($wrapper as any).setValue = (value: any) => {
controlValue = parseInt(value);
$text.textContent = renderTextValue(value); $text.textContent = renderTextValue(value);
$range && ($range.value = value); $range && ($range.value = value);
}; };
$decBtn.addEventListener('click', onClick); $btnDec.addEventListener('click', onClick);
$decBtn.addEventListener('pointerdown', onMouseDown); $btnDec.addEventListener('pointerdown', onMouseDown);
$decBtn.addEventListener('pointerup', onMouseUp); $btnDec.addEventListener('pointerup', onMouseUp);
$decBtn.addEventListener('contextmenu', onContextMenu); $btnDec.addEventListener('contextmenu', onContextMenu);
$incBtn.addEventListener('click', onClick); $btnInc.addEventListener('click', onClick);
$incBtn.addEventListener('pointerdown', onMouseDown); $btnInc.addEventListener('pointerdown', onMouseDown);
$incBtn.addEventListener('pointerup', onMouseUp); $btnInc.addEventListener('pointerup', onMouseUp);
$incBtn.addEventListener('contextmenu', onContextMenu); $btnInc.addEventListener('contextmenu', onContextMenu);
return $wrapper; return $wrapper;
} }

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,13 @@ const Texts = {
"activated": "Activated", "activated": "Activated",
"active": "Active", "active": "Active",
"advanced": "Advanced", "advanced": "Advanced",
"always-off": "Always off",
"always-on": "Always on",
"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 +54,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",
@ -104,6 +110,7 @@ const Texts = {
"hide": "Hide", "hide": "Hide",
"hide-idle-cursor": "Hide mouse cursor on idle", "hide-idle-cursor": "Hide mouse cursor on idle",
"hide-scrollbar": "Hide web page's scrollbar", "hide-scrollbar": "Hide web page's scrollbar",
"hide-sections": "Hide sections",
"hide-system-menu-icon": "Hide System menu's icon", "hide-system-menu-icon": "Hide System menu's icon",
"hide-touch-controller": "Hide touch controller", "hide-touch-controller": "Hide touch controller",
"horizontal-scroll-sensitivity": "Horizontal scroll sensitivity", "horizontal-scroll-sensitivity": "Horizontal scroll sensitivity",
@ -152,28 +159,28 @@ 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) => `Pressione ${e.key} para alternar este recurso`,
, (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} щоб перемкнути цю функцію`,
(e: any) => `Nhấn ${e.key} để bật/tắt tính năng này`, (e: any) => `Nhấn ${e.key} để bật/tắt tính năng này`,
, (e: any) => `按下 ${e.key} 来切换此功能`,
], ],
"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",
@ -185,14 +192,20 @@ const Texts = {
"save": "Save", "save": "Save",
"screen": "Screen", "screen": "Screen",
"screenshot-apply-filters": "Applies video filters to screenshots", "screenshot-apply-filters": "Applies video filters to screenshots",
"section-all-games": "All games",
"section-most-popular": "Most popular",
"section-news": "News",
"section-play-with-friends": "Play with friends",
"separate-touch-controller": "Separate Touch controller & Controller #1", "separate-touch-controller": "Separate Touch controller & Controller #1",
"separate-touch-controller-note": "Touch controller is Player 1, Controller #1 is Player 2", "separate-touch-controller-note": "Touch controller is Player 1, Controller #1 is Player 2",
"server": "Server", "server": "Server",
"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-controller-connection-status": "Show controller connection status",
"show-game-art": "Show game art", "show-game-art": "Show game art",
"show-hide": "Show/hide", "show-hide": "Show/hide",
"show-stats-on-startup": "Show stats when starting the game", "show-stats-on-startup": "Show stats when starting the game",
@ -218,7 +231,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 +275,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 +291,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 {
@ -369,8 +383,12 @@ export class Translations {
const resp = await NATIVE_FETCH(`https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/translations/${locale}.json`); const resp = await NATIVE_FETCH(`https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/translations/${locale}.json`);
const translations = await resp.json(); const translations = await resp.json();
// Prevent saving incorrect translations
let currentLocale = localStorage.getItem(Translations.#KEY_LOCALE);
if (currentLocale === locale) {
window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations)); window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations));
Translations.#foreignTranslations = translations; Translations.#foreignTranslations = translations;
}
return true; return true;
} catch (e) { } catch (e) {
debugger; debugger;
@ -390,5 +408,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,18 +1,13 @@
import { UserAgentProfile } from "@enums/user-agent";
import { deepClone } from "./global";
import { BX_FLAGS } from "./bx-flags";
type UserAgentConfig = { type UserAgentConfig = {
profile: UserAgentProfile, profile: UserAgentProfile,
custom?: string, custom?: string,
}; };
export enum UserAgentProfile { const SMART_TV_UNIQUE_ID = 'FC4A1DA2-711C-4E9C-BC7F-047AF8A672EA';
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')) {
@ -27,13 +22,16 @@ 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',
[UserAgentProfile.SMARTTV_GENERIC]: window.navigator.userAgent + ' SmartTV', [UserAgentProfile.SMART_TV_GENERIC]: `${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.SMART_TV_TIZEN]: `Mozilla/5.0 (SMART-TV; LINUX; Tizen 7.0) AppleWebKit/537.36 (KHTML, like Gecko) ${CHROMIUM_VERSION}/7.0 TV Safari/537.36 ${SMART_TV_UNIQUE_ID}`,
[UserAgentProfile.VR_OCULUS]: window.navigator.userAgent + ' OculusBrowser VR', [UserAgentProfile.VR_OCULUS]: window.navigator.userAgent + ' OculusBrowser VR',
[UserAgentProfile.ANDROID_KIWI_V123]: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.118 Mobile Safari/537.36',
} }
static init() { static init() {
@ -50,7 +48,7 @@ export class UserAgent {
} }
static updateStorage(profile: UserAgentProfile, custom?: string) { static updateStorage(profile: UserAgentProfile, custom?: string) {
const clonedConfig = structuredClone(UserAgent.#config); const clonedConfig = deepClone(UserAgent.#config);
clonedConfig.profile = profile; clonedConfig.profile = profile;
if (typeof custom !== 'undefined') { if (typeof custom !== 'undefined') {
@ -79,20 +77,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() {
@ -101,7 +119,12 @@ export class UserAgent {
return; return;
} }
const newUserAgent = UserAgent.get(profile); let newUserAgent = UserAgent.get(profile);
// Pretend to be Tizen TV
if (BX_FLAGS.IsSupportedTvBrowser) {
newUserAgent += ` SmartTV ${SMART_TV_UNIQUE_ID}`;
}
// Clear data of navigator.userAgentData, force xCloud to detect browser based on navigator.userAgent // Clear data of navigator.userAgentData, force xCloud to detect browser based on navigator.userAgent
(window.navigator as any).orgUserAgentData = (window.navigator as any).userAgentData; (window.navigator as any).orgUserAgentData = (window.navigator as any).userAgentData;

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

@ -0,0 +1,115 @@
import { ButtonStyle, CE, createButton } from "@utils/html";
export class BxSelectElement {
static wrap($select: HTMLSelectElement) {
const $btnPrev = createButton({
label: '<',
style: ButtonStyle.FOCUSABLE,
attributes: {
tabindex: 0,
},
});
const $btnNext = createButton({
label: '>',
style: ButtonStyle.FOCUSABLE,
attributes: {
tabindex: 0,
},
});
const isMultiple = $select.multiple;
let visibleIndex = $select.selectedIndex;
let $checkBox: HTMLInputElement;
let $label: HTMLElement;
const $content = CE('div', {},
$checkBox = CE('input', {type: 'checkbox', id: $select.id + '_checkbox'}),
$label = CE('label', {for: $select.id + '_checkbox'}, ''),
);
isMultiple && $checkBox.addEventListener('input', e => {
const $option = getOptionAtIndex(visibleIndex);
$option && ($option.selected = (e.target as HTMLInputElement).checked);
$select.dispatchEvent(new Event('input'));
});
// Only show checkbox in "multiple" <select>
$checkBox.classList.toggle('bx-gone', !isMultiple);
const getOptionAtIndex = (index: number): HTMLOptionElement | undefined => {
return $select.querySelector(`option:nth-of-type(${visibleIndex + 1})`) as HTMLOptionElement;
}
const render = () => {
// console.log('options', this.options, 'selectedIndices', this.selectedIndices, 'selectedOptions', this.selectedOptions);
visibleIndex = normalizeIndex(visibleIndex);
const $option = getOptionAtIndex(visibleIndex);
let content = '';
if ($option) {
content = $option.textContent || '';
}
$label.textContent = content;
// Hide checkbox when the selection is empty
isMultiple && ($checkBox.checked = $option?.selected || false);
$checkBox.classList.toggle('bx-gone', !isMultiple || !content);
const disablePrev = visibleIndex <= 0;
const disableNext = visibleIndex === $select.querySelectorAll('option').length - 1;
$btnPrev.classList.toggle('bx-inactive', disablePrev);
disablePrev && $btnNext.focus();
$btnNext.classList.toggle('bx-inactive', disableNext);
disableNext && $btnPrev.focus();
}
const normalizeIndex = (index: number): number => {
return Math.min(Math.max(index, 0), $select.querySelectorAll('option').length - 1);
}
const onPrevNext = (e: Event) => {
const goNext = e.target === $btnNext;
const currentIndex = visibleIndex;
let newIndex = goNext ? currentIndex + 1 : currentIndex - 1;
newIndex = normalizeIndex(newIndex);
visibleIndex = newIndex;
if (!isMultiple && newIndex !== currentIndex) {
$select.selectedIndex = newIndex;
}
$select.dispatchEvent(new Event('input'));
};
$select.addEventListener('input', e => render());
$btnPrev.addEventListener('click', onPrevNext);
$btnNext.addEventListener('click', onPrevNext);
const observer = new MutationObserver((mutationList, observer) => {
mutationList.forEach(mutation => {
mutation.type === 'childList' && render();
});
});
observer.observe($select, {
subtree: true,
childList: true,
});
render();
return CE('div', {class: 'bx-select'},
$select,
$btnPrev,
$content,
$btnNext,
);
}
}

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/*"],