Compare commits

...

160 Commits

Author SHA1 Message Date
1d00d793b8 Bump version to 5.2.0 2024-07-13 18:27:51 +07:00
2a9da6f827 Update better-xcloud.user.js 2024-07-13 18:24:18 +07:00
b6089a61f9 Update translations 2024-07-13 18:22:43 +07:00
0fe6608be9 Disable "patchSetCurrentlyFocusedInteractable" patch 2024-07-13 18:07:09 +07:00
9e39e80309 Fix watchHeader() being called multiple times 2024-07-13 18:04:17 +07:00
5bfcf3a044 Refactor header.ts 2024-07-13 18:00:15 +07:00
66d5d9edc6 Disable the region selection box when the server lis is empty 2024-07-13 17:41:27 +07:00
9d00082c67 Disable "EnableWifiWarnings" flag 2024-07-13 17:35:45 +07:00
ef2e0892bc Add setting to bypass region restriction 2024-07-13 17:27:40 +07:00
ce1901b300 Refactor network.ts 2024-07-13 16:19:33 +07:00
18a8b8330c Add "patchSetCurrentlyFocusedInteractable" patch 2024-07-13 16:07:53 +07:00
b9e78f09d3 Update better-xcloud.user.js 2024-07-12 06:20:02 +07:00
33b2b36e2b Revert "Remove website's version detection"
This reverts commit 91ab57fa29.
2024-07-12 06:19:30 +07:00
61ed68c40f Update better-xcloud.user.js 2024-07-10 07:18:18 +07:00
4ad0d44929 Disable touch for non-touch supported User-Agent profile 2024-07-10 07:18:10 +07:00
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
0d59ab2ee2 Bump version to 4.7.0 2024-06-08 17:22:43 +07:00
ff794c44b5 Update better-xcloud.user.js 2024-06-08 17:21:55 +07:00
ccc824d544 Add vscode files 2024-06-08 17:05:02 +07:00
eb8490a798 Add native MKB support for Android app 2024-06-08 17:04:49 +07:00
a41d0cda0c Update better-xcloud.user.js 2024-06-07 21:04:52 +07:00
559c3c52c3 Update better-xcloud.user.js 2024-06-07 07:57:10 +07:00
2ed1e8735f Update better-xcloud.user.js 2024-06-07 07:20:25 +07:00
03d5550f05 Update better-xcloud.user.js 2024-06-06 20:53:53 +07:00
fb1ce5306d Update better-xcloud.user.js 2024-06-05 21:37:38 +07:00
e8e37aa575 Update better-xcloud.user.js 2024-06-05 18:28:31 +07:00
9f1f28a2d7 Update better-xcloud.user.js 2024-06-03 15:52:55 +07:00
44cf4f1d19 Fix video ratio not working properly 2024-06-03 15:52:52 +07:00
bb20f408a3 Update custom-flags.user.js 2024-06-03 05:44:58 +07:00
c03737e224 Update custom-flags.user.js 2024-06-02 11:13:47 +07:00
5b137f7791 Update 01-bug-report.yml 2024-06-02 10:54:14 +07:00
7f52479f0a Update better-xcloud.user.js 2024-06-02 10:44:23 +07:00
2e0a59cbe1 Add back the ability to use native MKB feature on unofficial titles 2024-06-02 10:43:59 +07:00
850afb4ca7 Disable xCloud analytics also remove the Feedback button in the Guide menu 2024-06-02 09:57:04 +07:00
e98fa29271 Disable social features also hide the "Start a party" button in the Guide menu 2024-06-02 09:52:41 +07:00
d79aaecb54 Bump version to 4.6.3 2024-06-01 18:39:30 +07:00
148b60cccb Update better-xcloud.user.js 2024-06-01 18:39:08 +07:00
db78918d34 Don't process further when vibration intensity is 0 2024-06-01 18:37:06 +07:00
ddc4346da8 Update better-xcloud.user.js 2024-06-01 18:28:04 +07:00
4db25e8d62 Don't show stream badges in the Guide menu until xCloud removes the Stream menu 2024-06-01 18:27:46 +07:00
e10a98c245 Fix not disabling vibration when intensity is 0 2024-06-01 18:26:23 +07:00
c9b070253c Fix button styles in WebView 2024-06-01 18:23:14 +07:00
ba07e0498e Fix disabling the MKB dialog not making it go away 2024-06-01 18:23:00 +07:00
6c8f336e9c Update better-xcloud.user.js 2024-06-01 17:29:11 +07:00
522e4dddd2 Update home icon 2024-06-01 17:29:09 +07:00
e1627dca61 Update better-xcloud.user.js 2024-06-01 17:11:53 +07:00
b5a19cd211 Replace double-quote with single-quote in SVG files 2024-06-01 17:11:29 +07:00
d5d81f3374 Add "Back to home" button in the Stream menu 2024-06-01 17:04:33 +07:00
2db78d01a0 Hide xCloud's Home button in the Guide menu 2024-06-01 16:34:54 +07:00
8f9976da28 Add "Reload stream" & "Back to home" buttons in the Guide menu 2024-06-01 16:29:01 +07:00
322418ec5b Reposition badges in the Guide menu 2024-06-01 16:00:11 +07:00
28049e5d22 Update package.json 2024-06-01 15:49:30 +07:00
9593cdf8dd Update better-xcloud.user.js 2024-06-01 10:21:29 +07:00
732bd19f3a Add Catalan 2024-06-01 10:21:11 +07:00
562c1c95f5 Update translations 2024-06-01 10:21:05 +07:00
5d1a0a3428 Update better-xcloud.user.js 2024-06-01 10:12:52 +07:00
758501bcd3 Change objectFit to "contain" 2024-06-01 10:12:45 +07:00
e10eadc832 Fix first time activating controller shortcut will also open the Guide menu (#409) 2024-06-01 10:11:53 +07:00
ed3c4041ff Show stats in the Guide menu & refactor 2024-06-01 10:11:06 +07:00
60cadb4b04 Update better-xcloud.user.js 2024-05-31 21:29:36 +07:00
b5c033498e Move "AUDIO_MIC_ON_PLAYING" setting 2024-05-31 07:31:36 +07:00
a24446a6b4 Update better-xcloud.user.js 2024-05-31 07:22:01 +07:00
bee190b867 Use a better method to skip feedback dialog 2024-05-31 07:14:58 +07:00
941ed0a00f Fix loading screen not working properly 2024-05-31 06:55:31 +07:00
1f632db6b4 Bump version to 4.6.2 2024-05-30 17:24:50 +07:00
c07e3297ca Update better-xcloud.user.js 2024-05-30 17:22:19 +07:00
5e43915ff7 Add a Disable button in the MKB dialog 2024-05-30 17:22:06 +07:00
e21375821d Update better-xcloud.user.js 2024-05-30 16:46:56 +07:00
6438e533d6 Hide rocket animation in Smart TV profile 2024-05-30 16:46:48 +07:00
e9671cbe5d Fix video not being full screen (#415) 2024-05-30 16:28:05 +07:00
b99ec65cc9 Update better-xcloud.user.js 2024-05-30 09:34:47 +07:00
addcf56abf Minor fix 2024-05-30 09:22:04 +07:00
db17bda673 Bump version to 4.6.1 2024-05-30 07:09:39 +07:00
0a60119c3b Update better-xcloud.user.js 2024-05-30 07:09:14 +07:00
ef14c78941 Fix settings being reset after refreshing page 2024-05-30 07:04:01 +07:00
f2dc102996 Update better-xcloud.user.js 2024-05-29 20:19:43 +07:00
02db103a72 Fix pink border when using Clarity feature in Logitech G Cloud 2024-05-29 20:19:36 +07:00
f291047b64 Update better-xcloud.user.js 2024-05-29 20:09:22 +07:00
5866644673 Clear TABs when disabling touch control 2024-05-29 20:09:20 +07:00
103 changed files with 10070 additions and 9029 deletions

View File

@ -11,12 +11,21 @@ body:
options: options:
- label: I will only use English in my report. - label: I will only use English in my report.
required: true required: true
- label: "The bug doesn't happen when I disable Better xCloud script."
required: true
- label: I have used the search function for [**open and closed issues**](https://github.com/redphx/better-xcloud/issues?q=is%3Aissue) to see if someone else has already submitted the same bug report. - label: I have used the search function for [**open and closed issues**](https://github.com/redphx/better-xcloud/issues?q=is%3Aissue) to see if someone else has already submitted the same bug report.
required: true required: true
- label: I will describe the problem with as much detail as possible. - label: I will describe the problem with as much detail as possible.
required: true required: true
- type: checkboxes
id: questions
attributes:
label: Questions
options:
- label: xCloud officially supports my country/region.
required: false
- label: "The bug doesn't happen when I disable Better xCloud script."
required: false
- label: "The bug didn't happen in previous Better xCloud version (name which one)."
required: false
- type: dropdown - type: dropdown
id: device_type id: device_type
attributes: attributes:
@ -50,9 +59,9 @@ body:
- type: input - type: input
id: browser_version id: browser_version
attributes: attributes:
label: "Browser Version" label: "Android app/Browser Version"
description: "What is the name and version of the browser?" description: "What is the name and version of the browser/Android app?"
placeholder: "e.g., Chrome 124.0" placeholder: "e.g., Chrome 124.0, Android app 0.15.0"
validations: validations:
required: true required: true
- type: input - type: input

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"files.readonlyInclude": {
"dist/**/*": true
}
}

15
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "typescript",
"tsconfig": "tsconfig.json",
"option": "watch",
"problemMatcher": [
"$tsc-watch"
],
"group": "build",
"label": "tsc: watch - tsconfig.json"
}
]
}

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.

114
build.ts
View File

@ -4,12 +4,13 @@ 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',
ANDROID_APP = 'android-app', ANDROID_APP = 'android-app',
MOBILE = 'mobile', MOBILE = 'mobile',
WEBOS = 'webos', WEBOS = 'webos',
} }
const postProcess = (str: string): string => { const postProcess = (str: string): string => {
@ -21,83 +22,88 @@ const postProcess = (str: string): string => {
// Replace "globalThis." with "var"; // Replace "globalThis." with "var";
str = str.replaceAll('globalThis.', 'var '); str = str.replaceAll('globalThis.', 'var ');
// 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;
} }
const build = async (target: BuildTarget, version: string, config: any={}) => { const build = async (target: BuildTarget, version: string, config: any={}) => {
console.log('-- Target:', target); console.log('-- Target:', target);
const startTime = performance.now(); const startTime = performance.now();
let outputScriptName = 'better-xcloud'; let outputScriptName = 'better-xcloud';
if (target !== BuildTarget.ALL) { if (target !== BuildTarget.ALL) {
outputScriptName += `.${target}`; outputScriptName += `.${target}`;
} }
let outputMetaName = outputScriptName; let outputMetaName = outputScriptName;
outputScriptName += '.user.js'; outputScriptName += '.user.js';
outputMetaName += '.meta.js'; outputMetaName += '.meta.js';
const outDir = './dist'; const outDir = './dist';
let output = await Bun.build({ let output = await Bun.build({
entrypoints: ['src/index.ts'], entrypoints: ['src/index.ts'],
outdir: outDir, outdir: outDir,
naming: outputScriptName, naming: outputScriptName,
define: { minify: {
'Bun.env.BUILD_TARGET': JSON.stringify(target), syntax: true,
'Bun.env.SCRIPT_VERSION': JSON.stringify(version), },
}, define: {
}); 'Bun.env.BUILD_TARGET': JSON.stringify(target),
'Bun.env.SCRIPT_VERSION': JSON.stringify(version),
},
});
if (!output.success) { if (!output.success) {
console.log(output); console.log(output);
process.exit(1); process.exit(1);
} }
const {path} = output.outputs[0]; const {path} = output.outputs[0];
// Get generated file // Get generated file
let result = postProcess(await readFile(path, 'utf-8')); let result = postProcess(await readFile(path, 'utf-8'));
// Replace [[VERSION]] with real value // Replace [[VERSION]] with real value
const scriptHeader = txtScriptHeader.replace('[[VERSION]]', version); const scriptHeader = txtScriptHeader.replace('[[VERSION]]', version);
// Save to script // Save to script
await Bun.write(path, scriptHeader + result); await Bun.write(path, scriptHeader + result);
console.log(`---- [${target}] done in ${performance.now() - startTime} ms`); console.log(`---- [${target}] done in ${performance.now() - startTime} ms`);
// Create meta file // Create meta file
await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version)); await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version));
} }
const buildTargets = [ const buildTargets = [
BuildTarget.ALL, BuildTarget.ALL,
// BuildTarget.ANDROID_APP, // BuildTarget.ANDROID_APP,
// BuildTarget.MOBILE, // BuildTarget.MOBILE,
// BuildTarget.WEBOS, // BuildTarget.WEBOS,
]; ];
const { values, positionals } = parseArgs({ const { values, positionals } = parseArgs({
args: Bun.argv, args: Bun.argv,
options: { options: {
version: { version: {
type: 'string', type: 'string',
}, },
}, },
strict: true, strict: true,
allowPositionals: true, allowPositionals: true,
}); });
if (!values['version']) { if (!values['version']) {
console.log('Missing --version param'); console.log('Missing --version param');
sys.exit(-1); sys.exit(-1);
} }
console.log('Building: ', values['version']); 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.6.0 // @version 5.2.0
// ==/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": "latest", "@types/bun": "^1.1.6",
"@types/node": "^20.12.7", "@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.0.0" "typescript": "^5.5.2"
} }
} }

View File

@ -0,0 +1,41 @@
// ==UserScript==
// @name Better xCloud - Custom flags
// @namespace https://github.com/redphx
// @version 1.0.0
// @description Customize Better xCloud script
// @author redphx
// @license MIT
// @match https://www.xbox.com/*/play*
// @run-at document-start
// @grant none
// ==/UserScript==
'use strict';
/*
Make sure this script is being loaded before the Better xCloud script.
How to:
1. Uninstall Better xCloud script.
2. Install this script.
3. Reinstall Better xCloud script. All your settings are still there.
*/
// Change this to `false` if you want to temporary disable the script
const enabled = true;
enabled && (window.BX_FLAGS = {
/*
Add titleId of the game(s) you want to add here.
Keep in mind: this method only works with some games.
Example:
- Flight Simulator has this link: /play/games/microsoft-flight-simulator-standard-40th-anniversa/9PMQDM08SNK9
- That means its titleId is "9PMQDM08SNK9"
- So it becomes:
ForceNativeMkbTitles: [
"9PMQDM08SNK9",
],
*/
ForceNativeMkbTitles: [
],
});

View File

@ -59,6 +59,10 @@
} }
} }
&.bx-tall {
height: calc(var(--bx-button-height) * 1.5) !important;
}
svg { svg {
display: inline-block; display: inline-block;
width: 16px; width: 16px;
@ -71,10 +75,10 @@
span { span {
display: inline-block; display: inline-block;
height: var(--bx-button-height); /* height: var(--bx-button-height); */
line-height: var(--bx-button-height); line-height: var(--bx-button-height);
vertical-align: middle; vertical-align: middle;
vertical-align: -webkit-baseline-middle; /* vertical-align: -webkit-baseline-middle; */
color: #fff; color: #fff;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;

View File

@ -1,6 +1,5 @@
.bx-settings-reload-button { .bx-settings-reload-button {
margin-top: 10px; margin-top: 10px;
height: calc(var(--bx-button-height) * 1.5);
} }
.bx-settings-container { .bx-settings-container {
@ -30,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;
@ -42,18 +48,20 @@
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 {
display: block; display: block;
color: #ff834b; color: #ff834b;

View File

@ -16,8 +16,6 @@
} }
.bx-mkb-pointer-lock-msg { .bx-mkb-pointer-lock-msg {
display: flex;
cursor: pointer;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
position: fixed; position: fixed;
@ -25,7 +23,7 @@
top: 50%; top: 50%;
transform: translateX(-50%) translateY(-50%); transform: translateX(-50%) translateY(-50%);
margin: auto; margin: auto;
background: #000000b3; background: #151515;
z-index: var(--bx-mkb-pointer-lock-msg-z-index); z-index: var(--bx-mkb-pointer-lock-msg-z-index);
color: #fff; color: #fff;
text-align: center; text-align: center;
@ -36,21 +34,14 @@
border-radius: 8px; border-radius: 8px;
align-items: center; align-items: center;
box-shadow: 0 0 6px #000; box-shadow: 0 0 6px #000;
min-width: 220px;
opacity: 0.9;
&:hover { &:hover {
background: #151515; opacity: 1;
} }
button { > div:first-of-type {
margin-right: 12px;
height: 60px;
}
svg {
width: 32px;
}
div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
text-align: left; text-align: left;
@ -61,14 +52,47 @@
&:first-child { &:first-child {
font-size: 22px; font-size: 22px;
margin-bottom: 8px; margin-bottom: 4px;
font-weight: bold;
} }
&:last-child { &:last-child {
font-size: 14px; font-size: 12px;
font-style: italic; font-style: italic;
} }
} }
> div:last-of-type {
margin-top: 10px;
&[data-type='native'] {
button {
&:first-of-type {
margin-bottom: 8px;
}
}
}
&[data-type='virtual'] {
div {
display: flex;
flex-flow: row;
margin-top: 8px;
button {
flex: 1;
&:first-of-type {
margin-right: 5px;
}
&:last-of-type {
margin-left: 5px;
}
}
}
}
}
} }
.bx-mkb-preset-tools { .bx-mkb-preset-tools {

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

@ -1,6 +1,5 @@
/* STATS BADGE */ /* STATS BADGE */
.bx-badges { .bx-badges {
position: absolute;
margin-left: 0px; margin-left: 0px;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
@ -21,23 +20,55 @@
.bx-badge-name { .bx-badge-name {
background-color: #2d3036; background-color: #2d3036;
display: inline-block;
padding: 2px 8px;
border-radius: 4px 0 0 4px; border-radius: 4px 0 0 4px;
text-transform: uppercase;
svg {
width: 16px;
height: 16px;
}
} }
.bx-badge-value { .bx-badge-value {
background-color: grey; background-color: grey;
display: inline-block;
padding: 2px 8px;
border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0;
} }
.bx-badge-name, .bx-badge-value {
display: inline-block;
padding: 0 8px;
/* height: 30px; */
line-height: 30px;
vertical-align: bottom;
}
.bx-badge-battery[data-charging=true] span:first-of-type::after { .bx-badge-battery[data-charging=true] span:first-of-type::after {
content: ' '; content: ' ';
} }
div[class^=StreamMenu-module__container] .bx-badges {
position: absolute;
max-width: 500px;
}
#gamepass-dialog-root .bx-badges {
position: fixed;
top: 60px;
left: 460px;
max-width: 500px;
@media (min-width: 568px) and (max-height: 480px) {
position: unset;
top: unset;
left: unset;
margin: 8px 0;
}
@media (min-width: 480px) and (min-height: calc(481px)) {
}
}
/* STATS BAR */ /* STATS BAR */
.bx-stats-bar { .bx-stats-bar {
display: block; display: block;

View File

@ -20,6 +20,18 @@ body[data-media-type=tv] .bx-stream-refresh-button {
top: calc(var(--gds-focus-borderSize) + 80px) !important; top: calc(var(--gds-focus-borderSize) + 80px) !important;
} }
.bx-stream-home-button {
top: calc(env(safe-area-inset-top, 0px) + 10px + 50px * 2) !important;
}
body[data-media-type=default] .bx-stream-home-button {
left: calc(env(safe-area-inset-left, 0px) + 12px) !important;
}
body[data-media-type=tv] .bx-stream-home-button {
top: calc(var(--gds-focus-borderSize) + 80px * 2) !important;
}
@keyframes bx-anim-taking-screenshot { @keyframes bx-anim-taking-screenshot {
0% { 0% {
border: 0px solid #ffffff80; border: 0px solid #ffffff80;
@ -53,3 +65,18 @@ div[data-testid=media-container] {
align-self: center; align-self: center;
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] {
.bx-button {
overflow: visible;
margin-bottom: 12px;
}
}

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

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' stroke-miterlimit='2' viewBox='0 0 32 32'>
<path d='M24.774 6.71H3.097C1.398 6.71 0 8.108 0 9.806v12.387c0 1.699 1.398 3.097 3.097 3.097h21.677c1.699 0 3.097-1.398 3.097-3.097V9.806c0-1.699-1.398-3.097-3.097-3.097zm1.032 15.484a1.04 1.04 0 0 1-1.032 1.032H3.097a1.04 1.04 0 0 1-1.032-1.032V9.806a1.04 1.04 0 0 1 1.032-1.032h21.677a1.04 1.04 0 0 1 1.032 1.032v12.387zm-2.065-10.323v8.258a1.04 1.04 0 0 1-1.032 1.032H5.161a1.04 1.04 0 0 1-1.032-1.032v-8.258a1.04 1.04 0 0 1 1.032-1.032H22.71a1.04 1.04 0 0 1 1.032 1.032zm8.258 0v8.258a1.04 1.04 0 0 1-1.032 1.032 1.04 1.04 0 0 1-1.032-1.032v-8.258a1.04 1.04 0 0 1 1.032-1.032A1.04 1.04 0 0 1 32 11.871z' fill-rule='nonzero'/>
</svg>

After

Width:  |  Height:  |  Size: 821 B

View File

@ -1,6 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'> <svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<g transform="matrix(.150985 0 0 .150985 -3.32603 -2.72209)" fill="none" stroke="#fff" stroke-width="16"> <g transform='matrix(.150985 0 0 .150985 -3.32603 -2.72209)' fill='none' stroke='#fff' stroke-width='16'>
<path d="M208 208H48c-8.777 0-16-7.223-16-16V80c0-8.777 7.223-16 16-16h32l16-24h64l16 24h32c8.777 0 16 7.223 16 16v112c0 8.777-7.223 16-16 16z"/> <path d='M208 208H48c-8.777 0-16-7.223-16-16V80c0-8.777 7.223-16 16-16h32l16-24h64l16 24h32c8.777 0 16 7.223 16 16v112c0 8.777-7.223 16-16 16z'/>
<circle cx="128" cy="132" r="36"/> <circle cx='128' cy='132' r='36'/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 494 B

After

Width:  |  Height:  |  Size: 494 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" stroke="#fff" fill="#fff" height="100%" viewBox="0 0 32 32" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"> <svg xmlns='http://www.w3.org/2000/svg' width='100%' stroke='#fff' fill='#fff' height='100%' viewBox='0 0 32 32' fill-rule='evenodd' stroke-linejoin='round' stroke-miterlimit='2'>
<path d="M6.755 1.924l-6 13.649c-.119.27-.119.578 0 .849l6 13.649c.234.533.857.775 1.389.541s.775-.857.541-1.389L2.871 15.997 8.685 2.773c.234-.533-.008-1.155-.541-1.389s-1.155.008-1.389.541z"/> <path d='M6.755 1.924l-6 13.649c-.119.27-.119.578 0 .849l6 13.649c.234.533.857.775 1.389.541s.775-.857.541-1.389L2.871 15.997 8.685 2.773c.234-.533-.008-1.155-.541-1.389s-1.155.008-1.389.541z'/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 386 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" stroke="#fff" fill="#fff" height="100%" viewBox="0 0 32 32" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"> <svg xmlns='http://www.w3.org/2000/svg' width='100%' stroke='#fff' fill='#fff' height='100%' viewBox='0 0 32 32' fill-rule='evenodd' stroke-linejoin='round' stroke-miterlimit='2'>
<path d="M2.685 1.924l6 13.649c.119.27.119.578 0 .849l-6 13.649c-.234.533-.857.775-1.389.541s-.775-.857-.541-1.389l5.813-13.225L.755 2.773c-.234-.533.008-1.155.541-1.389s1.155.008 1.389.541z"/> <path d='M2.685 1.924l6 13.649c.119.27.119.578 0 .849l-6 13.649c-.234.533-.857.775-1.389.541s-.775-.857-.541-1.389l5.813-13.225L.755 2.773c-.234-.533.008-1.155.541-1.389s1.155.008 1.389.541z'/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

6
src/assets/svg/clock.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<g transform='matrix(.150026 0 0 .150026 -3.20332 -3.20332)' fill='none' stroke='#fff' stroke-width='16'>
<circle cx='128' cy='128' r='96'/>
<path d='M128 72v56h56'/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 374 B

3
src/assets/svg/cloud.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M9.773 16c0-5.694 4.685-10.379 10.379-10.379S30.53 10.306 30.53 16s-4.685 10.379-10.379 10.379H8.735c-3.982-.005-7.256-3.283-7.256-7.265s3.28-7.265 7.265-7.265c.606 0 1.21.076 1.797.226' fill='none' stroke='#fff' stroke-width='2.076'/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@ -1,4 +1,4 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'> <svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d="M25.425 1.5c2.784 0 5.075 2.291 5.075 5.075s-2.291 5.075-5.075 5.075H20.35V6.575c0-2.784 2.291-5.075 5.075-5.075zM11.65 11.65H6.575C3.791 11.65 1.5 9.359 1.5 6.575S3.791 1.5 6.575 1.5s5.075 2.291 5.075 5.075v5.075zm8.7 8.7h5.075c2.784 0 5.075 2.291 5.075 5.075S28.209 30.5 25.425 30.5s-5.075-2.291-5.075-5.075V20.35zM6.575 30.5c-2.784 0-5.075-2.291-5.075-5.075s2.291-5.075 5.075-5.075h5.075v5.075c0 2.784-2.291 5.075-5.075 5.075z"/> <path d='M25.425 1.5c2.784 0 5.075 2.291 5.075 5.075s-2.291 5.075-5.075 5.075H20.35V6.575c0-2.784 2.291-5.075 5.075-5.075zM11.65 11.65H6.575C3.791 11.65 1.5 9.359 1.5 6.575S3.791 1.5 6.575 1.5s5.075 2.291 5.075 5.075v5.075zm8.7 8.7h5.075c2.784 0 5.075 2.291 5.075 5.075S28.209 30.5 25.425 30.5s-5.075-2.291-5.075-5.075V20.35zM6.575 30.5c-2.784 0-5.075-2.291-5.075-5.075s2.291-5.075 5.075-5.075h5.075v5.075c0 2.784-2.291 5.075-5.075 5.075z'/>
<path d="M11.65 11.65h8.7v8.7h-8.7z"/> <path d='M11.65 11.65h8.7v8.7h-8.7z'/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 667 B

After

Width:  |  Height:  |  Size: 667 B

View File

@ -0,0 +1,4 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M16 19.955V1.5m14.5 18.455v9.227c0 .723-.595 1.318-1.318 1.318H2.818c-.723 0-1.318-.595-1.318-1.318v-9.227'/>
<path d='M22.591 13.364L16 19.955l-6.591-6.591'/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

3
src/assets/svg/home.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M12.217 30.503V20.414h7.567v10.089h10.089V15.37a1.26 1.26 0 0 0-.369-.892L16.892 1.867a1.26 1.26 0 0 0-1.784 0L2.497 14.478a1.26 1.26 0 0 0-.369.892v15.133h10.089z'/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@ -1,4 +1,4 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'> <svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d="M16 25.125v5.368M5.265 4.728l21.471 23.618m-4.789-5.267c-1.698 1.326-3.793 2.047-5.947 2.047-5.3 0-9.662-4.362-9.662-9.662"/> <path d='M16 25.125v5.368M5.265 4.728l21.471 23.618m-4.789-5.267c-1.698 1.326-3.793 2.047-5.947 2.047-5.3 0-9.662-4.362-9.662-9.662'/>
<path d="M25.662 15.463a9.62 9.62 0 0 1-.978 4.242m-5.64.187c-.895.616-1.957.943-3.043.939-2.945 0-5.368-2.423-5.368-5.368v-4.831m.442-5.896A5.38 5.38 0 0 1 16 1.507c2.945 0 5.368 2.423 5.368 5.368v8.588c0 .188-.01.375-.03.562"/> <path d='M25.662 15.463a9.62 9.62 0 0 1-.978 4.242m-5.64.187c-.895.616-1.957.943-3.043.939-2.945 0-5.368-2.423-5.368-5.368v-4.831m.442-5.896A5.38 5.38 0 0 1 16 1.507c2.945 0 5.368 2.423 5.368 5.368v8.588c0 .188-.01.375-.03.562'/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 551 B

After

Width:  |  Height:  |  Size: 551 B

View File

@ -1,3 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'> <svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d="M21.368 6.875A5.37 5.37 0 0 0 16 1.507a5.37 5.37 0 0 0-5.368 5.368v8.588A5.37 5.37 0 0 0 16 20.831a5.37 5.37 0 0 0 5.368-5.368V6.875zM16 25.125v5.368m9.662-15.03c0 5.3-4.362 9.662-9.662 9.662s-9.662-4.362-9.662-9.662"/> <path d='M21.368 6.875A5.37 5.37 0 0 0 16 1.507a5.37 5.37 0 0 0-5.368 5.368v8.588A5.37 5.37 0 0 0 16 20.831a5.37 5.37 0 0 0 5.368-5.368V6.875zM16 25.125v5.368m9.662-15.03c0 5.3-4.362 9.662-9.662 9.662s-9.662-4.362-9.662-9.662'/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1,10 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<g stroke-width="2.1">
<path d="m15.817 6h-10.604c-2.215 0-4.013 1.798-4.013 4.013v12.213c0 2.215 1.798 4.013 4.013 4.013h11.21"/>
<path d="m5.698 20.617h1.124m-1.124-4.517h7.9m-7.881-4.5h7.9m-2.3 9h2.2"/>
</g>
<g stroke-width="2.13">
<path d="m30.805 13.1c0-3.919-3.181-7.1-7.1-7.1s-7.1 3.181-7.1 7.1v6.4c0 3.919 3.182 7.1 7.1 7.1s7.1-3.181 7.1-7.1z"/>
<path d="m23.705 14.715v-4.753"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 619 B

View File

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

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1,4 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M8.964 21.417h-6.5a1.09 1.09 0 0 1-1.083-1.083v-8.667a1.09 1.09 0 0 1 1.083-1.083h6.5L18.714 3v26l-9.75-7.583z'/>
<path d='M8.964 10.583v10.833m15.167-8.28a4.35 4.35 0 0 1 0 5.728M28.149 9.5a9.79 9.79 0 0 1 0 13'/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@ -1,9 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 32 32" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"> <svg xmlns='http://www.w3.org/2000/svg' fill='#fff' viewBox='0 0 32 32' fill-rule='evenodd' stroke-linejoin='round' stroke-miterlimit='2'>
<g fill="none" stroke="#fff"> <g fill='none' stroke='#fff'>
<path d="M6.021 5.021l20 22" stroke-width="2"/> <path d='M6.021 5.021l20 22' stroke-width='2'/>
<path d="M8.735 8.559H2.909a.89.89 0 0 0-.889.889v13.146a.89.89 0 0 0 .889.888h19.34m4.289 0h2.594a.89.89 0 0 0 .889-.888V9.448a.89.89 0 0 0-.889-.889H12.971" stroke-miterlimit="1.5" stroke-width="2.083"/> <path d='M8.735 8.559H2.909a.89.89 0 0 0-.889.889v13.146a.89.89 0 0 0 .889.888h19.34m4.289 0h2.594a.89.89 0 0 0 .889-.888V9.448a.89.89 0 0 0-.889-.889H12.971' stroke-miterlimit='1.5' stroke-width='2.083'/>
</g> </g>
<path d="M8.147 11.981l-.053-.001-.054.001c-.55.028-.988.483-.988 1.04v6c0 .575.467 1.042 1.042 1.042l.053-.001c.55-.028.988-.484.988-1.04v-6a1.04 1.04 0 0 0-.988-1.04z"/> <path d='M8.147 11.981l-.053-.001-.054.001c-.55.028-.988.483-.988 1.04v6c0 .575.467 1.042 1.042 1.042l.053-.001c.55-.028.988-.484.988-1.04v-6a1.04 1.04 0 0 0-.988-1.04z'/>
<path d="M11.147 14.981l-.054-.001h-6a1.04 1.04 0 1 0 0 2.083h6c.575 0 1.042-.467 1.042-1.042a1.04 1.04 0 0 0-.988-1.04z"/> <path d='M11.147 14.981l-.054-.001h-6a1.04 1.04 0 1 0 0 2.083h6c.575 0 1.042-.467 1.042-1.042a1.04 1.04 0 0 0-.988-1.04z'/>
<circle cx="25.345" cy="18.582" r="2.561" fill="none" stroke="#fff" stroke-width="1.78" transform="matrix(1.17131 0 0 1.17131 -5.74235 -5.74456)"/> <circle cx='25.345' cy='18.582' r='2.561' fill='none' stroke='#fff' stroke-width='1.78' transform='matrix(1.17131 0 0 1.17131 -5.74235 -5.74456)'/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 915 B

After

Width:  |  Height:  |  Size: 915 B

View File

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 32 32" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"> <svg xmlns='http://www.w3.org/2000/svg' fill='#fff' viewBox='0 0 32 32' fill-rule='evenodd' stroke-linejoin='round' stroke-miterlimit='2'>
<path d="M30.021 9.448a.89.89 0 0 0-.889-.889H2.909a.89.89 0 0 0-.889.889v13.146a.89.89 0 0 0 .889.888h26.223a.89.89 0 0 0 .889-.888V9.448z" fill="none" stroke="#fff" stroke-width="2.083"/> <path d='M30.021 9.448a.89.89 0 0 0-.889-.889H2.909a.89.89 0 0 0-.889.889v13.146a.89.89 0 0 0 .889.888h26.223a.89.89 0 0 0 .889-.888V9.448z' fill='none' stroke='#fff' stroke-width='2.083'/>
<path d="M8.147 11.981l-.053-.001-.054.001c-.55.028-.988.483-.988 1.04v6c0 .575.467 1.042 1.042 1.042l.053-.001c.55-.028.988-.484.988-1.04v-6a1.04 1.04 0 0 0-.988-1.04z"/> <path d='M8.147 11.981l-.053-.001-.054.001c-.55.028-.988.483-.988 1.04v6c0 .575.467 1.042 1.042 1.042l.053-.001c.55-.028.988-.484.988-1.04v-6a1.04 1.04 0 0 0-.988-1.04z'/>
<path d="M11.147 14.981l-.054-.001h-6a1.04 1.04 0 1 0 0 2.083h6c.575 0 1.042-.467 1.042-1.042a1.04 1.04 0 0 0-.988-1.04z"/> <path d='M11.147 14.981l-.054-.001h-6a1.04 1.04 0 1 0 0 2.083h6c.575 0 1.042-.467 1.042-1.042a1.04 1.04 0 0 0-.988-1.04z'/>
<circle cx="25.345" cy="18.582" r="2.561" fill="none" stroke="#fff" stroke-width="1.78" transform="matrix(1.17131 0 0 1.17131 -5.74235 -5.74456)"/> <circle cx='25.345' cy='18.582' r='2.561' fill='none' stroke='#fff' stroke-width='1.78' transform='matrix(1.17131 0 0 1.17131 -5.74235 -5.74456)'/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 796 B

After

Width:  |  Height:  |  Size: 796 B

View File

@ -0,0 +1,4 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M16 19.905V1.682m14.318 18.223v9.112a1.31 1.31 0 0 1-1.302 1.302H2.983a1.31 1.31 0 0 1-1.302-1.302v-9.112'/>
<path d='M9.492 8.19L16 1.682l6.508 6.508'/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@ -0,0 +1,11 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<g stroke-width="2.06">
<path d="M8.417 13.218h4.124"/>
<path d="M10.479 11.155v4.125"/>
<path d="M12.787 19.404L7.36 25.565a3.61 3.61 0 0 1-2.551 1.056A3.63 3.63 0 0 1 1.2 23.013c0-.21.018-.42.055-.626l2.108-10.845C3.923 8.356 6.714 6.007 9.949 6h5.192"/>
</g>
<g stroke-width="2.11">
<path d="M30.8 13.1c0-3.919-3.181-7.1-7.1-7.1s-7.1 3.181-7.1 7.1v6.421c0 3.919 3.181 7.1 7.1 7.1s7.1-3.181 7.1-7.1V13.1z"/>
<path d="M23.7 14.724V9.966"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@ -1,4 +1,5 @@
export enum GamePassCloudGallery { export enum GamePassCloudGallery {
TOUCH = '9c86f07a-f3e8-45ad-82a0-a1f759597059',
ALL = '29a81209-df6f-41fd-a528-2ae6b91f719c', ALL = '29a81209-df6f-41fd-a528-2ae6b91f719c',
NATIVE_MKB = '8fa264dd-124f-4af3-97e8-596fcdf4b486',
TOUCH = '9c86f07a-f3e8-45ad-82a0-a1f759597059',
} }

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

@ -6,40 +6,51 @@ import { t } from "@utils/translation";
import { interceptHttpRequests } from "@utils/network"; import { interceptHttpRequests } from "@utils/network";
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { showGamepadToast } from "@utils/gamepad"; import { showGamepadToast } from "@utils/gamepad";
import { MkbHandler } 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";
import { TouchController } from "@modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { watchHeader } from "@modules/ui/header";
import { checkForUpdate, disablePwa } from "@utils/utils"; import { checkForUpdate, disablePwa } from "@utils/utils";
import { Patcher } from "@modules/patcher"; import { Patcher } from "@modules/patcher";
import { RemotePlay } from "@modules/remote-play"; import { RemotePlay } from "@modules/remote-play";
import { onHistoryChanged, patchHistoryMethod } from "@utils/history"; import { onHistoryChanged, patchHistoryMethod } from "@utils/history";
import { VibrationManager } from "@modules/vibration-manager"; import { VibrationManager } from "@modules/vibration-manager";
import { overridePreloadState } from "@utils/preload-state"; import { overridePreloadState } from "@utils/preload-state";
import { patchAudioContext, patchCanvasContext, patchMeControl, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches"; import { patchAudioContext, patchCanvasContext, patchMeControl, patchPointerLockApi, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches";
import { AppInterface, STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { injectStreamMenuButtons } from "@modules/stream/stream-ui"; import { injectStreamMenuButtons } from "@modules/stream/stream-ui";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { GameBar } from "./modules/game-bar/game-bar"; 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 { 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";
import { HeaderSection } from "./modules/ui/header";
// 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];
// @ts-ignore if (url && (url.startsWith('/play') || url.substring(6).startsWith('/play'))) {
location.pathname.includes('/play') && location.reload(true); console.log('Redirecting to xbox.com/play');
}, 2000); window.stop();
}); window.location.href = 'https://www.xbox.com' + url;
return;
}
// @ts-ignore
return nativePushState.apply(this, arguments);
}
// 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');
} }
@ -90,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
@ -103,13 +125,13 @@ window.addEventListener('popstate', onHistoryChanged);
window.history.pushState = patchHistoryMethod('pushState'); window.history.pushState = patchHistoryMethod('pushState');
window.history.replaceState = patchHistoryMethod('replaceState'); window.history.replaceState = patchHistoryMethod('replaceState');
window.addEventListener(BxEvent.XCLOUD_SERVERS_UNAVAILABLE, e => {
STATES.supportedRegion = false;
window.setTimeout(HeaderSection.watchHeader, 2000);
});
window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => { window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => {
// Start rendering UI HeaderSection.watchHeader();
if (document.querySelector('div[class^=UnsupportedMarketPage]')) {
window.setTimeout(watchHeader, 2000);
} else {
watchHeader();
}
}); });
window.addEventListener(BxEvent.STREAM_LOADING, e => { window.addEventListener(BxEvent.STREAM_LOADING, e => {
@ -144,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();
@ -157,39 +176,52 @@ 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 => {
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
}); });
window.addEventListener(BxEvent.STREAM_STOPPED, e => { window.addEventListener(BxEvent.NAVIGATION_FOCUS_CHANGED, e => {
const $elm = (e as any).element;
if (($elm.tagName === 'BUTTON' && $elm.className.includes('MruGameCard')) || ($elm.tagName === 'A' && $elm.className.includes('GameCard'))) {
console.dir($elm);
}
});
function unload() {
if (!STATES.isPlaying) { if (!STATES.isPlaying) {
return; return;
} }
// Stop MKB listeners
EmulatedMkbHandler.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;
// Stop MKB listeners StreamSettings.getInstance().hide();
getPref(PrefKey.MKB_ENABLED) && MkbHandler.INSTANCE.destroy(); StreamStats.getInstance().onStoppedPlaying();
const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog');
if ($streamSettingsDialog) {
$streamSettingsDialog.classList.add('bx-gone');
}
STATES.currentStream.audioGainNode = null;
STATES.currentStream.$video = null;
StreamStats.onStoppedPlaying();
MouseCursorHider.stop(); MouseCursorHider.stop();
TouchController.reset(); TouchController.reset();
GameBar.getInstance().disable(); GameBar.getInstance().disable();
}
window.addEventListener(BxEvent.STREAM_STOPPED, unload);
window.addEventListener('pagehide', e => {
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
}); });
window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => { window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => {
@ -206,6 +238,28 @@ function observeRootDialog($root: HTMLElement) {
continue; continue;
} }
if (mutation.addedNodes.length === 1) {
const $addedElm = mutation.addedNodes[0];
if ($addedElm instanceof HTMLElement && $addedElm.className) {
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
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
if ($selectedTab) {
let $elm: Element | null = $selectedTab;
let index;
for (index = 0; ($elm = $elm?.previousElementSibling); index++);
if (index === 0) {
BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
}
}
}
}
}
}
const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false; const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false;
if (shown !== currentShown) { if (shown !== currentShown) {
currentShown = shown; currentShown = shown;
@ -244,11 +298,12 @@ function main() {
interceptHttpRequests(); interceptHttpRequests();
patchVideoApi(); patchVideoApi();
patchCanvasContext(); patchCanvasContext();
AppInterface && patchPointerLockApi();
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();
@ -258,21 +313,26 @@ 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();
StreamBadges.setupEvents(); StreamBadges.setupEvents();
StreamStats.setupEvents(); StreamStats.setupEvents();
MkbHandler.setupEvents(); EmulatedMkbHandler.setupEvents();
Patcher.init(); Patcher.init();
disablePwa(); disablePwa();
// Show a toast when connecting/disconecting controller // Show a toast when connecting/disconecting controller
window.addEventListener('gamepadconnected', e => showGamepadToast(e.gamepad)); if (getPref(PrefKey.CONTROLLER_SHOW_CONNECTION_STATUS)) {
window.addEventListener('gamepaddisconnected', e => showGamepadToast(e.gamepad)); window.addEventListener('gamepadconnected', 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)) {
@ -284,7 +344,13 @@ 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());
}
// Preload Remote Play
getPref(PrefKey.REMOTE_PLAY_ENABLED) && BX_FLAGS.PreloadRemotePlay && RemotePlay.preload();
} }
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,9 +1,9 @@
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 { MkbHandler } from "./mkb/mkb-handler"; import { EmulatedMkbHandler } from "./mkb/mkb-handler";
import { StreamStats } from "./stream/stream-stats"; import { StreamStats } from "./stream/stream-stats";
import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone"; import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone";
import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui"; import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui";
@ -87,7 +87,7 @@ export class ControllerShortcut {
break; break;
case ShortcutAction.STREAM_STATS_TOGGLE: case ShortcutAction.STREAM_STATS_TOGGLE:
StreamStats.toggle(); StreamStats.getInstance().toggle();
break; break;
case ShortcutAction.STREAM_MICROPHONE_TOGGLE: case ShortcutAction.STREAM_MICROPHONE_TOGGLE:
@ -172,7 +172,7 @@ export class ControllerShortcut {
} }
// Ignore emulated gamepad // Ignore emulated gamepad
if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) { if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
continue; continue;
} }

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

@ -46,6 +46,10 @@ export class LoadingScreen {
#game-stream div[class*=RocketAnimation-module__container] > svg { #game-stream div[class*=RocketAnimation-module__container] > svg {
display: none; display: none;
} }
#game-stream video[class*=RocketAnimationVideo-module__video] {
display: none;
}
`; `;
$bgStyle.textContent += css; $bgStyle.textContent += css;
} }
@ -105,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')),
@ -159,13 +163,13 @@ export class LoadingScreen {
`; `;
} }
LoadingScreen.reset(); setTimeout(LoadingScreen.reset, 2000);
} }
static reset() { static reset() {
LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('bx-gone');
LoadingScreen.#$bgStyle && (LoadingScreen.#$bgStyle.textContent = ''); LoadingScreen.#$bgStyle && (LoadingScreen.#$bgStyle.textContent = '');
LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('bx-gone');
LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval); LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval);
LoadingScreen.#waitTimeInterval = null; LoadingScreen.#waitTimeInterval = null;
} }

View File

@ -0,0 +1,23 @@
export abstract class MouseDataProvider {
protected mkbHandler: MkbHandler;
constructor(handler: MkbHandler) {
this.mkbHandler = handler;
}
abstract init(): void;
abstract start(): void;
abstract stop(): void;
abstract destroy(): void;
}
export abstract class MkbHandler {
abstract init(): void;
abstract start(): void;
abstract stop(): void;
abstract destroy(): void;
abstract handleMouseMove(data: MkbMouseMove): void;
abstract handleMouseClick(data: MkbMouseClick): void;
abstract handleMouseWheel(data: MkbMouseWheel): boolean;
abstract waitForMouseData(enabled: boolean): void;
abstract isEnabled(): boolean;
}

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 } 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,29 +8,23 @@ 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 { BxIcon } from "@utils/bx-icon";
import { PointerClient } from "./pointer-client"; import { PointerClient } from "./pointer-client";
import { NativeMkbHandler } from "./native-mkb-handler";
import { MkbHandler, MouseDataProvider } from "./base-mkb-handler";
import { StreamSettings } from "../stream/stream-settings";
const LOG_TAG = 'MkbHandler'; const LOG_TAG = 'MkbHandler';
const PointerToMouseButton = {
abstract class MouseDataProvider { 1: 0,
protected mkbHandler: MkbHandler; 2: 2,
constructor(handler: MkbHandler) { 4: 1,
this.mkbHandler = handler;
}
abstract init(): void;
abstract start(): void;
abstract stop(): void;
abstract destroy(): void;
abstract toggle(enabled: boolean): void;
} }
class WebSocketMouseDataProvider extends MouseDataProvider { class WebSocketMouseDataProvider extends MouseDataProvider {
#pointerClient: PointerClient | undefined #pointerClient: PointerClient | undefined
#connected = false #connected = false
@ -39,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');
@ -57,36 +51,22 @@ class WebSocketMouseDataProvider extends MouseDataProvider {
destroy(): void { destroy(): void {
this.#connected && this.#pointerClient?.stop(); this.#connected && this.#pointerClient?.stop();
} }
toggle(enabled: boolean): void {
if (!this.#connected) {
enabled = false;
}
enabled ? this.mkbHandler.start() : this.mkbHandler.stop();
this.mkbHandler.waitForMouseData(!enabled);
}
} }
class PointerLockMouseDataProvider extends MouseDataProvider { class PointerLockMouseDataProvider extends MouseDataProvider {
init(): void { init(): void {}
document.addEventListener('pointerlockchange', this.#onPointerLockChange);
document.addEventListener('pointerlockerror', this.#onPointerLockError);
}
start(): void { start(): void {
if (!document.pointerLockElement) {
document.body.requestPointerLock();
}
window.addEventListener('mousemove', this.#onMouseMoveEvent); window.addEventListener('mousemove', this.#onMouseMoveEvent);
window.addEventListener('mousedown', this.#onMouseEvent); window.addEventListener('mousedown', this.#onMouseEvent);
window.addEventListener('mouseup', this.#onMouseEvent); window.addEventListener('mouseup', this.#onMouseEvent);
window.addEventListener('wheel', this.#onWheelEvent); window.addEventListener('wheel', this.#onWheelEvent, {passive: false});
window.addEventListener('contextmenu', this.#disableContextMenu); window.addEventListener('contextmenu', this.#disableContextMenu);
} }
stop(): void { stop(): void {
document.pointerLockElement && document.exitPointerLock();
window.removeEventListener('mousemove', this.#onMouseMoveEvent); window.removeEventListener('mousemove', this.#onMouseMoveEvent);
window.removeEventListener('mousedown', this.#onMouseEvent); window.removeEventListener('mousedown', this.#onMouseEvent);
window.removeEventListener('mouseup', this.#onMouseEvent); window.removeEventListener('mouseup', this.#onMouseEvent);
@ -94,32 +74,7 @@ class PointerLockMouseDataProvider extends MouseDataProvider {
window.removeEventListener('contextmenu', this.#disableContextMenu); window.removeEventListener('contextmenu', this.#disableContextMenu);
} }
destroy(): void { destroy(): void {}
document.removeEventListener('pointerlockchange', this.#onPointerLockChange);
document.removeEventListener('pointerlockerror', this.#onPointerLockError);
}
toggle(enabled: boolean): void {
enabled ? document.pointerLockElement && this.mkbHandler.start() : this.mkbHandler.stop();
if (enabled) {
!document.pointerLockElement && this.mkbHandler.waitForMouseData(true);
} else {
this.mkbHandler.waitForMouseData(false);
document.pointerLockElement && document.exitPointerLock();
}
}
#onPointerLockChange = () => {
if (this.mkbHandler.isEnabled() && !document.pointerLockElement) {
this.mkbHandler.stop();
}
}
#onPointerLockError = (e: Event) => {
console.log(e);
this.stop();
}
#onMouseMoveEvent = (e: MouseEvent) => { #onMouseMoveEvent = (e: MouseEvent) => {
this.mkbHandler.handleMouseMove({ this.mkbHandler.handleMouseMove({
@ -132,10 +87,9 @@ class PointerLockMouseDataProvider extends MouseDataProvider {
e.preventDefault(); e.preventDefault();
const isMouseDown = e.type === 'mousedown'; const isMouseDown = e.type === 'mousedown';
const key = KeyHelper.getKeyFromEvent(e);
const data: MkbMouseClick = { const data: MkbMouseClick = {
key: key, mouseButton: e.button,
pressed: isMouseDown pressed: isMouseDown,
}; };
this.mkbHandler.handleMouseClick(data); this.mkbHandler.handleMouseClick(data);
@ -147,7 +101,12 @@ class PointerLockMouseDataProvider extends MouseDataProvider {
return; return;
} }
if (this.mkbHandler.handleMouseWheel({key})) { const data: MkbMouseWheel = {
vertical: e.deltaY,
horizontal: e.deltaX,
};
if (this.mkbHandler.handleMouseWheel(data)) {
e.preventDefault(); e.preventDefault();
} }
} }
@ -159,14 +118,14 @@ class PointerLockMouseDataProvider extends MouseDataProvider {
This class uses some code from Yuzu emulator to handle mouse's movements This class uses some code from Yuzu emulator to handle mouse's movements
Source: https://github.com/yuzu-emu/yuzu-mainline/blob/master/src/input_common/drivers/mouse.cpp Source: https://github.com/yuzu-emu/yuzu-mainline/blob/master/src/input_common/drivers/mouse.cpp
*/ */
export class MkbHandler { export class EmulatedMkbHandler extends MkbHandler {
static #instance: MkbHandler; static #instance: EmulatedMkbHandler;
static get INSTANCE() { public static getInstance(): EmulatedMkbHandler {
if (!MkbHandler.#instance) { if (!EmulatedMkbHandler.#instance) {
MkbHandler.#instance = new MkbHandler(); EmulatedMkbHandler.#instance = new EmulatedMkbHandler();
} }
return MkbHandler.#instance; return EmulatedMkbHandler.#instance;
} }
#CURRENT_PRESET_DATA = MkbPreset.convert(MkbPreset.DEFAULT_PRESET); #CURRENT_PRESET_DATA = MkbPreset.convert(MkbPreset.DEFAULT_PRESET);
@ -178,7 +137,7 @@ export class MkbHandler {
static VIRTUAL_GAMEPAD_ID = 'Xbox 360 Controller'; static VIRTUAL_GAMEPAD_ID = 'Xbox 360 Controller';
#VIRTUAL_GAMEPAD = { #VIRTUAL_GAMEPAD = {
id: MkbHandler.VIRTUAL_GAMEPAD_ID, id: EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID,
index: 3, index: 3,
connected: false, connected: false,
hapticActuators: null, hapticActuators: null,
@ -203,6 +162,8 @@ export class MkbHandler {
#$message?: HTMLElement; #$message?: HTMLElement;
#escKeyDownTime: number = -1;
#STICK_MAP: {[key in GamepadKey]?: [GamepadKey[], number, number]}; #STICK_MAP: {[key in GamepadKey]?: [GamepadKey[], number, number]};
#LEFT_STICK_X: GamepadKey[] = []; #LEFT_STICK_X: GamepadKey[] = [];
#LEFT_STICK_Y: GamepadKey[] = []; #LEFT_STICK_Y: GamepadKey[] = [];
@ -210,6 +171,8 @@ export class MkbHandler {
#RIGHT_STICK_Y: GamepadKey[] = []; #RIGHT_STICK_Y: GamepadKey[] = [];
constructor() { constructor() {
super();
this.#STICK_MAP = { this.#STICK_MAP = {
[GamepadKey.LS_LEFT]: [this.#LEFT_STICK_X, 0, -1], [GamepadKey.LS_LEFT]: [this.#LEFT_STICK_X, 0, -1],
[GamepadKey.LS_RIGHT]: [this.#LEFT_STICK_X, 0, 1], [GamepadKey.LS_RIGHT]: [this.#LEFT_STICK_X, 0, 1],
@ -307,20 +270,34 @@ export class MkbHandler {
const isKeyDown = e.type === 'keydown'; const isKeyDown = e.type === 'keydown';
// Toggle MKB feature // Toggle MKB feature
if (isKeyDown) { if (e.code === 'F8') {
if (e.code === 'F8') { if (!isKeyDown) {
e.preventDefault(); e.preventDefault();
this.toggle(); this.toggle();
return;
} else if (e.code === 'Escape') {
e.preventDefault();
this.#enabled && this.stop();
return;
} }
if (!this.#isPolling) { return;
return; }
// Hijack the Esc button
if (e.code === 'Escape') {
e.preventDefault();
// Hold the Esc for 1 second to disable MKB
if (this.#enabled && isKeyDown) {
if (this.#escKeyDownTime === -1) {
this.#escKeyDownTime = performance.now();
} else if (performance.now() - this.#escKeyDownTime >= 1000) {
this.stop();
}
} else {
this.#escKeyDownTime = -1;
} }
return;
}
if (!this.#isPolling) {
return;
} }
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[e.code || e.key]!; const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[e.code || e.key]!;
@ -347,11 +324,24 @@ export class MkbHandler {
} }
handleMouseClick = (data: MkbMouseClick) => { handleMouseClick = (data: MkbMouseClick) => {
if (!data || !data.key) { let mouseButton;
if (typeof data.mouseButton !== 'undefined') {
mouseButton = data.mouseButton;
} else if (typeof data.pointerButton !== 'undefined') {
mouseButton = PointerToMouseButton[data.pointerButton as keyof typeof PointerToMouseButton];
}
const keyCode = 'Mouse' + mouseButton;
const key = {
code: keyCode,
name: KeyHelper.codeToKeyName(keyCode),
};
if (!key.name) {
return; return;
} }
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[data.key.code]!; const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code]!;
if (typeof buttonIndex === 'undefined') { if (typeof buttonIndex === 'undefined') {
return; return;
} }
@ -379,9 +369,9 @@ export class MkbHandler {
if (length !== 0 && length < deadzoneCounterweight) { if (length !== 0 && length < deadzoneCounterweight) {
x *= deadzoneCounterweight / length; x *= deadzoneCounterweight / length;
y *= deadzoneCounterweight / length; y *= deadzoneCounterweight / length;
} else if (length > MkbHandler.MAXIMUM_STICK_RANGE) { } else if (length > EmulatedMkbHandler.MAXIMUM_STICK_RANGE) {
x *= MkbHandler.MAXIMUM_STICK_RANGE / length; x *= EmulatedMkbHandler.MAXIMUM_STICK_RANGE / length;
y *= MkbHandler.MAXIMUM_STICK_RANGE / length; y *= EmulatedMkbHandler.MAXIMUM_STICK_RANGE / length;
} }
const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT; const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
@ -389,16 +379,32 @@ export class MkbHandler {
} }
handleMouseWheel = (data: MkbMouseWheel): boolean => { handleMouseWheel = (data: MkbMouseWheel): boolean => {
if (!data || !data.key) { let code = '';
if (data.vertical < 0) {
code = WheelCode.SCROLL_UP;
} else if (data.vertical > 0) {
code = WheelCode.SCROLL_DOWN;
} else if (data.horizontal < 0) {
code = WheelCode.SCROLL_LEFT;
} else if (data.horizontal > 0) {
code = WheelCode.SCROLL_RIGHT;
}
if (!code) {
return false; return false;
} }
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[data.key.code]!; const key = {
code: code,
name: KeyHelper.codeToKeyName(code),
};
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code]!;
if (typeof buttonIndex === 'undefined') { if (typeof buttonIndex === 'undefined') {
return false; return false;
} }
if (this.#prevWheelCode === null || this.#prevWheelCode === data.key.code) { if (this.#prevWheelCode === null || this.#prevWheelCode === key.code) {
this.#wheelStoppedTimeout && clearTimeout(this.#wheelStoppedTimeout); this.#wheelStoppedTimeout && clearTimeout(this.#wheelStoppedTimeout);
this.#pressButton(buttonIndex, true); this.#pressButton(buttonIndex, true);
} }
@ -411,10 +417,18 @@ export class MkbHandler {
return true; return true;
} }
toggle = () => { toggle = (force?: boolean) => {
this.#enabled = !this.#enabled; if (typeof force !== 'undefined') {
Toast.show(t('mouse-and-keyboard'), t(this.#enabled ? 'enabled' : 'disabled'), {instant: true}); this.#enabled = force;
this.#mouseDataProvider?.toggle(this.#enabled); } else {
this.#enabled = !this.#enabled;
}
if (this.#enabled) {
document.body.requestPointerLock();
} else {
document.pointerLockElement && document.exitPointerLock();
}
} }
#getCurrentPreset = (): Promise<MkbStoredPreset> => { #getCurrentPreset = (): Promise<MkbStoredPreset> => {
@ -450,9 +464,97 @@ export class MkbHandler {
} }
} }
#onDialogShown = () => {
document.pointerLockElement && document.exitPointerLock();
}
#initMessage = () => {
if (!this.#$message) {
this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg bx-gone'},
CE('div', {},
CE('p', {}, t('virtual-controller')),
CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})),
),
CE('div', {'data-type': 'virtual'},
createButton({
style: ButtonStyle.PRIMARY | ButtonStyle.TALL | ButtonStyle.FULL_WIDTH,
label: t('activate'),
onClick: ((e: Event) => {
e.preventDefault();
e.stopPropagation();
this.toggle(true);
}).bind(this),
}),
CE('div', {},
createButton({
label: t('ignore'),
style: ButtonStyle.GHOST,
onClick: e => {
e.preventDefault();
e.stopPropagation();
this.toggle(false);
this.waitForMouseData(false);
},
}),
createButton({
label: t('edit'),
onClick: e => {
e.preventDefault();
e.stopPropagation();
StreamSettings.getInstance().show('mkb');
},
}),
),
),
);
}
if (!this.#$message.isConnected) {
document.documentElement.appendChild(this.#$message);
}
}
#onPointerLockChange = () => {
if (document.pointerLockElement) {
this.start();
} else {
this.stop();
}
}
#onPointerLockError = (e: Event) => {
console.log(e);
this.stop();
}
#onPointerLockRequested = () => {
this.start();
}
#onPointerLockExited = () => {
this.#mouseDataProvider?.stop();
}
handleEvent(event: Event) {
switch (event.type) {
case BxEvent.POINTER_LOCK_REQUESTED:
this.#onPointerLockRequested();
break;
case BxEvent.POINTER_LOCK_EXITED:
this.#onPointerLockExited();
break;
}
}
init = () => { init = () => {
this.refreshPresetData(); this.refreshPresetData();
this.#enabled = true; this.#enabled = false;
if (AppInterface) { if (AppInterface) {
this.#mouseDataProvider = new WebSocketMouseDataProvider(this); this.#mouseDataProvider = new WebSocketMouseDataProvider(this);
@ -462,30 +564,29 @@ export class MkbHandler {
this.#mouseDataProvider.init(); this.#mouseDataProvider.init();
window.addEventListener('keydown', this.#onKeyboardEvent); window.addEventListener('keydown', this.#onKeyboardEvent);
window.addEventListener('keyup', this.#onKeyboardEvent);
this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg bx-gone'},
createButton({
icon: BxIcon.MOUSE_SETTINGS,
style: ButtonStyle.PRIMARY,
onClick: e => {
e.preventDefault();
e.stopPropagation();
showStreamSettings('mkb');
},
}),
CE('div', {},
CE('p', {}, t('mkb-click-to-activate')),
CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})),
),
);
this.#$message.addEventListener('click', this.start.bind(this));
document.documentElement.appendChild(this.#$message);
window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged); window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged);
window.addEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this.#onDialogShown);
this.waitForMouseData(true); if (AppInterface) {
// Android app doesn't support PointerLock API so we need to use a different method
window.addEventListener(BxEvent.POINTER_LOCK_REQUESTED, this);
window.addEventListener(BxEvent.POINTER_LOCK_EXITED, this);
} else {
document.addEventListener('pointerlockchange', this.#onPointerLockChange);
document.addEventListener('pointerlockerror', this.#onPointerLockError);
}
this.#initMessage();
this.#$message?.classList.add('bx-gone');
if (AppInterface) {
Toast.show(t('press-key-to-toggle-mkb', {key: `<b>F8</b>`}), t('virtual-controller'), {html: true});
this.waitForMouseData(false);
} else {
this.waitForMouseData(true);
}
} }
destroy = () => { destroy = () => {
@ -497,6 +598,18 @@ export class MkbHandler {
document.pointerLockElement && document.exitPointerLock(); document.pointerLockElement && document.exitPointerLock();
window.removeEventListener('keydown', this.#onKeyboardEvent); window.removeEventListener('keydown', this.#onKeyboardEvent);
window.removeEventListener('keyup', this.#onKeyboardEvent);
if (AppInterface) {
window.removeEventListener(BxEvent.POINTER_LOCK_REQUESTED, this);
window.removeEventListener(BxEvent.POINTER_LOCK_EXITED, this);
} else {
document.removeEventListener('pointerlockchange', this.#onPointerLockChange);
document.removeEventListener('pointerlockerror', this.#onPointerLockError);
}
window.removeEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged);
window.removeEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this.#onDialogShown);
this.#mouseDataProvider?.destroy(); this.#mouseDataProvider?.destroy();
@ -506,17 +619,17 @@ export class MkbHandler {
start = () => { start = () => {
if (!this.#enabled) { if (!this.#enabled) {
this.#enabled = true; this.#enabled = true;
Toast.show(t('mouse-and-keyboard'), t('enabled'), {instant: true}); Toast.show(t('virtual-controller'), t('enabled'), {instant: true});
} }
this.#isPolling = true; this.#isPolling = true;
this.#escKeyDownTime = -1;
this.#resetGamepad(); this.#resetGamepad();
window.navigator.getGamepads = this.#patchedGetGamepads; window.navigator.getGamepads = this.#patchedGetGamepads;
this.waitForMouseData(false); this.waitForMouseData(false);
window.addEventListener('keyup', this.#onKeyboardEvent);
this.#mouseDataProvider?.start(); this.#mouseDataProvider?.start();
// Dispatch "gamepadconnected" event // Dispatch "gamepadconnected" event
@ -527,36 +640,48 @@ export class MkbHandler {
BxEvent.dispatch(window, 'gamepadconnected', { BxEvent.dispatch(window, 'gamepadconnected', {
gamepad: virtualGamepad, gamepad: virtualGamepad,
}); });
window.BX_EXPOSED.stopTakRendering = true;
Toast.show(t('virtual-controller'), t('enabled'), {instant: true});
} }
stop = () => { stop = () => {
this.#enabled = false;
this.#isPolling = false; this.#isPolling = false;
this.#escKeyDownTime = -1;
// Dispatch "gamepaddisconnected" event
this.#resetGamepad();
const virtualGamepad = this.#getVirtualGamepad(); const virtualGamepad = this.#getVirtualGamepad();
virtualGamepad.connected = false; if (virtualGamepad.connected) {
virtualGamepad.timestamp = performance.now(); // Dispatch "gamepaddisconnected" event
this.#resetGamepad();
BxEvent.dispatch(window, 'gamepaddisconnected', { virtualGamepad.connected = false;
gamepad: virtualGamepad, virtualGamepad.timestamp = performance.now();
});
window.navigator.getGamepads = this.#nativeGetGamepads; BxEvent.dispatch(window, 'gamepaddisconnected', {
gamepad: virtualGamepad,
});
window.removeEventListener('keyup', this.#onKeyboardEvent); window.navigator.getGamepads = this.#nativeGetGamepads;
}
this.waitForMouseData(true); this.waitForMouseData(true);
this.#mouseDataProvider?.stop(); this.#mouseDataProvider?.stop();
// Toast.show(t('virtual-controller'), t('disabled'), {instant: true});
} }
static setupEvents() { static setupEvents() {
getPref(PrefKey.MKB_ENABLED) && (AppInterface || !UserAgent.isMobile()) && window.addEventListener(BxEvent.STREAM_PLAYING, () => { window.addEventListener(BxEvent.STREAM_PLAYING, () => {
// Enable MKB if (STATES.currentStream.titleInfo?.details.hasMkbSupport) {
if (!STATES.currentStream.titleInfo?.details.hasMkbSupport) { // Enable native MKB in Android app
BxLogger.info(LOG_TAG, 'Emulate MKB'); if (AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on') {
MkbHandler.INSTANCE.init(); AppInterface && NativeMkbHandler.getInstance().init();
}
} else if (getPref(PrefKey.MKB_ENABLED) && (AppInterface || !UserAgent.isMobile())) {
BxLogger.info(LOG_TAG, 'Emulate MKB');
EmulatedMkbHandler.getInstance().init();
} }
}); });
} }

View File

@ -1,7 +1,7 @@
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 { MkbHandler } 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";
@ -119,9 +119,9 @@ export class MkbPreset {
// Pre-calculate mouse's sensitivities // Pre-calculate mouse's sensitivities
const mouse = obj.mouse; const mouse = obj.mouse;
mouse[MkbPresetKey.MOUSE_SENSITIVITY_X] *= MkbHandler.DEFAULT_PANNING_SENSITIVITY; mouse[MkbPresetKey.MOUSE_SENSITIVITY_X] *= EmulatedMkbHandler.DEFAULT_PANNING_SENSITIVITY;
mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y] *= MkbHandler.DEFAULT_PANNING_SENSITIVITY; mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y] *= EmulatedMkbHandler.DEFAULT_PANNING_SENSITIVITY;
mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT] *= MkbHandler.DEFAULT_DEADZONE_COUNTERWEIGHT; mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT] *= EmulatedMkbHandler.DEFAULT_DEADZONE_COUNTERWEIGHT;
const mouseMapTo = MouseMapTo[mouse[MkbPresetKey.MOUSE_MAP_TO]!]; const mouseMapTo = MouseMapTo[mouse[MkbPresetKey.MOUSE_MAP_TO]!];
if (typeof mouseMapTo !== 'undefined') { if (typeof mouseMapTo !== 'undefined') {

View File

@ -1,16 +1,16 @@
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 { MkbHandler } from "./mkb-handler"; import { EmulatedMkbHandler } from "./mkb-handler";
import { LocalDb } from "@utils/local-db"; 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 = {
@ -258,7 +258,7 @@ export class MkbRemapper {
defaultPresetId = this.#STATE.currentPresetId; defaultPresetId = this.#STATE.currentPresetId;
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, defaultPresetId); setPref(PrefKey.MKB_DEFAULT_PRESET_ID, defaultPresetId);
MkbHandler.INSTANCE.refreshPresetData(); EmulatedMkbHandler.getInstance().refreshPresetData();
} else { } else {
defaultPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID); defaultPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
} }
@ -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;
} }
@ -487,7 +487,7 @@ export class MkbRemapper {
style: ButtonStyle.PRIMARY, style: ButtonStyle.PRIMARY,
onClick: e => { onClick: e => {
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId); setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId);
MkbHandler.INSTANCE.refreshPresetData(); EmulatedMkbHandler.getInstance().refreshPresetData();
this.#refresh(); this.#refresh();
}, },
@ -511,13 +511,13 @@ 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 => {
// If this is the default preset => refresh preset data // If this is the default preset => refresh preset data
if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) { if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) {
MkbHandler.INSTANCE.refreshPresetData(); EmulatedMkbHandler.getInstance().refreshPresetData();
} }
this.#toggleEditing(false); this.#toggleEditing(false);

View File

@ -0,0 +1,319 @@
import { Toast } from "@/utils/toast";
import { PointerClient } from "./pointer-client";
import { AppInterface, STATES } from "@/utils/global";
import { MkbHandler } from "./base-mkb-handler";
import { t } from "@/utils/translation";
import { BxEvent } from "@/utils/bx-event";
import { ButtonStyle, CE, createButton } from "@/utils/html";
import { PrefKey, getPref } from "@/utils/preferences";
type NativeMouseData = {
X: number,
Y: number,
Buttons: number,
WheelX: number,
WheelY: number,
Type? : 0, // 0: Relative, 1: Absolute
}
type XcloudInputSink = {
onMouseInput: (data: NativeMouseData) => void;
}
export class NativeMkbHandler extends MkbHandler {
private static instance: NativeMkbHandler;
#pointerClient: PointerClient | undefined;
#enabled: boolean = false;
#mouseButtonsPressed = 0;
#mouseWheelX = 0;
#mouseWheelY = 0;
#mouseVerticalMultiply = 0;
#mouseHorizontalMultiply = 0;
#inputSink: XcloudInputSink | undefined;
#$message?: HTMLElement;
public static getInstance(): NativeMkbHandler {
if (!NativeMkbHandler.instance) {
NativeMkbHandler.instance = new NativeMkbHandler();
}
return NativeMkbHandler.instance;
}
#onKeyboardEvent(e: KeyboardEvent) {
if (e.type === 'keyup' && e.code === 'F8') {
e.preventDefault();
this.toggle();
return;
}
}
#onPointerLockRequested(e: Event) {
AppInterface.requestPointerCapture();
this.start();
}
#onPointerLockExited(e: Event) {
AppInterface.releasePointerCapture();
this.stop();
}
#onPollingModeChanged = (e: Event) => {
if (!this.#$message) {
return;
}
const mode = (e as any).mode;
if (mode === 'None') {
this.#$message.classList.remove('bx-offscreen');
} else {
this.#$message.classList.add('bx-offscreen');
}
}
#onDialogShown = () => {
document.pointerLockElement && document.exitPointerLock();
}
#initMessage() {
if (!this.#$message) {
this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg'},
CE('div', {},
CE('p', {}, t('native-mkb')),
CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})),
),
CE('div', {'data-type': 'native'},
createButton({
style: ButtonStyle.PRIMARY | ButtonStyle.FULL_WIDTH | ButtonStyle.TALL,
label: t('activate'),
onClick: ((e: Event) => {
e.preventDefault();
e.stopPropagation();
this.toggle(true);
}).bind(this),
}),
createButton({
style: ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH,
label: t('ignore'),
onClick: e => {
e.preventDefault();
e.stopPropagation();
this.#$message?.classList.add('bx-gone');
},
}),
),
);
}
if (!this.#$message.isConnected) {
document.documentElement.appendChild(this.#$message);
}
}
handleEvent(event: Event) {
switch (event.type) {
case 'keyup':
this.#onKeyboardEvent(event as KeyboardEvent);
break;
case BxEvent.XCLOUD_DIALOG_SHOWN:
this.#onDialogShown();
break;
case BxEvent.POINTER_LOCK_REQUESTED:
this.#onPointerLockRequested(event);
break;
case BxEvent.POINTER_LOCK_EXITED:
this.#onPointerLockExited(event);
break;
case BxEvent.XCLOUD_POLLING_MODE_CHANGED:
this.#onPollingModeChanged(event);
break;
}
}
init() {
this.#pointerClient = PointerClient.getInstance();
this.#inputSink = window.BX_EXPOSED.inputSink;
// Stop keyboard input at startup
this.#updateInputConfigurationAsync(false);
try {
this.#pointerClient.start(STATES.pointerServerPort, this);
} catch (e) {
Toast.show('Cannot enable Mouse & Keyboard feature');
}
this.#mouseVerticalMultiply = getPref(PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY);
this.#mouseHorizontalMultiply = getPref(PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY);
window.addEventListener('keyup', this);
window.addEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this);
window.addEventListener(BxEvent.POINTER_LOCK_REQUESTED, this);
window.addEventListener(BxEvent.POINTER_LOCK_EXITED, this);
window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this);
this.#initMessage();
if (AppInterface) {
Toast.show(t('press-key-to-toggle-mkb', {key: `<b>F8</b>`}), t('native-mkb'), {html: true});
this.#$message?.classList.add('bx-gone');
} else {
this.#$message?.classList.remove('bx-gone');
}
}
toggle(force?: boolean) {
let setEnable: boolean;
if (typeof force !== 'undefined') {
setEnable = force;
} else {
setEnable = !this.#enabled;
}
if (setEnable) {
document.documentElement.requestPointerLock();
} else {
document.exitPointerLock();
}
}
#updateInputConfigurationAsync(enabled: boolean) {
window.BX_EXPOSED.streamSession.updateInputConfigurationAsync({
enableKeyboardInput: enabled,
enableMouseInput: enabled,
enableAbsoluteMouse: false,
enableTouchInput: false,
});
}
start() {
this.#resetMouseInput();
this.#enabled = true;
this.#updateInputConfigurationAsync(true);
window.BX_EXPOSED.stopTakRendering = true;
this.#$message?.classList.add('bx-gone');
Toast.show(t('native-mkb'), t('enabled'), {instant: true});
}
stop() {
this.#resetMouseInput();
this.#enabled = false;
this.#updateInputConfigurationAsync(false);
this.#$message?.classList.remove('bx-gone');
}
destroy(): void {
this.#pointerClient?.stop();
window.removeEventListener('keyup', this);
window.removeEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this);
window.removeEventListener(BxEvent.POINTER_LOCK_REQUESTED, this);
window.removeEventListener(BxEvent.POINTER_LOCK_EXITED, this);
window.removeEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this);
this.#$message?.classList.add('bx-gone');
}
handleMouseMove(data: MkbMouseMove): void {
this.#sendMouseInput({
X: data.movementX,
Y: data.movementY,
Buttons: this.#mouseButtonsPressed,
WheelX: this.#mouseWheelX,
WheelY: this.#mouseWheelY,
});
}
handleMouseClick(data: MkbMouseClick): void {
const { pointerButton, pressed } = data;
if (pressed) {
this.#mouseButtonsPressed |= pointerButton!;
} else {
this.#mouseButtonsPressed ^= pointerButton!;
}
this.#mouseButtonsPressed = Math.max(0, this.#mouseButtonsPressed);
this.#sendMouseInput({
X: 0,
Y: 0,
Buttons: this.#mouseButtonsPressed,
WheelX: this.#mouseWheelX,
WheelY: this.#mouseWheelY,
});
}
handleMouseWheel(data: MkbMouseWheel): boolean {
const { vertical, horizontal } = data;
this.#mouseWheelX = horizontal;
if (this.#mouseHorizontalMultiply && this.#mouseHorizontalMultiply !== 1) {
this.#mouseWheelX *= this.#mouseHorizontalMultiply;
}
this.#mouseWheelY = vertical;
if (this.#mouseVerticalMultiply && this.#mouseVerticalMultiply !== 1) {
this.#mouseWheelY *= this.#mouseVerticalMultiply;
}
this.#sendMouseInput({
X: 0,
Y: 0,
Buttons: this.#mouseButtonsPressed,
WheelX: this.#mouseWheelX,
WheelY: this.#mouseWheelY,
});
return true;
}
setVerticalScrollMultiplier(vertical: number) {
this.#mouseVerticalMultiply = vertical;
}
setHorizontalScrollMultiplier(horizontal: number) {
this.#mouseHorizontalMultiply = horizontal;
}
waitForMouseData(enabled: boolean): void {
}
isEnabled(): boolean {
return this.#enabled;
}
#sendMouseInput(data: NativeMouseData) {
data.Type = 0; // Relative
this.#inputSink?.onMouseInput(data);
}
#resetMouseInput() {
this.#mouseButtonsPressed = 0;
this.#mouseWheelX = 0;
this.#mouseWheelY = 0;
this.#sendMouseInput({
X: 0,
Y: 0,
Buttons: 0,
WheelX: 0,
WheelY: 0,
});
}
}

View File

@ -1,8 +1,6 @@
import { BxLogger } from "@/utils/bx-logger"; import { BxLogger } from "@/utils/bx-logger";
import type { MkbHandler } from "./mkb-handler";
import { KeyHelper } from "./key-helper";
import { WheelCode } from "./definitions";
import { Toast } from "@/utils/toast"; import { Toast } from "@/utils/toast";
import type { MkbHandler } from "./base-mkb-handler";
const LOG_TAG = 'PointerClient'; const LOG_TAG = 'PointerClient';
@ -14,15 +12,8 @@ enum PointerAction {
POINTER_CAPTURE_CHANGED = 5, POINTER_CAPTURE_CHANGED = 5,
} }
const FixedMouseIndex = {
1: 0,
2: 2,
4: 1,
}
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) {
@ -35,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
@ -50,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) => {
@ -97,15 +92,10 @@ export class PointerClient {
} }
onPress(messageType: PointerAction, dataView: DataView, offset: number) { onPress(messageType: PointerAction, dataView: DataView, offset: number) {
const buttonIndex = dataView.getInt8(offset); const button = dataView.getUint8(offset);
const fixedIndex = FixedMouseIndex[buttonIndex as keyof typeof FixedMouseIndex];
const keyCode = 'Mouse' + fixedIndex;
this.#mkbHandler?.handleMouseClick({ this.#mkbHandler?.handleMouseClick({
key: { pointerButton: button,
code: keyCode,
name: KeyHelper.codeToKeyName(keyCode),
},
pressed: messageType === PointerAction.BUTTON_PRESS, pressed: messageType === PointerAction.BUTTON_PRESS,
}); });
@ -114,26 +104,13 @@ export class PointerClient {
onScroll(dataView: DataView, offset: number) { onScroll(dataView: DataView, offset: number) {
// [V_SCROLL, H_SCROLL] // [V_SCROLL, H_SCROLL]
const vScroll = dataView.getInt8(offset); const vScroll = dataView.getInt16(offset);
offset += Int8Array.BYTES_PER_ELEMENT; offset += Int16Array.BYTES_PER_ELEMENT;
const hScroll = dataView.getInt8(offset); const hScroll = dataView.getInt16(offset);
let code = ''; this.#mkbHandler?.handleMouseWheel({
if (vScroll < 0) { vertical: vScroll,
code = WheelCode.SCROLL_UP; horizontal: hScroll,
} else if (vScroll > 0) {
code = WheelCode.SCROLL_DOWN;
} else if (hScroll < 0) {
code = WheelCode.SCROLL_LEFT;
} else if (hScroll > 0) {
code = WheelCode.SCROLL_RIGHT;
}
code && this.#mkbHandler?.handleMouseWheel({
key: {
code: code,
name: KeyHelper.codeToKeyName(code),
},
}); });
// BxLogger.info(LOG_TAG, 'scroll', vScroll, hScroll); // BxLogger.info(LOG_TAG, 'scroll', vScroll, hScroll);
@ -148,5 +125,6 @@ export class PointerClient {
try { try {
this.#socket?.close(); this.#socket?.close();
} catch (e) {} } catch (e) {}
this.#socket = null;
} }
} }

View File

@ -7,10 +7,14 @@ 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 codeSetCurrentlyFocusedInteractable from "./patches/set-currently-focused-interactable.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 +191,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 +238,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;
@ -304,6 +312,37 @@ window.dispatchEvent(new Event("${BxEvent.TOUCH_LAYOUT_MANAGER_READY}"));
return str; return str;
}, },
patchBabylonRendererClass(str: string) {
// ()=>{a.current.render(),h.current=window.requestAnimationFrame(l)
let index = str.indexOf('.current.render(),');
if (index === -1) {
return false;
}
// Move back a character
index -= 1;
// Get variable of the "BabylonRendererClass" object
const rendererVar = str[index];
const newCode = `
if (window.BX_EXPOSED.stopTakRendering) {
try {
document.getElementById('BabylonCanvasContainer-main')?.parentElement.classList.add('bx-offscreen');
${rendererVar}.current.dispose();
} catch (e) {}
window.BX_EXPOSED.stopTakRendering = false;
return;
}
`;
str = str.substring(0, index) + newCode + str.substring(index);
return str;
},
supportLocalCoOp(str: string) { supportLocalCoOp(str: string) {
const text = 'this.gamepadMappingsToSend=[],'; const text = 'this.gamepadMappingsToSend=[],';
if (!str.includes(text)) { if (!str.includes(text)) {
@ -535,38 +574,251 @@ 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);
return str; return str;
}, },
skipFeedbackDialog(str: string) {
const text = '&&this.shouldTransitionToFeedback(';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, '&& false ' + text);
return str;
},
enableNativeMkb(str: string) {
const text = 'e.mouseSupported&&e.keyboardSupported&&e.fullscreenSupported;';
if ((!str.includes(text))) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
patchMouseAndKeyboardEnabled(str: string) {
const text = 'get mouseAndKeyboardEnabled(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
exposeInputSink(str: string) {
const text = 'this.controlChannel=null,this.inputChannel=null';
if (!str.includes(text)) {
return false;
}
const newCode = 'window.BX_EXPOSED.inputSink = this;';
str = str.replace(text, newCode + text);
return str;
},
disableNativeRequestPointerLock(str: string) {
const text = 'async requestPointerLock(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return;');
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;
},
// 24225.js#4127, 24.17.11
patchSetCurrentlyFocusedInteractable(str: string) {
let index = str.indexOf('.setCurrentlyFocusedInteractable=(');
if (index === -1) {
return false;
}
index = str.indexOf('{', index) + 1;
str = str.substring(0, index) + codeSetCurrentlyFocusedInteractable + str.substring(index);
return str;
},
}; };
let PATCH_ORDERS: PatchArray = [ let PATCH_ORDERS: PatchArray = [
...(getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' ? [
'enableNativeMkb',
'patchMouseAndKeyboardEnabled',
'disableNativeRequestPointerLock',
'exposeInputSink',
] : []),
'patchRequestInfoCrash',
'disableStreamGate', 'disableStreamGate',
'overrideSettings', 'overrideSettings',
'broadcastPollingMode', 'broadcastPollingMode',
'exposeStreamSession', 'exposeStreamSession',
'exposeDialogRoutes',
'enableTvRoutes',
'overrideStorageGetSettings',
// 'patchSetCurrentlyFocusedInteractable',
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',
@ -582,7 +834,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 ? [
@ -598,6 +850,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
@ -605,11 +859,16 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
// Patch volume control for combined audio+video stream // Patch volume control for combined audio+video stream
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchCombinedAudioVideoMediaStream', getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchCombinedAudioVideoMediaStream',
// Skip feedback dialog
getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', ...(STATES.userAgent.capabilities.touch ? [
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls',
STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity', (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF) || !STATES.userAgent.capabilities.touch) && 'disableTakRenderer',
getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity',
'patchBabylonRendererClass',
] : []),
BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging', BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging',

View File

@ -11,7 +11,12 @@ if (btnHome) {
this.bxHomeStates = {}; this.bxHomeStates = {};
} }
let intervalMs = 0;
let hijack = false;
if (btnHome.pressed) { if (btnHome.pressed) {
hijack = true;
intervalMs = 16;
this.gamepadIsIdle.set(currentGamepad.index, false); this.gamepadIsIdle.set(currentGamepad.index, false);
if (this.bxHomeStates[currentGamepad.index]) { if (this.bxHomeStates[currentGamepad.index]) {
@ -33,14 +38,8 @@ if (btnHome) {
timestamp: currentGamepad.timestamp, timestamp: currentGamepad.timestamp,
}; };
} }
// Listen to next button press
const intervalMs = 16;
this.inputConfiguration.useIntervalWorkerThreadForInput && this.intervalWorker ? this.intervalWorker.scheduleTimer(intervalMs) : this.pollGamepadssetTimeoutTimerID = setTimeout(this.pollGamepads, intervalMs);
// Hijack this button
return;
} else if (this.bxHomeStates[currentGamepad.index]) { } else if (this.bxHomeStates[currentGamepad.index]) {
hijack = true;
const info = structuredClone(this.bxHomeStates[currentGamepad.index]); const info = structuredClone(this.bxHomeStates[currentGamepad.index]);
// Home button released // Home button released
@ -77,11 +76,19 @@ if (btnHome) {
}]; }];
const isLongPress = (currentGamepad.timestamp - info.timestamp) >= 500; const isLongPress = (currentGamepad.timestamp - info.timestamp) >= 500;
const intervalMs = isLongPress ? 500 : 100; intervalMs = isLongPress ? 500 : 100;
this.inputSink.onGamepadInput(performance.now() - intervalMs, fakeGamepadMappings); this.inputSink.onGamepadInput(performance.now() - intervalMs, fakeGamepadMappings);
this.inputConfiguration.useIntervalWorkerThreadForInput && this.intervalWorker ? this.intervalWorker.scheduleTimer(intervalMs) : this.pollGamepadssetTimeoutTimerID = setTimeout(this.pollGamepads, intervalMs); } else {
return; intervalMs = 4;
} }
} }
if (hijack && intervalMs) {
// Listen to next button press
this.inputConfiguration.useIntervalWorkerThreadForInput && this.intervalWorker ? this.intervalWorker.scheduleTimer(intervalMs) : this.pollGamepadssetTimeoutTimerID = setTimeout(this.pollGamepads, intervalMs);
// Hijack this button
return;
}
} }

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 @@
e && BxEvent.dispatch(window, BxEvent.NAVIGATION_FOCUS_CHANGED, {element: e});

View File

@ -3,7 +3,11 @@ if (!window.BX_ENABLE_CONTROLLER_VIBRATION) {
} }
const intensity = window.BX_VIBRATION_INTENSITY; const intensity = window.BX_VIBRATION_INTENSITY;
if (intensity && intensity < 1) { if (intensity === 0) {
return void(0);
}
if (intensity < 1) {
e.leftMotorPercent *= intensity; e.leftMotorPercent *= intensity;
e.rightMotorPercent *= intensity; e.rightMotorPercent *= intensity;
e.leftTriggerMotorPercent *= intensity; e.leftTriggerMotorPercent *= intensity;

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

@ -7,6 +7,7 @@ import { getPref, PrefKey, setPref } from "@utils/preferences";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { localRedirect } from "@modules/ui/ui"; import { localRedirect } from "@modules/ui/ui";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { HeaderSection } from "./ui/header";
const LOG_TAG = 'RemotePlay'; const LOG_TAG = 'RemotePlay';
@ -97,6 +98,10 @@ export class RemotePlay {
RemotePlay.#getXhomeToken(() => { RemotePlay.#getXhomeToken(() => {
RemotePlay.#getConsolesList(() => { RemotePlay.#getConsolesList(() => {
BxLogger.info(LOG_TAG, 'Consoles', RemotePlay.#CONSOLES); BxLogger.info(LOG_TAG, 'Consoles', RemotePlay.#CONSOLES);
if (RemotePlay.#CONSOLES && RemotePlay.#CONSOLES.length > 0) {
STATES.supportedRegion && HeaderSection.showRemotePlayButton();
}
RemotePlay.#renderConsoles(); RemotePlay.#renderConsoles();
BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY); BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY);
}); });

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

@ -1,85 +1,105 @@
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { CE } from "@utils/html"; import { CE, createSvgIcon } from "@utils/html";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { BxLogger } from "@/utils/bx-logger";
import { BxIcon } from "@/utils/bx-icon";
enum StreamBadge { enum StreamBadge {
PLAYTIME = 'playtime', PLAYTIME = 'playtime',
BATTERY = 'battery', BATTERY = 'battery',
IN = 'in', DOWNLOAD = 'in',
OUT = 'out', UPLOAD = 'out',
SERVER = 'server', SERVER = 'server',
VIDEO = 'video', VIDEO = 'video',
AUDIO = 'audio', AUDIO = 'audio',
}
BREAK = 'break', const StreamBadgeIcon: Partial<{[key in StreamBadge]: any}> = {
[StreamBadge.PLAYTIME]: BxIcon.PLAYTIME,
[StreamBadge.VIDEO]: BxIcon.DISPLAY,
[StreamBadge.BATTERY]: BxIcon.BATTERY,
[StreamBadge.DOWNLOAD]: BxIcon.DOWNLOAD,
[StreamBadge.UPLOAD]: BxIcon.UPLOAD,
[StreamBadge.SERVER]: BxIcon.SERVER,
[StreamBadge.AUDIO]: BxIcon.AUDIO,
} }
export class StreamBadges { export class StreamBadges {
static ipv6 = false; private static instance: StreamBadges;
static resolution?: {width: number, height: number} | null = null; public static getInstance(): StreamBadges {
static video?: {codec: string, profile?: string | null} | null = null; if (!StreamBadges.instance) {
static audio?: {codec: string, bitrate: number} | null = null; StreamBadges.instance = new StreamBadges();
static fps = 0;
static region = '';
static startBatteryLevel = 100;
static startTimestamp = 0;
static #cachedDoms: {[index: string]: HTMLElement} = {};
static #interval?: number | null;
static readonly #REFRESH_INTERVAL = 3000;
static #renderBadge(name: StreamBadge, value: string, color: string) {
if (name === StreamBadge.BREAK) {
return CE('div', {'style': 'display: block'});
} }
return StreamBadges.instance;
}
#ipv6 = false;
#resolution?: {width: number, height: number} | null = null;
#video?: {codec: string, profile?: string | null} | null = null;
#audio?: {codec: string, bitrate: number} | null = null;
#region = '';
startBatteryLevel = 100;
startTimestamp = 0;
#$container: HTMLElement | undefined;
#cachedDoms: Partial<{[key in StreamBadge]: HTMLElement}> = {};
#interval?: number | null;
readonly #REFRESH_INTERVAL = 3000;
setRegion(region: string) {
this.#region = region;
}
#renderBadge(name: StreamBadge, value: string, color: string) {
let $badge; let $badge;
if (StreamBadges.#cachedDoms[name]) { if (this.#cachedDoms[name]) {
$badge = StreamBadges.#cachedDoms[name]; $badge = this.#cachedDoms[name]!;
$badge.lastElementChild!.textContent = value; $badge.lastElementChild!.textContent = value;
return $badge; return $badge;
} }
$badge = CE('div', {'class': 'bx-badge'}, $badge = CE('div', {'class': 'bx-badge', 'title': t(`badge-${name}`)},
CE('span', {'class': 'bx-badge-name'}, t(`badge-${name}`)), CE('span', {'class': 'bx-badge-name'}, createSvgIcon(StreamBadgeIcon[name])),
CE('span', {'class': 'bx-badge-value', 'style': `background-color: ${color}`}, value)); CE('span', {'class': 'bx-badge-value', 'style': `background-color: ${color}`}, value),
);
if (name === StreamBadge.BATTERY) { if (name === StreamBadge.BATTERY) {
$badge.classList.add('bx-badge-battery'); $badge.classList.add('bx-badge-battery');
} }
StreamBadges.#cachedDoms[name] = $badge; this.#cachedDoms[name] = $badge;
return $badge; return $badge;
} }
static async #updateBadges(forceUpdate: boolean) { async #updateBadges(forceUpdate = false) {
if (!forceUpdate && !document.querySelector('.bx-badges')) { if (!this.#$container || (!forceUpdate && !this.#$container.isConnected)) {
StreamBadges.#stop(); this.#stop();
return; return;
} }
// Playtime // Playtime
let now = +new Date; let now = +new Date;
const diffSeconds = Math.ceil((now - StreamBadges.startTimestamp) / 1000); const diffSeconds = Math.ceil((now - this.startTimestamp) / 1000);
const playtime = StreamBadges.#secondsToHm(diffSeconds); const playtime = this.#secondsToHm(diffSeconds);
// Battery // Battery
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;
batteryLevelInt = Math.round(bm.level * 100); batteryLevelInt = Math.round(bm.level * 100);
batteryLevel = `${batteryLevelInt}%`; batteryLevel = `${batteryLevelInt}%`;
if (batteryLevelInt != StreamBadges.startBatteryLevel) { if (batteryLevelInt != this.startBatteryLevel) {
const diffLevel = Math.round(batteryLevelInt - StreamBadges.startBatteryLevel); const diffLevel = Math.round(batteryLevelInt - this.startBatteryLevel);
const sign = diffLevel > 0 ? '+' : ''; const sign = diffLevel > 0 ? '+' : '';
batteryLevel += ` (${sign}${diffLevel}%)`; batteryLevel += ` (${sign}${diffLevel}%)`;
} }
@ -97,8 +117,8 @@ export class StreamBadges {
}); });
const badges = { const badges = {
[StreamBadge.IN]: totalIn ? StreamBadges.#humanFileSize(totalIn) : null, [StreamBadge.DOWNLOAD]: totalIn ? this.#humanFileSize(totalIn) : null,
[StreamBadge.OUT]: totalOut ? StreamBadges.#humanFileSize(totalOut) : null, [StreamBadge.UPLOAD]: totalOut ? this.#humanFileSize(totalOut) : null,
[StreamBadge.PLAYTIME]: playtime, [StreamBadge.PLAYTIME]: playtime,
[StreamBadge.BATTERY]: batteryLevel, [StreamBadge.BATTERY]: batteryLevel,
}; };
@ -110,56 +130,76 @@ export class StreamBadges {
continue; continue;
} }
const $elm = StreamBadges.#cachedDoms[name]; const $elm = this.#cachedDoms[name]!;
$elm && ($elm.lastElementChild!.textContent = value); $elm && ($elm.lastElementChild!.textContent = value);
if (name === StreamBadge.BATTERY) { if (name === StreamBadge.BATTERY) {
// Show charging status if (this.startBatteryLevel === 100 && batteryLevelInt === 100) {
$elm.setAttribute('data-charging', isCharging.toString()); // Hide battery badge when the battery is 100%
$elm.classList.add('bx-gone');
if (StreamBadges.startBatteryLevel === 100 && batteryLevelInt === 100) {
$elm.style.display = 'none';
} else { } else {
$elm.removeAttribute('style'); // Show charging status
$elm.dataset.charging = isCharging.toString()
$elm.classList.remove('bx-gone');
} }
} }
} }
} }
static #stop() { async #start() {
StreamBadges.#interval && clearInterval(StreamBadges.#interval); await this.#updateBadges(true);
StreamBadges.#interval = null; this.#stop();
this.#interval = window.setInterval(this.#updateBadges.bind(this), this.#REFRESH_INTERVAL);
} }
static #secondsToHm(seconds: number) { #stop() {
const h = Math.floor(seconds / 3600); this.#interval && clearInterval(this.#interval);
const m = Math.floor(seconds % 3600 / 60) + 1; this.#interval = null;
}
const hDisplay = h > 0 ? `${h}h`: ''; #secondsToHm(seconds: number) {
const mDisplay = m > 0 ? `${m}m`: ''; let h = Math.floor(seconds / 3600);
return hDisplay + mDisplay; let m = Math.floor(seconds % 3600 / 60) + 1;
if (m === 60) {
h += 1;
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
static #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];
} }
static async render() { async render() {
// Video if (this.#$container) {
let video = ''; this.#start();
if (StreamBadges.resolution) { return this.#$container;
video = `${StreamBadges.resolution.height}p`;
} }
if (StreamBadges.video) { await this.#getServerStats();
// Video
let video = '';
if (this.#resolution) {
video = `${this.#resolution.height}p`;
}
if (this.#video) {
video && (video += '/'); video && (video += '/');
video += StreamBadges.video.codec; video += this.#video.codec;
if (StreamBadges.video.profile) { if (this.#video.profile) {
const profile = StreamBadges.video.profile; const profile = this.#video.profile;
let quality = profile; let quality = profile;
if (profile.startsWith('4d')) { if (profile.startsWith('4d')) {
@ -176,66 +216,152 @@ export class StreamBadges {
// Audio // Audio
let audio; let audio;
if (StreamBadges.audio) { if (this.#audio) {
audio = StreamBadges.audio.codec; audio = this.#audio.codec;
const bitrate = StreamBadges.audio.bitrate / 1000; const bitrate = this.#audio.bitrate / 1000;
audio += ` (${bitrate} kHz)`; audio += ` (${bitrate} kHz)`;
} }
// Battery // Battery
let batteryLevel = ''; let batteryLevel = '';
if ('getBattery' in navigator) { if (STATES.browser.capabilities.batteryApi) {
batteryLevel = '100%'; batteryLevel = '100%';
} }
// Server + Region // Server + Region
let server = StreamBadges.region; let server = this.#region;
server += '@' + (StreamBadges.ipv6 ? 'IPv6' : 'IPv4'); server += '@' + (this.#ipv6 ? 'IPv6' : 'IPv4');
const BADGES = [ const BADGES = [
[StreamBadge.PLAYTIME, '1m', '#ff004d'], [StreamBadge.PLAYTIME, '1m', '#ff004d'],
[StreamBadge.BATTERY, batteryLevel, '#00b543'], [StreamBadge.BATTERY, batteryLevel, '#00b543'],
[StreamBadge.IN, StreamBadges.#humanFileSize(0), '#29adff'], [StreamBadge.DOWNLOAD, this.#humanFileSize(0), '#29adff'],
[StreamBadge.OUT, StreamBadges.#humanFileSize(0), '#ff77a8'], [StreamBadge.UPLOAD, this.#humanFileSize(0), '#ff77a8'],
[StreamBadge.BREAK],
[StreamBadge.SERVER, server, '#ff6c24'], [StreamBadge.SERVER, server, '#ff6c24'],
video ? [StreamBadge.VIDEO, video, '#742f29'] : null, video ? [StreamBadge.VIDEO, video, '#742f29'] : null,
audio ? [StreamBadge.AUDIO, audio, '#5f574f'] : null, audio ? [StreamBadge.AUDIO, audio, '#5f574f'] : null,
]; ];
const $wrapper = CE('div', {'class': 'bx-badges'}); const $container = CE('div', {'class': 'bx-badges'});
BADGES.forEach(item => { BADGES.forEach(item => {
if (!item) { if (!item) {
return; return;
} }
const $badge = StreamBadges.#renderBadge(...(item as [StreamBadge, string, string])); const $badge = this.#renderBadge(...(item as [StreamBadge, string, string]));
$wrapper.appendChild($badge); $container.appendChild($badge);
}); });
await StreamBadges.#updateBadges(true); this.#$container = $container;
StreamBadges.#stop(); await this.#start();
StreamBadges.#interval = window.setInterval(StreamBadges.#updateBadges, StreamBadges.#REFRESH_INTERVAL);
return $wrapper; return $container;
}
async #getServerStats() {
const stats = await STATES.currentStream.peerConnection!.getStats();
const allVideoCodecs: {[index: string]: RTCBasicStat} = {};
let videoCodecId;
const allAudioCodecs: {[index: string]: RTCBasicStat} = {};
let audioCodecId;
const allCandidates: {[index: string]: string} = {};
let candidateId;
stats.forEach((stat: RTCBasicStat) => {
if (stat.type === 'codec') {
const mimeType = stat.mimeType.split('/')[0];
if (mimeType === 'video') {
// Store all video stats
allVideoCodecs[stat.id] = stat;
} else if (mimeType === 'audio') {
// Store all audio stats
allAudioCodecs[stat.id] = stat;
}
} else if (stat.type === 'inbound-rtp' && stat.packetsReceived > 0) {
// Get the codecId of the video/audio track currently being used
if (stat.kind === 'video') {
videoCodecId = stat.codecId;
} else if (stat.kind === 'audio') {
audioCodecId = stat.codecId;
}
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
candidateId = stat.remoteCandidateId;
} else if (stat.type === 'remote-candidate') {
allCandidates[stat.id] = stat.address;
}
});
// Get video codec from codecId
if (videoCodecId) {
const videoStat = allVideoCodecs[videoCodecId];
const video: any = {
codec: videoStat.mimeType.substring(6),
};
if (video.codec === 'H264') {
const match = /profile-level-id=([0-9a-f]{6})/.exec(videoStat.sdpFmtpLine);
video.profile = match ? match[1] : null;
}
this.#video = video;
}
// Get audio codec from codecId
if (audioCodecId) {
const audioStat = allAudioCodecs[audioCodecId];
this.#audio = {
codec: audioStat.mimeType.substring(6),
bitrate: audioStat.clockRate,
}
}
// Get server type
if (candidateId) {
BxLogger.info('candidate', candidateId, allCandidates);
this.#ipv6 = allCandidates[candidateId].includes(':');
}
} }
static setupEvents() { static setupEvents() {
window.addEventListener(BxEvent.STREAM_PLAYING, e => { window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const $video = (e as any).$video; const $video = (e as any).$video;
const streamBadges = StreamBadges.getInstance();
StreamBadges.resolution = { streamBadges.#resolution = {
width: $video.videoWidth, width: $video.videoWidth,
height: $video.videoHeight height: $video.videoHeight,
}; };
StreamBadges.startTimestamp = +new Date; streamBadges.startTimestamp = +new Date;
// 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) {}
}); });
/*
Don't do this until xCloud remove the Stream Menu page
window.addEventListener(BxEvent.XCLOUD_GUIDE_SHOWN, async e => {
const where = (e as any).where as XcloudGuideWhere;
if (where !== XcloudGuideWhere.HOME || !STATES.isPlaying) {
return;
}
const $btnQuit = document.querySelector('#gamepass-dialog-root a[class*=QuitGameButton]');
if (!$btnQuit) {
return;
}
// Add badges
$btnQuit.insertAdjacentElement('beforebegin', await StreamBadges.getInstance().render());
});
*/
} }
} }

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,11 +1,8 @@
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 { StreamBadges } from "./stream-badges"
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"
import { BxLogger } from "@utils/bx-logger"
export enum StreamStat { export enum StreamStat {
PING = 'ping', PING = 'ping',
@ -17,272 +14,238 @@ export enum StreamStat {
}; };
export class StreamStats { export class StreamStats {
static #interval?: number | null; private static instance: StreamStats;
static #updateInterval = 1000; public static getInstance(): StreamStats {
if (!StreamStats.instance) {
StreamStats.instance = new StreamStats();
}
static #$container: HTMLElement; return StreamStats.instance;
static #$fps: HTMLElement; }
static #$ping: HTMLElement;
static #$dt: HTMLElement;
static #$pl: HTMLElement;
static #$fl: HTMLElement;
static #$br: HTMLElement;
static #lastStat?: RTCBasicStat | null; #timeoutId?: number | null;
readonly #updateInterval = 1000;
static #quickGlanceObserver?: MutationObserver | null; #$container: HTMLElement | undefined;
#$fps: HTMLElement | undefined;
#$ping: HTMLElement | undefined;
#$dt: HTMLElement | undefined;
#$pl: HTMLElement | undefined;
#$fl: HTMLElement | undefined;
#$br: HTMLElement | undefined;
static start(glancing=false) { #lastVideoStat?: RTCBasicStat | null;
if (!StreamStats.isHidden() || (glancing && StreamStats.isGlancing())) {
#quickGlanceObserver?: MutationObserver | null;
constructor() {
this.#render();
}
start(glancing=false) {
if (!this.isHidden() || (glancing && this.isGlancing())) {
return; return;
} }
StreamStats.#$container.classList.remove('bx-gone'); if (this.#$container) {
StreamStats.#$container.setAttribute('data-display', glancing ? 'glancing' : 'fixed'); this.#$container.classList.remove('bx-gone');
this.#$container.dataset.display = glancing ? 'glancing' : 'fixed';
}
StreamStats.#interval = window.setInterval(StreamStats.update, StreamStats.#updateInterval); this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval);
} }
static stop(glancing=false) { stop(glancing=false) {
if (glancing && !StreamStats.isGlancing()) { if (glancing && !this.isGlancing()) {
return; return;
} }
StreamStats.#interval && clearInterval(StreamStats.#interval); this.#timeoutId && clearTimeout(this.#timeoutId);
StreamStats.#interval = null; this.#timeoutId = null;
StreamStats.#lastStat = null; this.#lastVideoStat = null;
if (StreamStats.#$container) { if (this.#$container) {
StreamStats.#$container.removeAttribute('data-display'); this.#$container.removeAttribute('data-display');
StreamStats.#$container.classList.add('bx-gone'); this.#$container.classList.add('bx-gone');
} }
} }
static toggle() { toggle() {
if (StreamStats.isGlancing()) { if (this.isGlancing()) {
StreamStats.#$container.setAttribute('data-display', 'fixed'); this.#$container && (this.#$container.dataset.display = 'fixed');
} else { } else {
StreamStats.isHidden() ? StreamStats.start() : StreamStats.stop(); this.isHidden() ? this.start() : this.stop();
} }
} }
static onStoppedPlaying() { onStoppedPlaying() {
StreamStats.stop(); this.stop();
StreamStats.quickGlanceStop(); this.quickGlanceStop();
StreamStats.hideSettingsUi(); this.hideSettingsUi();
} }
static isHidden = () => StreamStats.#$container && StreamStats.#$container.classList.contains('bx-gone'); isHidden = () => this.#$container && this.#$container.classList.contains('bx-gone');
static isGlancing = () => StreamStats.#$container && StreamStats.#$container.getAttribute('data-display') === 'glancing'; isGlancing = () => this.#$container && this.#$container.dataset.display === 'glancing';
static quickGlanceSetup() { quickGlanceSetup() {
if (StreamStats.#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]')!;
StreamStats.#quickGlanceObserver = new MutationObserver((mutationList, observer) => { if (!$uiContainer) {
return;
}
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') {
const expanded = (record.target as HTMLElement).ariaExpanded; const expanded = (record.target as HTMLElement).ariaExpanded;
if (expanded === 'true') { if (expanded === 'true') {
StreamStats.isHidden() && StreamStats.start(true); this.isHidden() && this.start(true);
} else { } else {
StreamStats.stop(true); this.stop(true);
} }
} }
} }
}); });
StreamStats.#quickGlanceObserver.observe($uiContainer, { this.#quickGlanceObserver.observe($uiContainer, {
attributes: true, attributes: true,
attributeFilter: ['aria-expanded'], attributeFilter: ['aria-expanded'],
subtree: true, subtree: true,
}); });
} }
static quickGlanceStop() { quickGlanceStop() {
StreamStats.#quickGlanceObserver && StreamStats.#quickGlanceObserver.disconnect(); this.#quickGlanceObserver && this.#quickGlanceObserver.disconnect();
StreamStats.#quickGlanceObserver = null; this.#quickGlanceObserver = null;
} }
static update() { async #update() {
if (StreamStats.isHidden() || !STATES.currentStream.peerConnection) { if (this.isHidden() || !STATES.currentStream.peerConnection) {
StreamStats.onStoppedPlaying(); this.onStoppedPlaying();
return; return;
} }
this.#timeoutId = null;
const startTime = performance.now();
const PREF_STATS_CONDITIONAL_FORMATTING = getPref(PrefKey.STATS_CONDITIONAL_FORMATTING); const PREF_STATS_CONDITIONAL_FORMATTING = getPref(PrefKey.STATS_CONDITIONAL_FORMATTING);
STATES.currentStream.peerConnection.getStats().then(stats => {
stats.forEach(stat => {
let grade = '';
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
// FPS
StreamStats.#$fps.textContent = stat.framesPerSecond || 0;
// Packets Lost const stats = await STATES.currentStream.peerConnection.getStats();
const packetsLost = stat.packetsLost; let grade = '';
const packetsReceived = stat.packetsReceived;
const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2);
StreamStats.#$pl.textContent = packetsLostPercentage === '0.00' ? packetsLost : `${packetsLost} (${packetsLostPercentage}%)`;
// Frames Dropped stats.forEach(stat => {
const framesDropped = stat.framesDropped; if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
const framesReceived = stat.framesReceived; // FPS
const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2); this.#$fps!.textContent = stat.framesPerSecond || 0;
StreamStats.#$fl.textContent = framesDroppedPercentage === '0.00' ? framesDropped : `${framesDropped} (${framesDroppedPercentage}%)`;
if (StreamStats.#lastStat) { // Packets Lost
const lastStat = StreamStats.#lastStat; const packetsLost = Math.max(0, stat.packetsLost); // packetsLost can be negative, but we don't care about that
// Bitrate const packetsReceived = stat.packetsReceived;
const timeDiff = stat.timestamp - lastStat.timestamp; const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2);
const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000; this.#$pl!.textContent = packetsLostPercentage === '0.00' ? packetsLost.toString() : `${packetsLost} (${packetsLostPercentage}%)`;
StreamStats.#$br.textContent = `${bitrate.toFixed(2)} Mbps`;
// Decode time // Frames dropped
const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime; const framesDropped = stat.framesDropped;
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; const framesReceived = stat.framesReceived;
const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000; const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2);
StreamStats.#$dt.textContent = `${currentDecodeTime.toFixed(2)}ms`; this.#$fl!.textContent = framesDroppedPercentage === '0.00' ? framesDropped : `${framesDropped} (${framesDroppedPercentage}%)`;
if (PREF_STATS_CONDITIONAL_FORMATTING) { if (!this.#lastVideoStat) {
grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : ''; this.#lastVideoStat = stat;
} return;
StreamStats.#$dt.setAttribute('data-grade', grade);
}
StreamStats.#lastStat = stat;
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
// Round Trip Time
const roundTripTime = typeof stat.currentRoundTripTime !== 'undefined' ? stat.currentRoundTripTime * 1000 : -1;
StreamStats.#$ping.textContent = roundTripTime === -1 ? '???' : roundTripTime.toString();
if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : '';
}
StreamStats.#$ping.setAttribute('data-grade', grade);
} }
});
const lastStat = this.#lastVideoStat;
// Bitrate
const timeDiff = stat.timestamp - lastStat.timestamp;
const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000;
this.#$br!.textContent = `${bitrate.toFixed(2)} Mbps`;
// Decode time
const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime;
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded;
const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000;
if (isNaN(currentDecodeTime)) {
this.#$dt!.textContent = '??ms';
} else {
this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`;
}
if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : '';
this.#$dt!.dataset.grade = grade;
}
this.#lastVideoStat = stat;
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
// Round Trip Time
const roundTripTime = !!stat.currentRoundTripTime ? stat.currentRoundTripTime * 1000 : -1;
this.#$ping!.textContent = roundTripTime === -1 ? '???' : roundTripTime.toString();
if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : '';
this.#$ping!.dataset.grade = grade;
}
}
}); });
const lapsedTime = performance.now() - startTime;
this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval - lapsedTime);
} }
static refreshStyles() { refreshStyles() {
const PREF_ITEMS = getPref(PrefKey.STATS_ITEMS); const PREF_ITEMS = getPref(PrefKey.STATS_ITEMS);
const PREF_POSITION = getPref(PrefKey.STATS_POSITION); const PREF_POSITION = getPref(PrefKey.STATS_POSITION);
const PREF_TRANSPARENT = getPref(PrefKey.STATS_TRANSPARENT); const PREF_TRANSPARENT = getPref(PrefKey.STATS_TRANSPARENT);
const PREF_OPACITY = getPref(PrefKey.STATS_OPACITY); const PREF_OPACITY = getPref(PrefKey.STATS_OPACITY);
const PREF_TEXT_SIZE = getPref(PrefKey.STATS_TEXT_SIZE); const PREF_TEXT_SIZE = getPref(PrefKey.STATS_TEXT_SIZE);
const $container = StreamStats.#$container; const $container = this.#$container!;
$container.setAttribute('data-stats', '[' + PREF_ITEMS.join('][') + ']'); $container.dataset.stats = '[' + PREF_ITEMS.join('][') + ']';
$container.setAttribute('data-position', PREF_POSITION); $container.dataset.position = PREF_POSITION;
$container.setAttribute('data-transparent', PREF_TRANSPARENT); $container.dataset.transparent = PREF_TRANSPARENT;
$container.style.opacity = PREF_OPACITY + '%'; $container.style.opacity = PREF_OPACITY + '%';
$container.style.fontSize = PREF_TEXT_SIZE; $container.style.fontSize = PREF_TEXT_SIZE;
} }
static hideSettingsUi() { hideSettingsUi() {
if (StreamStats.isGlancing() && !getPref(PrefKey.STATS_QUICK_GLANCE)) { if (this.isGlancing() && !getPref(PrefKey.STATS_QUICK_GLANCE)) {
StreamStats.stop(); this.stop();
} }
} }
static render() { #render() {
if (StreamStats.#$container) { const stats = {
return; [StreamStat.PING]: [t('stat-ping'), this.#$ping = CE('span', {}, '0')],
} [StreamStat.FPS]: [t('stat-fps'), this.#$fps = CE('span', {}, '0')],
[StreamStat.BITRATE]: [t('stat-bitrate'), this.#$br = CE('span', {}, '0 Mbps')],
const STATS = { [StreamStat.DECODE_TIME]: [t('stat-decode-time'), this.#$dt = CE('span', {}, '0ms')],
[StreamStat.PING]: [t('stat-ping'), StreamStats.#$ping = CE('span', {}, '0')], [StreamStat.PACKETS_LOST]: [t('stat-packets-lost'), this.#$pl = CE('span', {}, '0')],
[StreamStat.FPS]: [t('stat-fps'), StreamStats.#$fps = CE('span', {}, '0')], [StreamStat.FRAMES_LOST]: [t('stat-frames-lost'), this.#$fl = CE('span', {}, '0')],
[StreamStat.BITRATE]: [t('stat-bitrate'), StreamStats.#$br = CE('span', {}, '0 Mbps')],
[StreamStat.DECODE_TIME]: [t('stat-decode-time'), StreamStats.#$dt = CE('span', {}, '0ms')],
[StreamStat.PACKETS_LOST]: [t('stat-packets-lost'), StreamStats.#$pl = CE('span', {}, '0')],
[StreamStat.FRAMES_LOST]: [t('stat-frames-lost'), StreamStats.#$fl = CE('span', {}, '0')],
}; };
const $barFragment = document.createDocumentFragment(); const $barFragment = document.createDocumentFragment();
let statKey: keyof typeof STATS let statKey: keyof typeof stats;
for (statKey in STATS) { for (statKey in stats) {
const $div = CE('div', {'class': `bx-stat-${statKey}`, title: STATS[statKey][0]}, CE('label', {}, statKey.toUpperCase()), STATS[statKey][1]); const $div = CE('div', {
'class': `bx-stat-${statKey}`,
title: stats[statKey][0]
},
CE('label', {}, statKey.toUpperCase()),
stats[statKey][1],
);
$barFragment.appendChild($div); $barFragment.appendChild($div);
} }
StreamStats.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment); this.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment);
document.documentElement.appendChild(StreamStats.#$container); this.refreshStyles();
StreamStats.refreshStyles(); document.documentElement.appendChild(this.#$container!);
}
static getServerStats() {
STATES.currentStream.peerConnection && STATES.currentStream.peerConnection.getStats().then(stats => {
const allVideoCodecs: {[index: string]: RTCBasicStat} = {};
let videoCodecId;
const allAudioCodecs: {[index: string]: RTCBasicStat} = {};
let audioCodecId;
const allCandidates: {[index: string]: string} = {};
let candidateId;
stats.forEach((stat: RTCBasicStat) => {
if (stat.type === 'codec') {
const mimeType = stat.mimeType.split('/');
if (mimeType[0] === 'video') {
// Store all video stats
allVideoCodecs[stat.id] = stat;
} else if (mimeType[0] === 'audio') {
// Store all audio stats
allAudioCodecs[stat.id] = stat;
}
} else if (stat.type === 'inbound-rtp' && stat.packetsReceived > 0) {
// Get the codecId of the video/audio track currently being used
if (stat.kind === 'video') {
videoCodecId = stat.codecId;
} else if (stat.kind === 'audio') {
audioCodecId = stat.codecId;
}
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
candidateId = stat.remoteCandidateId;
} else if (stat.type === 'remote-candidate') {
allCandidates[stat.id] = stat.address;
}
});
// Get video codec from codecId
if (videoCodecId) {
const videoStat = allVideoCodecs[videoCodecId];
const video: typeof StreamBadges.video = {
codec: videoStat.mimeType.substring(6),
};
if (video.codec === 'H264') {
const match = /profile-level-id=([0-9a-f]{6})/.exec(videoStat.sdpFmtpLine);
video.profile = match ? match[1] : null;
}
StreamBadges.video = video;
}
// Get audio codec from codecId
if (audioCodecId) {
const audioStat = allAudioCodecs[audioCodecId];
StreamBadges.audio = {
codec: audioStat.mimeType.substring(6),
bitrate: audioStat.clockRate,
}
}
// Get server type
if (candidateId) {
BxLogger.info('candidate', candidateId, allCandidates);
StreamBadges.ipv6 = allCandidates[candidateId].includes(':');
}
if (getPref(PrefKey.STATS_SHOW_WHEN_PLAYING)) {
StreamStats.start();
}
});
} }
static setupEvents() { static setupEvents() {
@ -290,13 +253,20 @@ export class StreamStats {
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);
StreamStats.getServerStats(); const streamStats = StreamStats.getInstance();
// Setup Stat's Quick Glance mode // Setup Stat's Quick Glance mode
if (PREF_STATS_QUICK_GLANCE) {
StreamStats.quickGlanceSetup(); if (PREF_STATS_SHOW_WHEN_PLAYING) {
streamStats.start();
} else if (PREF_STATS_QUICK_GLANCE) {
streamStats.quickGlanceSetup();
// Show stats bar // Show stats bar
!PREF_STATS_SHOW_WHEN_PLAYING && StreamStats.start(true); !PREF_STATS_SHOW_WHEN_PLAYING && streamStats.start(true);
} }
}); });
} }
static refreshStyles() {
StreamStats.getInstance().refreshStyles();
}
} }

View File

@ -2,10 +2,10 @@ import { STATES } from "@utils/global.ts";
import { createSvgIcon } from "@utils/html.ts"; import { createSvgIcon } from "@utils/html.ts";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { BxEvent } from "@utils/bx-event.ts"; import { BxEvent } from "@utils/bx-event.ts";
import { PrefKey, getPref } from "@utils/preferences.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) {
@ -13,7 +13,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t
let timeout: number | null; let timeout: number | null;
const onTransitionStart = (e: TransitionEvent) => { const onTransitionStart = (e: TransitionEvent) => {
if ( e.propertyName !== 'opacity') { if (e.propertyName !== 'opacity') {
return; return;
} }
@ -22,7 +22,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t
}; };
const onTransitionEnd = (e: TransitionEvent) => { const onTransitionEnd = (e: TransitionEvent) => {
if ( e.propertyName !== 'opacity') { if (e.propertyName !== 'opacity') {
return; return;
} }
@ -35,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);
} }
@ -54,6 +54,28 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t
} }
function cloneCloseButton($$btnOrg: HTMLElement, icon: typeof BxIcon, className: string, onChange: any) {
// Create button from the Close button
const $btn = $$btnOrg.cloneNode(true) as HTMLElement;
// Refresh SVG
const $svg = createSvgIcon(icon);
// Copy classes
$svg.setAttribute('class', $btn.firstElementChild!.getAttribute('class') || '');
$svg.style.fill = 'none';
$btn.classList.add(className);
// Remove icon
$btn.removeChild($btn.firstElementChild!);
// Add icon
$btn.appendChild($svg);
// Add "click" event listener
$btn.addEventListener('click', onChange);
return $btn;
}
export function injectStreamMenuButtons() { export function injectStreamMenuButtons() {
const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); const $screen = document.querySelector('#PageContent section[class*=PureScreens]');
if (!$screen) { if (!$screen) {
@ -66,47 +88,16 @@ 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 PREF_DISABLE_FEEDBACK_DIALOG = getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG);
const observer = new MutationObserver(mutationList => { const observer = new MutationObserver(mutationList => {
mutationList.forEach(item => { mutationList.forEach(item => {
if (item.type !== 'childList') { if (item.type !== 'childList') {
return; return;
} }
item.removedNodes.forEach($node => {
if (!$node || $node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (!($node as HTMLElement).className || !($node as HTMLElement).className.startsWith) {
return;
}
});
item.addedNodes.forEach(async $node => { item.addedNodes.forEach(async $node => {
if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { if (!$node || $node.nodeType !== Node.ELEMENT_NODE) {
return; return;
@ -125,50 +116,35 @@ export function injectStreamMenuButtons() {
return; return;
} }
if (PREF_DISABLE_FEEDBACK_DIALOG && $elm.className.startsWith('PostStreamFeedbackScreen')) {
const $btnClose = $elm.querySelector('button');
$btnClose && $btnClose.click();
return;
}
// Render badges // Render badges
if ($elm.className?.startsWith('StreamMenu-module__container')) { if ($elm.className?.startsWith('StreamMenu-module__container')) {
const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]'); const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]') as HTMLElement;
if (!$btnCloseHud) { if (!$btnCloseHud) {
return; return;
} }
// Hide Stream Settings dialog when closing HUD // Hide Stream Settings dialog when closing HUD
$btnCloseHud && $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
const $btnRefresh = $btnCloseHud.cloneNode(true) as HTMLElement; const $btnRefresh = cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => {
// Refresh SVG
const $svgRefresh = createSvgIcon(BxIcon.REFRESH);
// Copy classes
$svgRefresh.setAttribute('class', $btnRefresh.firstElementChild!.getAttribute('class') || '');
$svgRefresh.style.fill = 'none';
$btnRefresh.classList.add('bx-stream-refresh-button');
// Remove icon
$btnRefresh.removeChild($btnRefresh.firstElementChild!);
// Add Refresh icon
$btnRefresh.appendChild($svgRefresh);
// Add "click" event listener
$btnRefresh.addEventListener('click', e => {
confirm(t('confirm-reload-stream')) && window.location.reload(); confirm(t('confirm-reload-stream')) && window.location.reload();
}); });
const $btnHome = cloneCloseButton($btnCloseHud, BxIcon.HOME, 'bx-stream-home-button', () => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
});
// Add to website // Add to website
$btnCloseHud.insertAdjacentElement('afterend', $btnRefresh); $btnCloseHud.insertAdjacentElement('afterend', $btnRefresh);
$btnRefresh.insertAdjacentElement('afterend', $btnHome);
// Render stream badges // Render stream badges
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.render()); $menu?.appendChild(await StreamBadges.getInstance().render());
hideSettingsFunc();
return; return;
} }
@ -208,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);
}); });
} }
@ -226,14 +196,14 @@ export function injectStreamMenuButtons() {
e.preventDefault(); e.preventDefault();
// Toggle Stream Stats // Toggle Stream Stats
StreamStats.toggle(); streamStats.toggle();
const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing());
$btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn);
}); });
} }
const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing());
$btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn);
if ($orgButton) { if ($orgButton) {
@ -252,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,16 +1,21 @@
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': {
items: [ items: [
PrefKey.BETTER_XCLOUD_LOCALE, PrefKey.BETTER_XCLOUD_LOCALE,
PrefKey.SERVER_BYPASS_RESTRICTION,
PrefKey.REMOTE_PLAY_ENABLED, PrefKey.REMOTE_PLAY_ENABLED,
], ],
}, },
@ -31,11 +36,11 @@ const SETTINGS_UI = {
PrefKey.BITRATE_VIDEO_MAX, PrefKey.BITRATE_VIDEO_MAX,
PrefKey.AUDIO_ENABLE_VOLUME_CONTROL, PrefKey.AUDIO_ENABLE_VOLUME_CONTROL,
PrefKey.AUDIO_MIC_ON_PLAYING,
PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG, PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG,
PrefKey.SCREENSHOT_APPLY_FILTERS, PrefKey.SCREENSHOT_APPLY_FILTERS,
PrefKey.AUDIO_MIC_ON_PLAYING,
PrefKey.GAME_FORTNITE_FORCE_CONSOLE, PrefKey.GAME_FORTNITE_FORCE_CONSOLE,
PrefKey.STREAM_COMBINE_SOURCES, PrefKey.STREAM_COMBINE_SOURCES,
], ],
@ -55,15 +60,15 @@ const SETTINGS_UI = {
[t('mouse-and-keyboard')]: { [t('mouse-and-keyboard')]: {
items: [ items: [
PrefKey.NATIVE_MKB_DISABLED, PrefKey.NATIVE_MKB_ENABLED,
PrefKey.MKB_ENABLED, PrefKey.MKB_ENABLED,
PrefKey.MKB_HIDE_IDLE_CURSOR, PrefKey.MKB_HIDE_IDLE_CURSOR,
], ],
}, },
[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 +90,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 +127,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 +147,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 = [];
// Show new version indicator // "New version available" button
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) { if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) {
$updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`; // Show new version indicator
$updateAvailable.classList.remove('bx-gone'); topButtons.push(createButton({
label: `🌟 Version ${PREF_LATEST_VERSION} available`,
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,8 +217,11 @@ export function setupSettingsUi() {
Translations.refreshCurrentLocale(); Translations.refreshCurrentLocale();
await Translations.updateTranslations(); await Translations.updateTranslations();
$btnReload.textContent = t('settings-reloading'); // Don't refresh the page on TV
$btnReload.click(); if (BX_FLAGS.ScriptUi !== 'tv') {
$btnReload.textContent = t('settings-reloading');
$btnReload.click();
}
} }
}; };
@ -237,9 +261,9 @@ export function setupSettingsUi() {
if (setting.experimental) { if (setting.experimental) {
settingLabel = '🧪 ' + settingLabel; settingLabel = '🧪 ' + settingLabel;
if (!settingNote) { if (!settingNote) {
settingNote = t('experimental') settingNote = t('experimental');
} else { } else {
settingNote = `${t('experimental')}: ${settingNote}` settingNote = `${t('experimental')}: ${settingNote}`;
} }
} }
@ -257,7 +281,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 +312,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);
}); });
@ -320,6 +344,8 @@ export function setupSettingsUi() {
$control.appendChild($option); $control.appendChild($option);
} }
$control.disabled = Object.keys(STATES.serverRegions).length === 0;
// Select preferred region // Select preferred region
$control.value = selectedValue; $control.value = selectedValue;
} else { } else {
@ -353,10 +379,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 +401,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;
} }
} }
@ -375,7 +411,7 @@ export function setupSettingsUi() {
$btnReload = createButton({ $btnReload = createButton({
label: t('settings-reload'), label: t('settings-reload'),
classes: ['bx-settings-reload-button'], classes: ['bx-settings-reload-button'],
style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH, style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH | ButtonStyle.TALL,
onClick: e => { onClick: e => {
window.location.reload(); window.location.reload();
$btnReload.disabled = true; $btnReload.disabled = true;
@ -399,7 +435,7 @@ export function setupSettingsUi() {
try { try {
const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content; 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); 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})`)); $wrapper.appendChild(CE('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`));
} catch (e) {} } catch (e) {}
$container.appendChild($wrapper); $container.appendChild($wrapper);

View File

@ -0,0 +1,140 @@
import { BxEvent } from "@/utils/bx-event";
import { AppInterface, STATES } from "@/utils/global";
import { createButton, ButtonStyle, CE } from "@/utils/html";
import { t } from "@/utils/translation";
import { StreamSettings } from "../stream/stream-settings";
export enum GuideMenuTab {
HOME,
}
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) {
// Find the last divider
const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]');
if (!$dividers) {
return;
}
const buttons: HTMLElement[] = [];
// "Stream settings" button
buttons.push(GuideMenu.#BUTTONS.streamSetting);
// "App settings" & "Close app" buttons
if (AppInterface) {
buttons.push(GuideMenu.#BUTTONS.appSettings);
buttons.push(GuideMenu.#BUTTONS.closeApp);
}
const $buttons = GuideMenu.#renderButtons(buttons);
const $lastDivider = $dividers[$dividers.length - 1];
$lastDivider.insertAdjacentElement('afterend', $buttons);
}
static #injectHomePlaying($root: HTMLElement) {
const $btnQuit = $root.querySelector('a[class*=QuitGameButton]');
if (!$btnQuit) {
return;
}
const buttons: HTMLElement[] = [];
buttons.push(GuideMenu.#BUTTONS.streamSetting);
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
// Reload stream
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
const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement;
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
}
static async #onShown(e: Event) {
const where = (e as any).where as GuideMenuTab;
if (where === GuideMenuTab.HOME) {
const $root = document.querySelector('#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]') as HTMLElement;
if ($root) {
if (STATES.isPlaying) {
GuideMenu.#injectHomePlaying($root);
} else {
GuideMenu.#injectHome($root);
}
}
}
}
static observe() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown);
}
}

View File

@ -1,5 +1,5 @@
import { SCRIPT_VERSION } from "@utils/global"; import { SCRIPT_VERSION } from "@utils/global";
import { createButton, ButtonStyle } from "@utils/html"; import { createButton, ButtonStyle, CE } 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 { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
@ -7,35 +7,20 @@ import { RemotePlay } from "@modules/remote-play";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { setupSettingsUi } from "./global-settings"; import { setupSettingsUi } from "./global-settings";
export class HeaderSection {
static #$remotePlayBtn = createButton({
classes: ['bx-header-remote-play-button', 'bx-gone'],
icon: BxIcon.REMOTE_PLAY,
title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
onClick: e => {
RemotePlay.togglePopup();
},
});
function injectSettingsButton($parent?: HTMLElement) { static #$settingsBtn = createButton({
if (!$parent) {
return;
}
const PREF_PREFERRED_REGION = getPreferredServerRegion(true);
const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION);
const $headerFragment = document.createDocumentFragment();
// Remote Play button
if (getPref(PrefKey.REMOTE_PLAY_ENABLED)) {
const $remotePlayBtn = createButton({
classes: ['bx-header-remote-play-button'],
icon: BxIcon.REMOTE_PLAY,
title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
onClick: e => {
RemotePlay.togglePopup();
},
});
$headerFragment.appendChild($remotePlayBtn);
}
// Setup Settings button
const $settingsBtn = createButton({
classes: ['bx-header-settings-button'], classes: ['bx-header-settings-button'],
label: PREF_PREFERRED_REGION, label: '???',
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT, style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
onClick: e => { onClick: e => {
setupSettingsUi(); setupSettingsUi();
@ -47,39 +32,61 @@ function injectSettingsButton($parent?: HTMLElement) {
}, },
}); });
// Show new update status static #$buttonsWrapper = CE('div', {},
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) { getPref(PrefKey.REMOTE_PLAY_ENABLED) ? HeaderSection.#$remotePlayBtn : null,
$settingsBtn.setAttribute('data-update-available', 'true'); HeaderSection.#$settingsBtn,
);
static #observer: MutationObserver;
static #injectSettingsButton($parent?: HTMLElement) {
if (!$parent) {
return;
}
const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION);
// Setup Settings button
const $settingsBtn = HeaderSection.#$settingsBtn;
$settingsBtn.querySelector('span')!.textContent = getPreferredServerRegion(true);
// Show new update status
if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) {
$settingsBtn.setAttribute('data-update-available', 'true');
}
// Add the Settings button to the web page
$parent.appendChild(HeaderSection.#$buttonsWrapper);
} }
// Add the Settings button to the web page static checkHeader() {
$headerFragment.appendChild($settingsBtn); const $button = document.querySelector('.bx-header-settings-button');
$parent.appendChild($headerFragment);
}
if (!$button) {
export function checkHeader() { const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
const $button = document.querySelector('.bx-header-settings-button'); HeaderSection.#injectSettingsButton($rightHeader as HTMLElement);
}
if (!$button) {
const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
injectSettingsButton($rightHeader as HTMLElement);
}
}
export function watchHeader() {
const $header = document.querySelector('#PageContent header');
if (!$header) {
return;
} }
let timeout: number | null; static showRemotePlayButton() {
const observer = new MutationObserver(mutationList => { HeaderSection.#$remotePlayBtn.classList.remove('bx-gone');
timeout && clearTimeout(timeout); }
timeout = window.setTimeout(checkHeader, 2000);
});
observer.observe($header, {subtree: true, childList: true});
checkHeader(); static watchHeader() {
const $header = document.querySelector('#PageContent header');
if (!$header) {
return;
}
let timeout: number | null;
HeaderSection.#observer && HeaderSection.#observer.disconnect();
HeaderSection.#observer = new MutationObserver(mutationList => {
timeout && clearTimeout(timeout);
timeout = window.setTimeout(HeaderSection.checkHeader, 2000);
});
HeaderSection.#observer.observe($header, {subtree: true, childList: true});
HeaderSection.checkHeader();
}
} }

View File

@ -1,17 +1,6 @@
import { 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";
export function localRedirect(path: string) { export function localRedirect(path: string) {
@ -37,493 +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 = [
getPref(PrefKey.MKB_ENABLED) && {
icon: BxIcon.MOUSE,
group: 'mkb',
items: [
{
group: 'mkb',
label: t('mouse-and-keyboard'),
help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/',
content: MkbRemapper.INSTANCE.render(),
},
],
},
{
icon: 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'));
});
},
},
],
}
],
},
{
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) => {
(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.floor(width);
height = Math.floor(height);
// Update size
$video.style.width = `${width}px`;
$video.style.height = `${height}px`;
$video.style.objectFit = '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();
StreamStats.render();
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;

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

@ -24,12 +24,25 @@ interface NavigatorBattery extends Navigator {
} }
type BxStates = { type BxStates = {
supportedRegion: boolean;
isPlaying: boolean; isPlaying: boolean;
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 +50,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 +64,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};
@ -59,6 +74,8 @@ type XcloudTitleInfo = {
details: { details: {
productId: string; productId: string;
supportedInputTypes: InputType[]; supportedInputTypes: InputType[];
supportedTabs: any[];
hasNativeTouchSupport: boolean;
hasTouchSupport: boolean; hasTouchSupport: boolean;
hasFakeTouchSupport: boolean; hasFakeTouchSupport: boolean;
hasMkbSupport: boolean; hasMkbSupport: boolean;
@ -75,22 +92,21 @@ 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;
} }
type MkbMouseClick = { type MkbMouseClick = {
key: { pointerButton?: number,
code: string; mouseButton?: number,
name: string; pressed: boolean,
} | null;
pressed: boolean;
} }
type MkbMouseWheel = { type MkbMouseWheel = {
key: { vertical: number;
code: string; horizontal: number;
name: string;
} | null;
} }

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

@ -26,6 +26,7 @@ export enum BxEvent {
REMOTE_PLAY_FAILED = 'bx-remote-play-failed', REMOTE_PLAY_FAILED = 'bx-remote-play-failed',
XCLOUD_SERVERS_READY = 'bx-servers-ready', XCLOUD_SERVERS_READY = 'bx-servers-ready',
XCLOUD_SERVERS_UNAVAILABLE = 'bx-servers-unavailable',
DATA_CHANNEL_CREATED = 'bx-data-channel-created', DATA_CHANNEL_CREATED = 'bx-data-channel-created',
@ -35,10 +36,17 @@ export enum BxEvent {
CAPTURE_SCREENSHOT = 'bx-capture-screenshot', CAPTURE_SCREENSHOT = 'bx-capture-screenshot',
GAINNODE_VOLUME_CHANGED = 'bx-gainnode-volume-changed', GAINNODE_VOLUME_CHANGED = 'bx-gainnode-volume-changed',
POINTER_LOCK_REQUESTED = 'bx-pointer-lock-requested',
POINTER_LOCK_EXITED = 'bx-pointer-lock-exited',
NAVIGATION_FOCUS_CHANGED = 'bx-nav-focus-changed',
// xCloud Dialog events // xCloud Dialog events
XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown', XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown',
XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed', XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed',
XCLOUD_GUIDE_MENU_SHOWN = 'bx-xcloud-guide-menu-shown',
XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed', XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed',
} }
@ -61,8 +69,8 @@ export namespace BxEvent {
} }
} }
AppInterface && AppInterface.onEvent(eventName);
target.dispatchEvent(event); target.dispatchEvent(event);
AppInterface && AppInterface.onEvent(eventName);
} }
} }

View File

@ -1,10 +1,9 @@
import { ControllerShortcut } from "@/modules/controller-shortcut"; import { ControllerShortcut } from "@/modules/controller-shortcut";
import { GameBar } from "@modules/game-bar/game-bar";
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 { UserAgent } from "@utils/user-agent";
import { BxLogger } from "./bx-logger"; import { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags";
export enum InputType { export enum InputType {
CONTROLLER = 'Controller', CONTROLLER = 'Controller',
@ -20,18 +19,22 @@ 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)) {
supportedInputTypes.push(InputType.MKB);
}
// Remove native MKB support on mobile browsers or by user's choice // Remove native MKB support on mobile browsers or by user's choice
if (getPref(PrefKey.NATIVE_MKB_DISABLED) || UserAgent.isMobile()) { if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.MKB); supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.MKB);
} }
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
@ -49,15 +52,16 @@ export const BxExposed = {
gamepadFound && (touchControllerAvailability = 'off'); gamepadFound && (touchControllerAvailability = 'off');
} }
if (touchControllerAvailability === 'off') { if (touchControllerAvailability === 'off') {
// Disable touch on all games (not native touch) // Disable touch on all games (not native touch)
supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.CUSTOM_TOUCH_OVERLAY && i !== InputType.GENERIC_TOUCH); supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.CUSTOM_TOUCH_OVERLAY && i !== InputType.GENERIC_TOUCH);
// Empty TABs
titleInfo.details.supportedTabs = [];
} }
// 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);
@ -105,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

@ -1,12 +1,19 @@
type BxFlags = { type BxFlags = Partial<{
CheckForUpdate?: boolean; CheckForUpdate: boolean;
PreloadRemotePlay?: boolean; PreloadRemotePlay: boolean;
PreloadUi?: boolean; PreloadUi: boolean;
EnableXcloudLogging?: boolean; EnableXcloudLogging: boolean;
SafariWorkaround?: boolean; SafariWorkaround: boolean;
UseDevTouchLayout?: boolean; UseDevTouchLayout: boolean;
}
ForceNativeMkbTitles: string[];
FeatureGates: {[key: string]: boolean} | null,
ScriptUi: 'default' | 'tv',
IsSupportedTvBrowser: boolean,
}>
// Setup flags // Setup flags
const DEFAULT_FLAGS: BxFlags = { const DEFAULT_FLAGS: BxFlags = {
@ -17,9 +24,14 @@ const DEFAULT_FLAGS: BxFlags = {
SafariWorkaround: true, SafariWorkaround: true,
UseDevTouchLayout: false, UseDevTouchLayout: false,
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

@ -3,8 +3,8 @@ import iconController from "@assets/svg/controller.svg" with { type: "text" };
import iconCopy from "@assets/svg/copy.svg" with { type: "text" }; import iconCopy from "@assets/svg/copy.svg" with { type: "text" };
import iconCursorText from "@assets/svg/cursor-text.svg" with { type: "text" }; import iconCursorText from "@assets/svg/cursor-text.svg" with { type: "text" };
import iconDisplay from "@assets/svg/display.svg" with { type: "text" }; import iconDisplay from "@assets/svg/display.svg" with { type: "text" };
import iconMouseSettings from "@assets/svg/mouse-settings.svg" with { type: "text" }; import iconHome from "@assets/svg/home.svg" with { type: "text" };
import iconMouse from "@assets/svg/mouse.svg" with { type: "text" }; import iconNativeMkb from "@assets/svg/native-mkb.svg" with { type: "text" };
import iconNew from "@assets/svg/new.svg" with { type: "text" }; import iconNew from "@assets/svg/new.svg" with { type: "text" };
import iconQuestion from "@assets/svg/question.svg" with { type: "text" }; import iconQuestion from "@assets/svg/question.svg" with { type: "text" };
import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" }; import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" };
@ -14,6 +14,7 @@ import iconStreamStats from "@assets/svg/stream-stats.svg" with { type: "text" }
import iconTrash from "@assets/svg/trash.svg" with { type: "text" }; import iconTrash from "@assets/svg/trash.svg" with { type: "text" };
import iconTouchControlEnable from "@assets/svg/touch-control-enable.svg" with { type: "text" }; import iconTouchControlEnable from "@assets/svg/touch-control-enable.svg" with { type: "text" };
import iconTouchControlDisable from "@assets/svg/touch-control-disable.svg" with { type: "text" }; import iconTouchControlDisable from "@assets/svg/touch-control-disable.svg" with { type: "text" };
import iconVirtualController from "@assets/svg/virtual-controller.svg" with { type: "text" };
// Game Bar // Game Bar
import iconCaretLeft from "@assets/svg/caret-left.svg" with { type: "text" }; import iconCaretLeft from "@assets/svg/caret-left.svg" with { type: "text" };
@ -22,20 +23,30 @@ import iconCamera from "@assets/svg/camera.svg" with { type: "text" };
import iconMicrophone from "@assets/svg/microphone.svg" with { type: "text" }; import iconMicrophone from "@assets/svg/microphone.svg" with { type: "text" };
import iconMicrophoneMuted from "@assets/svg/microphone-slash.svg" with { type: "text" }; import iconMicrophoneMuted from "@assets/svg/microphone-slash.svg" with { type: "text" };
// Stream Badge
import iconBatteryFull from "@assets/svg/battery-full.svg" with { type: "text" };
import iconClock from "@assets/svg/clock.svg" with { type: "text" };
import iconCloud from "@assets/svg/cloud.svg" with { type: "text" };
import iconDownload from "@assets/svg/download.svg" with { type: "text" };
import iconSpeakerHigh from "@assets/svg/speaker-high.svg" with { type: "text" };
import iconUpload from "@assets/svg/upload.svg" with { type: "text" };
export const BxIcon = { export const BxIcon = {
STREAM_SETTINGS: iconStreamSettings, STREAM_SETTINGS: iconStreamSettings,
STREAM_STATS: iconStreamStats, STREAM_STATS: iconStreamStats,
COMMAND: iconCommand, COMMAND: iconCommand,
CONTROLLER: iconController, CONTROLLER: iconController,
DISPLAY: iconDisplay, DISPLAY: iconDisplay,
MOUSE: iconMouse, HOME: iconHome,
MOUSE_SETTINGS: iconMouseSettings, NATIVE_MKB: iconNativeMkb,
NEW: iconNew, NEW: iconNew,
COPY: iconCopy, COPY: iconCopy,
TRASH: iconTrash, TRASH: iconTrash,
CURSOR_TEXT: iconCursorText, CURSOR_TEXT: iconCursorText,
QUESTION: iconQuestion, QUESTION: iconQuestion,
REFRESH: iconRefresh, REFRESH: iconRefresh,
VIRTUAL_CONTROLLER: iconVirtualController,
REMOTE_PLAY: iconRemotePlay, REMOTE_PLAY: iconRemotePlay,
@ -48,4 +59,12 @@ export const BxIcon = {
MICROPHONE: iconMicrophone, MICROPHONE: iconMicrophone,
MICROPHONE_MUTED: iconMicrophoneMuted, MICROPHONE_MUTED: iconMicrophoneMuted,
// Stream Badge
BATTERY: iconBatteryFull,
PLAYTIME: iconClock,
SERVER: iconCloud,
DOWNLOAD: iconDownload,
UPLOAD: iconUpload,
AUDIO: iconSpeakerHigh,
} as const; } as const;

View File

@ -1,19 +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;
// Hide "Play with friends" section 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]');
div[class^=HomePage-module__bottomSpacing]:has(button[class*=SocialEmptyCard]), }
button[class*=SocialEmptyCard] {
display: none; if (selectorToHide) {
} css += selectorToHide.join(',') + '{ display: none; }';
`;
} }
// Reduce animations // Reduce animations
@ -54,12 +68,13 @@ div[class*=StreamHUD-module__buttonsContainer] {
`; `;
} }
// Simplify Stream's menu
css += ` css += `
div[class*=StreamMenu-module__menu] { div[class*=StreamMenu-module__menu] {
min-width: 100vw !important; min-width: 100vw !important;
} }
`; `;
// Simplify Stream's menu
if (getPref(PrefKey.STREAM_SIMPLIFY_MENU)) { if (getPref(PrefKey.STREAM_SIMPLIFY_MENU)) {
css += ` css += `
div[class*=Menu-module__scrollable] { div[class*=Menu-module__scrollable] {
@ -135,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,21 @@
import { BX_FLAGS } from "./bx-flags";
import { getPref, PrefKey } from "./preferences";
export let FeatureGates: {[key: string]: boolean} = {
'PwaPrompt': false,
'EnableWifiWarnings': 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,4 +1,4 @@
import { MkbHandler } from "@modules/mkb/mkb-handler"; import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
@ -7,7 +7,7 @@ import { BxLogger } from "@utils/bx-logger";
// Show a toast when connecting/disconecting controller // Show a toast when connecting/disconecting controller
export function showGamepadToast(gamepad: Gamepad) { export function showGamepadToast(gamepad: Gamepad) {
// Don't show Toast for virtual controller // Don't show Toast for virtual controller
if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) { if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
return; return;
} }

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,43 @@ 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 = {
supportedRegion: true,
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

@ -1,7 +1,7 @@
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { LoadingScreen } from "@modules/loading-screen"; import { LoadingScreen } from "@modules/loading-screen";
import { RemotePlay } from "@modules/remote-play"; import { RemotePlay } from "@modules/remote-play";
import { checkHeader } from "@modules/ui/header"; import { HeaderSection } from "@/modules/ui/header";
export function patchHistoryMethod(type: 'pushState' | 'replaceState') { export function patchHistoryMethod(type: 'pushState' | 'replaceState') {
const orig = window.history[type]; const orig = window.history[type];
@ -34,7 +34,7 @@ export function onHistoryChanged(e: PopStateEvent) {
RemotePlay.detachPopup(); RemotePlay.detachPopup();
LoadingScreen.reset(); LoadingScreen.reset();
window.setTimeout(checkHeader, 2000); window.setTimeout(HeaderSection.watchHeader, 2000);
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
} }

View File

@ -1,7 +1,7 @@
import type { BxIcon } from "@utils/bx-icon"; import type { BxIcon } from "@utils/bx-icon";
type BxButton = { type BxButton = {
style?: number | string; style?: number | string | ButtonStyle;
url?: string; url?: string;
classes?: string[]; classes?: string[];
icon?: typeof BxIcon; icon?: typeof BxIcon;
@ -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};
@ -67,6 +68,7 @@ ButtonStyle[ButtonStyle.GHOST = 4] = 'bx-ghost';
ButtonStyle[ButtonStyle.FOCUSABLE = 8] = 'bx-focusable'; ButtonStyle[ButtonStyle.FOCUSABLE = 8] = 'bx-focusable';
ButtonStyle[ButtonStyle.FULL_WIDTH = 16] = 'bx-full-width'; ButtonStyle[ButtonStyle.FULL_WIDTH = 16] = 'bx-full-width';
ButtonStyle[ButtonStyle.FULL_HEIGHT = 32] = 'bx-full-height'; ButtonStyle[ButtonStyle.FULL_HEIGHT = 32] = 'bx-full-height';
ButtonStyle[ButtonStyle.TALL = 64] = 'bx-tall';
const ButtonStyleIndices = Object.keys(ButtonStyle).splice(0, Object.keys(ButtonStyle).length / 2).map(i => parseInt(i)); const ButtonStyleIndices = Object.keys(ButtonStyle).splice(0, Object.keys(ButtonStyle).length / 2).map(i => parseInt(i));
@ -93,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 nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription; const maxVideoBitrate = getPref(PrefKey.BITRATE_VIDEO_MAX);
RTCPeerConnection.prototype.setLocalDescription = function(description?: RTCLocalSessionDescriptionInit): Promise<void> { const codec = getPref(PrefKey.STREAM_CODEC_PROFILE);
// set maximum bitrate
try {
const maxVideoBitrate = getPref(PrefKey.BITRATE_VIDEO_MAX);
if (maxVideoBitrate > 0) {
arguments[0].sdp = patchSdpBitrate(arguments[0].sdp, Math.round(maxVideoBitrate / 1000));
}
} catch (e) {
BxLogger.error('setLocalDescription', e);
}
// @ts-ignore if (codec !== 'default' || maxVideoBitrate > 0) {
return nativeSetLocalDescription.apply(this, arguments); const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription;
}; 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
try {
if (maxVideoBitrate > 0 && description) {
arguments[0].sdp = patchSdpBitrate(arguments[0].sdp, Math.round(maxVideoBitrate / 1000));
}
} catch (e) {
BxLogger.error('setLocalDescription', e);
}
BxLogger.info('setLocalDescription', arguments[0].sdp);
// @ts-ignore
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,15 +211,57 @@ export function patchCanvasContext() {
HTMLCanvasElement.prototype.getContext = function(contextType: string, contextAttributes?: any) { HTMLCanvasElement.prototype.getContext = function(contextType: string, contextAttributes?: any) {
if (contextType.includes('webgl')) { if (contextType.includes('webgl')) {
contextAttributes = contextAttributes || {}; contextAttributes = contextAttributes || {};
if (!contextAttributes.isBx) {
contextAttributes.antialias = false;
contextAttributes.antialias = false; // Use low-power profile for touch controller
if (contextAttributes.powerPreference === 'high-performance') {
// Use low-power profile for touch controller contextAttributes.powerPreference = 'low-power';
if (contextAttributes.powerPreference === 'high-performance') { }
contextAttributes.powerPreference = 'low-power';
} }
} }
return nativeGetContext.apply(this, [contextType, contextAttributes]); return nativeGetContext.apply(this, [contextType, contextAttributes]);
} }
} }
export function patchPointerLockApi() {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get() {
return document.documentElement;
},
});
HTMLElement.prototype.requestFullscreen = function(options?: FullscreenOptions): Promise<void> {
return Promise.resolve();
}
let pointerLockElement: unknown = null;
Object.defineProperty(document, 'pointerLockElement', {
configurable: true,
get() {
return pointerLockElement;
},
});
// const nativeRequestPointerLock = HTMLElement.prototype.requestPointerLock;
HTMLElement.prototype.requestPointerLock = function() {
pointerLockElement = document.documentElement;
window.dispatchEvent(new Event(BxEvent.POINTER_LOCK_REQUESTED));
// document.dispatchEvent(new Event('pointerlockchange'));
// @ts-ignore
// nativeRequestPointerLock.apply(this, arguments);
}
// const nativeExitPointerLock = Document.prototype.exitPointerLock;
Document.prototype.exitPointerLock = function() {
pointerLockElement = null;
window.dispatchEvent(new Event(BxEvent.POINTER_LOCK_EXITED));
// document.dispatchEvent(new Event('pointerlockchange'));
// nativeExitPointerLock.apply(this);
}
}

View File

@ -1,15 +1,13 @@
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS, NATIVE_FETCH } from "@utils/bx-flags"; import { BX_FLAGS, NATIVE_FETCH } from "@utils/bx-flags";
import { LoadingScreen } from "@modules/loading-screen";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { RemotePlay } from "@modules/remote-play";
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 { GamePassCloudGallery } from "../enums/game-pass-gallery";
import { GamePassCloudGallery } from "./gamepass-gallery"; import { FeatureGates } from "./feature-gates";
import { InputType } from "./bx-exposed"; import { BxLogger } from "./bx-logger";
import { UserAgent } from "./user-agent"; import { XhomeInterceptor } from "./xhome-interceptor";
import { XcloudInterceptor } from "./xcloud-interceptor";
enum RequestType { enum RequestType {
XCLOUD = 'xcloud', XCLOUD = 'xcloud',
@ -95,12 +93,12 @@ 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;
} }
async function patchIceCandidates(request: Request, consoleAddrs?: {[index: string]: number}) { export async function patchIceCandidates(request: Request, consoleAddrs?: {[index: string]: number}) {
const response = await NATIVE_FETCH(request); const response = await NATIVE_FETCH(request);
const text = await response.clone().text(); const text = await response.clone().text();
@ -124,372 +122,6 @@ async function patchIceCandidates(request: Request, consoleAddrs?: {[index: stri
return response; return response;
} }
class XhomeInterceptor {
static #consoleAddrs: {[index: string]: number} = {};
static async #handleLogin(request: Request) {
try {
const clone = (request as Request).clone();
const obj = await clone.json();
obj.offeringId = 'xhome';
request = new Request('https://xhome.gssv-play-prod.xboxlive.com/v2/login/user', {
method: 'POST',
body: JSON.stringify(obj),
headers: {
'Content-Type': 'application/json',
},
});
} catch (e) {
alert(e);
console.log(e);
}
return NATIVE_FETCH(request);
}
static async #handleConfiguration(request: Request | URL) {
const response = await NATIVE_FETCH(request);
const obj = await response.clone().json()
console.log(obj);
const serverDetails = obj.serverDetails;
if (serverDetails.ipV4Address) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipV4Address] = serverDetails.ipV4Port;
}
if (serverDetails.ipV6Address) {
XhomeInterceptor.#consoleAddrs[serverDetails.ipV6Address] = serverDetails.ipV6Port;
}
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async #handleInputConfigs(request: Request | URL, opts: {[index: string]: any}) {
const response = await NATIVE_FETCH(request);
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'all') {
return response;
}
const obj = await response.clone().json() as any;
const xboxTitleId = JSON.parse(opts.body).titleIds[0];
STATES.currentStream.xboxTitleId = xboxTitleId;
const inputConfigs = obj[0];
let hasTouchSupport = inputConfigs.supportedTabs.length > 0;
if (!hasTouchSupport) {
const supportedInputTypes = inputConfigs.supportedInputTypes;
hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) || supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY);
}
if (hasTouchSupport) {
TouchController.disable();
BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, {
data: null,
});
} else {
TouchController.enable();
TouchController.getCustomLayouts(xboxTitleId);
}
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async #handleTitles(request: Request) {
const clone = request.clone();
const headers: {[index: string]: any} = {};
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
headers.authorization = `Bearer ${RemotePlay.XCLOUD_TOKEN}`;
const index = request.url.indexOf('.xboxlive.com');
request = new Request('https://wus.core.gssv-play-prod' + request.url.substring(index), {
method: clone.method,
body: await clone.text(),
headers: headers,
});
return NATIVE_FETCH(request);
}
static async #handlePlay(request: RequestInfo | URL) {
const clone = (request as Request).clone();
const body = await clone.json();
// body.settings.useIceConnection = true;
const newRequest = new Request(request, {
body: JSON.stringify(body),
});
return NATIVE_FETCH(newRequest);
}
static async handle(request: Request) {
TouchController.disable();
const clone = request.clone();
const headers: {[index: string]: string} = {};
for (const pair of (clone.headers as any).entries()) {
headers[pair[0]] = pair[1];
}
// Add xHome token to headers
headers.authorization = `Bearer ${RemotePlay.XHOME_TOKEN}`;
// Patch resolution
const deviceInfo = RemotePlay.BASE_DEVICE_INFO;
if (getPref(PrefKey.REMOTE_PLAY_RESOLUTION) === '720p') {
deviceInfo.dev.os.name = 'android';
}
headers['x-ms-device-info'] = JSON.stringify(deviceInfo);
const opts: {[index: string]: any} = {
method: clone.method,
headers: headers,
};
if (clone.method === 'POST') {
opts.body = await clone.text();
}
let newUrl = request.url;
if (!newUrl.includes('/servers/home')) {
const index = request.url.indexOf('.xboxlive.com');
newUrl = STATES.remotePlay.server + request.url.substring(index + 13);
}
request = new Request(newUrl, opts);
let url = (typeof request === 'string') ? request : request.url;
// Get console IP
if (url.includes('/configuration')) {
return XhomeInterceptor.#handleConfiguration(request);
} else if (url.endsWith('/sessions/home/play')) {
return XhomeInterceptor.#handlePlay(request);
} else if (url.includes('inputconfigs')) {
return XhomeInterceptor.#handleInputConfigs(request, opts);
} else if (url.includes('/login/user')) {
return XhomeInterceptor.#handleLogin(request);
} else if (url.endsWith('/titles')) {
return XhomeInterceptor.#handleTitles(request);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request, XhomeInterceptor.#consoleAddrs);
}
return await NATIVE_FETCH(request);
}
}
class XcloudInterceptor {
static async #handleLogin(request: RequestInfo | URL, init?: RequestInit) {
const response = await NATIVE_FETCH(request, init);
const obj = await response.clone().json();
// Preload Remote Play
getPref(PrefKey.REMOTE_PLAY_ENABLED) && BX_FLAGS.PreloadRemotePlay && RemotePlay.preload();
// Store xCloud token
RemotePlay.XCLOUD_TOKEN = obj.gsToken;
// Get server list
const serverEmojis = {
AustraliaEast: '🇦🇺',
AustraliaSouthEast: '🇦🇺',
BrazilSouth: '🇧🇷',
EastUS: '🇺🇸',
EastUS2: '🇺🇸',
JapanEast: '🇯🇵',
KoreaCentral: '🇰🇷',
MexicoCentral: '🇲🇽',
NorthCentralUs: '🇺🇸',
SouthCentralUS: '🇺🇸',
UKSouth: '🇬🇧',
WestEurope: '🇪🇺',
WestUS: '🇺🇸',
WestUS2: '🇺🇸',
};
const serverRegex = /\/\/(\w+)\./;
for (let region of obj.offeringSettings.regions) {
const regionName = region.name as keyof typeof serverEmojis;
let shortName = region.name;
let match = serverRegex.exec(region.baseUri);
if (match) {
shortName = match[1];
if (serverEmojis[regionName]) {
shortName = serverEmojis[regionName] + ' ' + shortName;
}
}
region.shortName = shortName.toUpperCase();
STATES.serverRegions[region.name] = Object.assign({}, region);
}
BxEvent.dispatch(window, BxEvent.XCLOUD_SERVERS_READY);
const preferredRegion = getPreferredServerRegion();
if (preferredRegion in STATES.serverRegions) {
const tmp = Object.assign({}, STATES.serverRegions[preferredRegion]);
tmp.isDefault = true;
obj.offeringSettings.regions = [tmp];
}
response.json = () => Promise.resolve(obj);
return response;
}
static async #handlePlay(request: RequestInfo | URL, init?: RequestInit) {
const PREF_STREAM_TARGET_RESOLUTION = getPref(PrefKey.STREAM_TARGET_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE);
const url = (typeof request === 'string') ? request : (request as Request).url;
const parsedUrl = new URL(url);
StreamBadges.region = parsedUrl.host.split('.', 1)[0];
for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName];
if (parsedUrl.origin == region.baseUri) {
StreamBadges.region = regionName;
break;
}
}
const clone = (request as Request).clone();
const body = await clone.json();
// Force stream's resolution
if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') {
const osName = (PREF_STREAM_TARGET_RESOLUTION === '720p') ? 'android' : 'windows';
body.settings.osName = osName;
}
// Override "locale" value
if (PREF_STREAM_PREFERRED_LOCALE !== 'default') {
body.settings.locale = PREF_STREAM_PREFERRED_LOCALE;
}
const newRequest = new Request(request, {
body: JSON.stringify(body),
});
return NATIVE_FETCH(newRequest);
}
static async #handleWaitTime(request: RequestInfo | URL, init?: RequestInit) {
const response = await NATIVE_FETCH(request, init);
if (getPref(PrefKey.UI_LOADING_SCREEN_WAIT_TIME)) {
const json = await response.clone().json();
if (json.estimatedAllocationTimeInSeconds > 0) {
// Setup wait time overlay
LoadingScreen.setupWaitTime(json.estimatedTotalWaitTimeInSeconds);
}
}
return response;
}
static async #handleConfiguration(request: RequestInfo | URL, init?: RequestInit) {
if ((request as Request).method !== 'GET') {
return NATIVE_FETCH(request, init);
}
// Touch controller for all games
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
const titleInfo = STATES.currentStream.titleInfo;
if (titleInfo?.details.hasTouchSupport) {
TouchController.disable();
} else {
TouchController.enable();
}
}
// Intercept configurations
const response = await NATIVE_FETCH(request, init);
const text = await response.clone().text();
if (!text.length) {
return response;
}
const obj = JSON.parse(text);
let overrides = JSON.parse(obj.clientStreamingConfigOverrides || '{}') || {};
overrides.inputConfiguration = overrides.inputConfiguration || {};
overrides.inputConfiguration.enableVibration = true;
if (getPref(PrefKey.NATIVE_MKB_DISABLED) || UserAgent.isMobile()) {
overrides.inputConfiguration = Object.assign(overrides.inputConfiguration, {
enableMouseInput: false,
enableAbsoluteMouse: false,
enableKeyboardInput: false,
});
}
overrides.videoConfiguration = overrides.videoConfiguration || {};
overrides.videoConfiguration.setCodecPreferences = true;
// Enable touch controller
if (TouchController.isEnabled()) {
overrides.inputConfiguration.enableTouchInput = true;
overrides.inputConfiguration.maxTouchPoints = 10;
}
// Enable mic
if (getPref(PrefKey.AUDIO_MIC_ON_PLAYING)) {
overrides.audioConfiguration = overrides.audioConfiguration || {};
overrides.audioConfiguration.enableMicrophone = true;
}
obj.clientStreamingConfigOverrides = JSON.stringify(overrides);
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async handle(request: RequestInfo | URL, init?: RequestInit) {
let url = (typeof request === 'string') ? request : (request as Request).url;
// Server list
if (url.endsWith('/v2/login/user')) {
return XcloudInterceptor.#handleLogin(request, init);
} else if (url.endsWith('/sessions/cloud/play')) { // Get session
return XcloudInterceptor.#handlePlay(request, init);
} else if (url.includes('xboxlive.com') && url.includes('/waittime/')) {
return XcloudInterceptor.#handleWaitTime(request, init);
} else if (url.endsWith('/configuration')) {
return XcloudInterceptor.#handleConfiguration(request, init);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request as Request);
}
return NATIVE_FETCH(request, init);
}
}
export function interceptHttpRequests() { export function interceptHttpRequests() {
let BLOCKED_URLS: string[] = []; let BLOCKED_URLS: string[] = [];
if (getPref(PrefKey.BLOCK_TRACKING)) { if (getPref(PrefKey.BLOCK_TRACKING)) {
@ -569,14 +201,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);
@ -587,7 +215,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();
@ -613,6 +241,21 @@ export function interceptHttpRequests() {
return response; return response;
} }
if (BX_FLAGS.ForceNativeMkbTitles && url.includes('catalog.gamepass.com/sigls/') && url.includes(GamePassCloudGallery.NATIVE_MKB)) {
const response = await NATIVE_FETCH(request, init);
const obj = await response.clone().json();
try {
const newCustomList = BX_FLAGS.ForceNativeMkbTitles.map((item: string) => ({ id: item }));
obj.push(...newCustomList);
} catch (e) {
console.log(e);
}
response.json = () => Promise.resolve(obj);
return response;
}
let requestType: RequestType; let requestType: RequestType;
if (url.includes('/sessions/home') || url.includes('xhome.') || (STATES.remotePlay.isPlaying && url.endsWith('/inputconfigs'))) { if (url.includes('/sessions/home') || url.includes('xhome.') || (STATES.remotePlay.isPlaying && url.endsWith('/inputconfigs'))) {
requestType = RequestType.XHOME; requestType = RequestType.XHOME;

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, ut } from "@utils/translation";
import { SettingElement, SettingElementType } from "@utils/settings"; import { SettingElement, SettingElementType } from "@utils/settings";
import { 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',
@ -14,6 +17,8 @@ export enum PrefKey {
BETTER_XCLOUD_LOCALE = 'bx_locale', BETTER_XCLOUD_LOCALE = 'bx_locale',
SERVER_REGION = 'server_region', SERVER_REGION = 'server_region',
SERVER_BYPASS_RESTRICTION = 'server_bypass_restriction',
PREFER_IPV6_SERVER = 'prefer_ipv6_server', PREFER_IPV6_SERVER = 'prefer_ipv6_server',
STREAM_TARGET_RESOLUTION = 'stream_target_resolution', STREAM_TARGET_RESOLUTION = 'stream_target_resolution',
STREAM_PREFERRED_LOCALE = 'stream_preferred_locale', STREAM_PREFERRED_LOCALE = 'stream_preferred_locale',
@ -43,8 +48,12 @@ 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_SCROLL_HORIZONTAL_SENSITIVITY = 'native_mkb_scroll_x_sensitivity',
NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY = 'native_mkb_scroll_y_sensitivity',
NATIVE_MKB_DISABLED = 'native_mkb_disabled',
MKB_ENABLED = 'mkb_enabled', MKB_ENABLED = 'mkb_enabled',
MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor', MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor',
MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse', MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse',
@ -64,10 +73,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',
@ -112,6 +124,12 @@ export class Preferences {
label: t('region'), label: t('region'),
default: 'default', default: 'default',
}, },
[PrefKey.SERVER_BYPASS_RESTRICTION]: {
label: ut('Bypass region restriction'),
note: ut('⚠️ Use this at your own risk'),
default: false,
},
[PrefKey.STREAM_PREFERRED_LOCALE]: { [PrefKey.STREAM_PREFERRED_LOCALE]: {
label: t('preferred-game-language'), label: t('preferred-game-language'),
default: 'default', default: 'default',
@ -163,7 +181,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;
} }
@ -260,7 +278,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';
@ -270,7 +288,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,
@ -284,7 +302,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'),
@ -294,7 +312,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'),
@ -303,7 +321,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]: {
@ -337,18 +355,15 @@ export class Preferences {
} else { } else {
return (value / (1024 * 1000)).toFixed(1) + ' Mb/s'; return (value / (1024 * 1000)).toFixed(1) + ' Mb/s';
} }
return null;
}, },
}, },
migrate: function(savedPrefs: any, value: any) { migrate: function(savedPrefs: any, value: any) {
try { try {
value = parseInt(value); value = parseInt(value);
if (value < 100) { if (value !== 0 && value < 100) {
value *= 1024 * 1000; value *= 1024 * 1000;
} }
this.set(PrefKey.BITRATE_VIDEO_MAX, value, true);
this.set(PrefKey.BITRATE_VIDEO_MAX, value);
savedPrefs[PrefKey.BITRATE_VIDEO_MAX] = value; savedPrefs[PrefKey.BITRATE_VIDEO_MAX] = value;
} catch (e) {} } catch (e) {}
}, },
@ -380,6 +395,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,
}, },
@ -437,9 +457,64 @@ export class Preferences {
}, },
}, },
[PrefKey.NATIVE_MKB_DISABLED]: { [PrefKey.NATIVE_MKB_ENABLED]: {
label: t('disable-native-mkb'), label: t('native-mkb'),
default: false, default: 'default',
options: {
default: t('default'),
on: t('on'),
off: t('off'),
},
ready: (setting: PreferenceSetting) => {
if (AppInterface) {
} else if (UserAgent.isMobile()) {
setting.unsupported = true;
setting.default = 'off';
delete setting.options!['default'];
delete setting.options!['on'];
} else {
delete setting.options!['on'];
}
},
},
[PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY]: {
label: t('horizontal-scroll-sensitivity'),
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 100 * 100,
steps: 10,
params: {
exactTicks: 20 * 100,
customTextValue: (value: any) => {
if (!value) {
return t('default');
}
return (value / 100).toFixed(1) + 'x';
},
},
},
[PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY]: {
label: t('vertical-scroll-sensitivity'),
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 100 * 100,
steps: 10,
params: {
exactTicks: 20 * 100,
customTextValue: (value: any) => {
if (!value) {
return t('default');
}
return (value / 100).toFixed(1) + 'x';
},
},
}, },
[PrefKey.MKB_DEFAULT_PRESET_ID]: { [PrefKey.MKB_DEFAULT_PRESET_ID]: {
@ -489,7 +564,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]: {
@ -508,26 +597,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',
@ -701,11 +809,12 @@ export class Preferences {
for (let settingId in Preferences.SETTINGS) { for (let settingId in Preferences.SETTINGS) {
const setting = Preferences.SETTINGS[settingId]; const setting = Preferences.SETTINGS[settingId];
setting.ready && setting.ready.call(this, setting);
if (setting.migrate && settingId in savedPrefs) { if (setting.migrate && settingId in savedPrefs) {
setting.migrate.call(this, savedPrefs, savedPrefs[settingId]); setting.migrate.call(this, savedPrefs, savedPrefs[settingId]);
} }
setting.ready && setting.ready.call(this, setting);
} }
for (let settingId in Preferences.SETTINGS) { for (let settingId in Preferences.SETTINGS) {
@ -716,7 +825,7 @@ export class Preferences {
continue; continue;
} }
// Ignore deprecated settings // Ignore deprecated/migrated settings
if (setting.migrate) { if (setting.migrate) {
continue; continue;
} }
@ -783,11 +892,11 @@ export class Preferences {
return this.#prefs[key]; return this.#prefs[key];
} }
set(key: PrefKey, value: any): any { set(key: PrefKey, value: any, skipSave?: boolean): any {
value = this.#validateValue(key, value); value = this.#validateValue(key, value);
this.#prefs[key] = value; this.#prefs[key] = value;
this.#updateStorage(); !skipSave && this.#updateStorage();
return value; return value;
} }

View File

@ -1,8 +1,9 @@
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";
const LOG_TAG = 'PreloadState'; const LOG_TAG = 'PreloadState';
@ -23,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) {
@ -37,6 +38,12 @@ export function overridePreloadState() {
// Add to the official list // Add to the official list
sigls[GamePassCloudGallery.TOUCH]?.data.products.push(...customList); sigls[GamePassCloudGallery.TOUCH]?.data.products.push(...customList);
} }
if (BX_FLAGS.ForceNativeMkbTitles && GamePassCloudGallery.NATIVE_MKB in sigls) {
// Add to the official list
sigls[GamePassCloudGallery.NATIVE_MKB]?.data.products.push(...BX_FLAGS.ForceNativeMkbTitles);
}
} catch (e) { } catch (e) {
BxLogger.error(LOG_TAG, e); BxLogger.error(LOG_TAG, e);
} }
@ -52,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,10 +1,12 @@
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)',
'en-ID': 'Bahasa Indonesia', 'ca-CA': 'Català',
'de-DE': 'Deutsch', 'de-DE': 'Deutsch',
'en-ID': 'Bahasa Indonesia',
'es-ES': 'español (España)', 'es-ES': 'español (España)',
'fr-FR': 'français', 'fr-FR': 'français',
'it-IT': 'italiano', 'it-IT': 'italiano',
@ -25,10 +27,17 @@ 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-confirm": "Do you want to go back to the home page (without disconnecting)?",
"badge-audio": "Audio", "badge-audio": "Audio",
"badge-battery": "Battery", "badge-battery": "Battery",
"badge-in": "In", "badge-in": "In",
@ -42,13 +51,15 @@ const Texts = {
"bottom-right": "Bottom-right", "bottom-right": "Bottom-right",
"brightness": "Brightness", "brightness": "Brightness",
"browser-unsupported-feature": "Your browser doesn't support this feature", "browser-unsupported-feature": "Your browser doesn't support this feature",
"bypass-region-restriction": "Bypass region restriction",
"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",
"close-app": "Close app",
"combine-audio-video-streams": "Combine audio & video streams", "combine-audio-video-streams": "Combine audio & video streams",
"combine-audio-video-streams-summary": "May fix the laggy audio problem", "combine-audio-video-streams-summary": "May fix the laggy audio problem",
"conditional-formatting": "Conditional formatting text color", "conditional-formatting": "Conditional formatting text color",
@ -74,7 +85,6 @@ const Texts = {
"device-vibration-not-using-gamepad": "On when not using gamepad", "device-vibration-not-using-gamepad": "On when not using gamepad",
"disable": "Disable", "disable": "Disable",
"disable-home-context-menu": "Disable context menu in Home page", "disable-home-context-menu": "Disable context menu in Home page",
"disable-native-mkb": "Disable native Mouse & Keyboard feature",
"disable-post-stream-feedback-dialog": "Disable post-stream feedback dialog", "disable-post-stream-feedback-dialog": "Disable post-stream feedback dialog",
"disable-social-features": "Disable social features", "disable-social-features": "Disable social features",
"disable-xcloud-analytics": "Disable xCloud analytics", "disable-xcloud-analytics": "Disable xCloud analytics",
@ -101,9 +111,12 @@ 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-sensitivity": "Horizontal sensitivity", "horizontal-sensitivity": "Horizontal sensitivity",
"ignore": "Ignore",
"import": "Import", "import": "Import",
"increase": "Increase", "increase": "Increase",
"install-android": "Install Better xCloud app for Android", "install-android": "Install Better xCloud app for Android",
@ -122,8 +135,10 @@ const Texts = {
"mkb-click-to-activate": "Click to activate", "mkb-click-to-activate": "Click to activate",
"mkb-disclaimer": "Using this feature when playing online could be viewed as cheating", "mkb-disclaimer": "Using this feature when playing online could be viewed as cheating",
"mouse-and-keyboard": "Mouse & Keyboard", "mouse-and-keyboard": "Mouse & Keyboard",
"mouse-wheel": "Mouse wheel",
"muted": "Muted", "muted": "Muted",
"name": "Name", "name": "Name",
"native-mkb": "Native Mouse & Keyboard",
"new": "New", "new": "New",
"no-consoles-found": "No consoles found", "no-consoles-found": "No consoles found",
"normal": "Normal", "normal": "Normal",
@ -141,30 +156,32 @@ const Texts = {
"preset": "Preset", "preset": "Preset",
"press-esc-to-cancel": "Press Esc to cancel", "press-esc-to-cancel": "Press Esc to cancel",
"press-key-to-toggle-mkb": [ "press-key-to-toggle-mkb": [
(e: any) => `Press ${e.key} to toggle the Mouse and Keyboard feature`, (e: any) => `Press ${e.key} to toggle this feature`,
(e: any) => `${e.key}: Maus- und Tastaturunterstützung an-/ausschalten`,
(e: any) => `Tekan ${e.key} untuk mengaktifkan fitur Mouse dan Keyboard`,
(e: any) => `Pulsa ${e.key} para activar la función de ratón y teclado`,
(e: any) => `Appuyez sur ${e.key} pour activer/désactiver la fonction Souris et Clavier`,
(e: any) => `Premi ${e.key} per attivare o disattivare la funzione Mouse e Tastiera`,
(e: any) => `${e.key} キーでマウスとキーボードの機能を切り替える`,
(e: any) => `${e.key} 키를 눌러 마우스와 키보드 기능을 활성화 하십시오`,
(e: any) => `Naciśnij ${e.key}, aby przełączyć funkcję myszy i klawiatury`,
(e: any) => `Pressione ${e.key} para ativar/desativar a função de Mouse e Teclado`,
(e: any) => `Нажмите ${e.key} для переключения функции мыши и клавиатуры`,
, ,
(e: any) => `Klavye ve fare özelliğini açmak için ${e.key} tuşuna basın`, (e: any) => `${e.key}: Funktion an-/ausschalten`,
(e: any) => `Натисніть ${e.key}, щоб увімкнути або вимкнути функцію миші та клавіатури`, ,
(e: any) => `Nhấn ${e.key} để bật/tắt tính năng Chuột và Bàn phím`, (e: any) => `Pulsa ${e.key} para alternar esta función`,
(e: any) => `按下 ${e.key} 切换键鼠模式`, (e: any) => `Appuyez sur ${e.key} pour activer cette fonctionnalité`,
(e: any) => `Premi ${e.key} per attivare questa funzionalità`,
(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) => `Натисніть ${e.key} щоб перемкнути цю функцію`,
(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",
"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",
@ -176,13 +193,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-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",
@ -208,7 +232,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",
@ -229,6 +252,7 @@ const Texts = {
"touch-control-layout": "Touch control layout", "touch-control-layout": "Touch control layout",
"touch-control-layout-by": [ "touch-control-layout-by": [
(e: any) => `Touch control layout by ${e.name}`, (e: any) => `Touch control layout by ${e.name}`,
(e: any) => `Format del control tàctil per ${e.name}`,
(e: any) => `Touch-Steuerungslayout von ${e.name}`, (e: any) => `Touch-Steuerungslayout von ${e.name}`,
(e: any) => `Tata letak Sentuhan layar oleh ${e.name}`, (e: any) => `Tata letak Sentuhan layar oleh ${e.name}`,
(e: any) => `Disposición del control táctil por ${e.nombre}`, (e: any) => `Disposición del control táctil por ${e.nombre}`,
@ -252,12 +276,16 @@ 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",
"use-this-at-your-own-risk": "Use this at your own risk",
"user-agent-profile": "User-Agent profile", "user-agent-profile": "User-Agent profile",
"vertical-scroll-sensitivity": "Vertical scroll sensitivity",
"vertical-sensitivity": "Vertical sensitivity", "vertical-sensitivity": "Vertical sensitivity",
"vibration-intensity": "Vibration intensity", "vibration-intensity": "Vibration intensity",
"vibration-status": "Vibration", "vibration-status": "Vibration",
"video": "Video", "video": "Video",
"virtual-controller": "Virtual controller",
"visual-quality": "Visual quality", "visual-quality": "Visual quality",
"visual-quality-high": "High", "visual-quality-high": "High",
"visual-quality-low": "Low", "visual-quality-low": "Low",
@ -265,6 +293,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 {
@ -356,8 +385,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();
window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations)); // Prevent saving incorrect translations
Translations.#foreignTranslations = translations; let currentLocale = localStorage.getItem(Translations.#KEY_LOCALE);
if (currentLocale === locale) {
window.localStorage.setItem(Translations.#KEY_TRANSLATIONS, JSON.stringify(translations));
Translations.#foreignTranslations = translations;
}
return true; return true;
} catch (e) { } catch (e) {
debugger; debugger;
@ -377,5 +410,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,216 @@
import { LoadingScreen } from "@modules/loading-screen";
import { RemotePlay } from "@modules/remote-play";
import { StreamBadges } from "@modules/stream/stream-badges";
import { TouchController } from "@modules/touch-controller";
import { BxEvent } from "./bx-event";
import { NATIVE_FETCH, BX_FLAGS } from "./bx-flags";
import { STATES } from "./global";
import { patchIceCandidates } from "./network";
import { getPref, PrefKey } from "./preferences";
import { getPreferredServerRegion } from "./region";
export
class XcloudInterceptor {
static async #handleLogin(request: RequestInfo | URL, init?: RequestInit) {
if (getPref(PrefKey.SERVER_BYPASS_RESTRICTION)) {
(request as Request).headers.set('X-Forwarded-For', '9.9.9.9');
}
const response = await NATIVE_FETCH(request, init);
if (response.status !== 200) {
// Unsupported region
BxEvent.dispatch(window, BxEvent.XCLOUD_SERVERS_UNAVAILABLE);
return response;
}
const obj = await response.clone().json();
// Store xCloud token
RemotePlay.XCLOUD_TOKEN = obj.gsToken;
// Get server list
const serverEmojis = {
AustraliaEast: '🇦🇺',
AustraliaSouthEast: '🇦🇺',
BrazilSouth: '🇧🇷',
EastUS: '🇺🇸',
EastUS2: '🇺🇸',
JapanEast: '🇯🇵',
KoreaCentral: '🇰🇷',
MexicoCentral: '🇲🇽',
NorthCentralUs: '🇺🇸',
SouthCentralUS: '🇺🇸',
UKSouth: '🇬🇧',
WestEurope: '🇪🇺',
WestUS: '🇺🇸',
WestUS2: '🇺🇸',
};
const serverRegex = /\/\/(\w+)\./;
for (let region of obj.offeringSettings.regions) {
const regionName = region.name as keyof typeof serverEmojis;
let shortName = region.name;
let match = serverRegex.exec(region.baseUri);
if (match) {
shortName = match[1];
if (serverEmojis[regionName]) {
shortName = serverEmojis[regionName] + ' ' + shortName;
}
}
region.shortName = shortName.toUpperCase();
STATES.serverRegions[region.name] = Object.assign({}, region);
}
BxEvent.dispatch(window, BxEvent.XCLOUD_SERVERS_READY);
const preferredRegion = getPreferredServerRegion();
if (preferredRegion in STATES.serverRegions) {
const tmp = Object.assign({}, STATES.serverRegions[preferredRegion]);
tmp.isDefault = true;
obj.offeringSettings.regions = [tmp];
}
response.json = () => Promise.resolve(obj);
return response;
}
static async #handlePlay(request: RequestInfo | URL, init?: RequestInit) {
const PREF_STREAM_TARGET_RESOLUTION = getPref(PrefKey.STREAM_TARGET_RESOLUTION);
const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE);
const url = (typeof request === 'string') ? request : (request as Request).url;
const parsedUrl = new URL(url);
let badgeRegion: string = parsedUrl.host.split('.', 1)[0];
for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName];
if (parsedUrl.origin == region.baseUri) {
badgeRegion = regionName;
break;
}
}
StreamBadges.getInstance().setRegion(badgeRegion);
const clone = (request as Request).clone();
const body = await clone.json();
// Force stream's resolution
if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') {
const osName = (PREF_STREAM_TARGET_RESOLUTION === '720p') ? 'android' : 'windows';
body.settings.osName = osName;
}
// Override "locale" value
if (PREF_STREAM_PREFERRED_LOCALE !== 'default') {
body.settings.locale = PREF_STREAM_PREFERRED_LOCALE;
}
const newRequest = new Request(request, {
body: JSON.stringify(body),
});
return NATIVE_FETCH(newRequest);
}
static async #handleWaitTime(request: RequestInfo | URL, init?: RequestInit) {
const response = await NATIVE_FETCH(request, init);
if (getPref(PrefKey.UI_LOADING_SCREEN_WAIT_TIME)) {
const json = await response.clone().json();
if (json.estimatedAllocationTimeInSeconds > 0) {
// Setup wait time overlay
LoadingScreen.setupWaitTime(json.estimatedTotalWaitTimeInSeconds);
}
}
return response;
}
static async #handleConfiguration(request: RequestInfo | URL, init?: RequestInit) {
if ((request as Request).method !== 'GET') {
return NATIVE_FETCH(request, init);
}
// Touch controller for all games
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
const titleInfo = STATES.currentStream.titleInfo;
if (titleInfo?.details.hasTouchSupport) {
TouchController.disable();
} else {
TouchController.enable();
}
}
// Intercept configurations
const response = await NATIVE_FETCH(request, init);
const text = await response.clone().text();
if (!text.length) {
return response;
}
const obj = JSON.parse(text);
let overrides = JSON.parse(obj.clientStreamingConfigOverrides || '{}') || {};
overrides.inputConfiguration = overrides.inputConfiguration || {};
overrides.inputConfiguration.enableVibration = true;
let overrideMkb: boolean | null = null;
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || (STATES.currentStream.titleInfo && BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo.details.productId))) {
overrideMkb = true;
}
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
overrideMkb = false;
}
if (overrideMkb !== null) {
overrides.inputConfiguration = Object.assign(overrides.inputConfiguration, {
enableMouseInput: overrideMkb,
enableKeyboardInput: overrideMkb,
});
}
// Enable touch controller
if (TouchController.isEnabled()) {
overrides.inputConfiguration.enableTouchInput = true;
overrides.inputConfiguration.maxTouchPoints = 10;
}
// Enable mic
if (getPref(PrefKey.AUDIO_MIC_ON_PLAYING)) {
overrides.audioConfiguration = overrides.audioConfiguration || {};
overrides.audioConfiguration.enableMicrophone = true;
}
obj.clientStreamingConfigOverrides = JSON.stringify(overrides);
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
}
static async handle(request: RequestInfo | URL, init?: RequestInit) {
let url = (typeof request === 'string') ? request : (request as Request).url;
// Server list
if (url.endsWith('/v2/login/user')) {
return XcloudInterceptor.#handleLogin(request, init);
} else if (url.endsWith('/sessions/cloud/play')) { // Get session
return XcloudInterceptor.#handlePlay(request, init);
} else if (url.includes('xboxlive.com') && url.includes('/waittime/')) {
return XcloudInterceptor.#handleWaitTime(request, init);
} else if (url.endsWith('/configuration')) {
return XcloudInterceptor.#handleConfiguration(request, init);
} else if (url && url.endsWith('/ice') && url.includes('/sessions/') && (request as Request).method === 'GET') {
return patchIceCandidates(request as Request);
}
return NATIVE_FETCH(request, init);
}
}

Some files were not shown because too many files have changed in this diff Show More