Compare commits

...

231 Commits

Author SHA1 Message Date
619d70d3cb Update better-xcloud.user.js 2024-08-03 17:20:27 +07:00
fb123e00d7 Fix Settings button not showing on Header sometimes 2024-08-03 17:04:54 +07:00
39f7ee6ddb Add "detectBrowserRouterReady" patch 2024-08-02 20:47:28 +07:00
5db35cdcc9 Bug fixes 2024-08-02 07:19:27 +07:00
8c7e4650d4 Create PatcherUtils 2024-08-02 07:07:59 +07:00
a77460e242 Bump version to 5.5.2 2024-08-02 05:57:10 +07:00
d2839b2b7c Fix crashing when hiding "Play with touch" section 2024-08-02 05:56:35 +07:00
8aa5177e10 Update 02-feature-request.yml 2024-08-01 19:28:54 +07:00
ff490be713 Fix Settings dialog not showing full settings when signed in 2024-08-01 19:23:57 +07:00
eb340e7f2a Update Device Code page's CSS 2024-08-01 17:51:37 +07:00
654862fd1c Bump version to 5.5.1 2024-07-31 17:45:45 +07:00
ddb234673c Update better-xcloud.user.js 2024-07-31 17:43:46 +07:00
e822072836 Open Settings dialog on Unsupported page 2024-07-31 17:31:26 +07:00
362638ff0c Fix not setting default User-Agent correctly 2024-07-31 17:31:05 +07:00
b4a94c95c0 Fix CSS of focus border + shortcut button 2024-07-31 08:47:34 +07:00
a996c0e367 Update better-xcloud.user.js 2024-07-31 07:39:58 +07:00
09a2c86ad4 Fix macros/build.renderStylus() not loading CSS each build 2024-07-31 07:39:39 +07:00
0d3385790c Show fullscreen text when reloading page 2024-07-31 07:37:23 +07:00
a39d056eba Render Settings footer in lite mode 2024-07-31 06:54:14 +07:00
847adb1fff Compress CSS 2024-07-31 06:27:43 +07:00
b49ee400f1 Close Settings dialog when opening App settings 2024-07-31 06:08:00 +07:00
ab91323abd Rearrange visual quality & resolution options 2024-07-30 18:34:57 +07:00
74237dbd24 Minor fixes 2024-07-30 18:31:47 +07:00
825db798db Update better-xcloud.user.js 2024-07-30 18:23:49 +07:00
41fe12afc6 Try to fix Remote Play issue 2024-07-30 18:23:17 +07:00
361ce057b7 Minor fixes 2024-07-30 18:06:40 +07:00
9fad2914ac Fix Settings sometimes not being injected to header 2024-07-28 15:58:03 +07:00
eb42f4a3d3 Update better-xcloud.user.js 2024-07-28 10:51:24 +07:00
857c7ec0c3 Update build script 2024-07-28 10:51:01 +07:00
8d559a53a8 Disable AAM 2024-07-28 09:41:23 +07:00
13323cce24 Minor fix 2024-07-28 09:07:45 +07:00
03eb323fd9 Implement es-lint-plugin-compat 2024-07-28 09:00:31 +07:00
fd21fe63f7 Remove disableTrackEvent() patch 2024-07-28 08:02:36 +07:00
857b63a9f9 Remove unused flags 2024-07-28 07:25:11 +07:00
40006c5931 Bump version to 5.5.0 2024-07-27 16:45:47 +07:00
8742da0531 Fix not disabling the Reload button correctly 2024-07-27 16:33:40 +07:00
6e17c2e24b Fix not showing default touch control 2024-07-27 16:29:03 +07:00
6a81ee2806 Add Danish 2024-07-27 16:24:09 +07:00
9dfdeb8f12 Merge Global settings and Stream settings into one dialog 2024-07-27 16:09:13 +07:00
023799232e Update better-xcloud.user.js 2024-07-27 15:46:25 +07:00
a44714ed29 Update better-xcloud.user.js 2024-07-27 11:36:48 +07:00
70d5d62890 Update better-xcloud.user.js 2024-07-27 10:49:29 +07:00
5d8dd4e3a9 Update better-xcloud.user.js 2024-07-27 08:02:44 +07:00
60526d5166 Update better-xcloud.user.js 2024-07-27 06:36:28 +07:00
40794f6088 Update better-xcloud.user.js 2024-07-26 21:53:04 +07:00
4de3fd9228 Update better-xcloud.user.js 2024-07-26 21:38:09 +07:00
d75f65e2d2 Update better-xcloud.user.js 2024-07-26 20:07:41 +07:00
21b9b2f661 Update better-xcloud.user.js 2024-07-26 19:48:51 +07:00
fc6f610859 Update better-xcloud.user.js 2024-07-26 18:00:08 +07:00
231febc0ad Update better-xcloud.user.js 2024-07-26 08:05:06 +07:00
e3bd341e57 Update better-xcloud.user.js 2024-07-25 20:26:29 +07:00
a0996eee77 Update better-xcloud.user.js 2024-07-25 08:55:18 +07:00
f46722e540 Update better-xcloud.user.js 2024-07-24 20:48:45 +07:00
1ec162115f Try to fix crashing on iOS (#455) 2024-07-21 06:17:12 +07:00
5a27caad23 Bump version to 5.4.2 2024-07-20 07:18:58 +07:00
e7d7ccf165 Add VIDEO_POWER_PREFERENCE value to Debug info 2024-07-20 07:13:44 +07:00
782c0a6967 Update better-xcloud.user.js 2024-07-20 07:10:22 +07:00
5f696ff0b8 Update translations 2024-07-20 07:04:31 +07:00
c796152bdd Focus the other button when reaching the beginning/end 2024-07-20 06:54:53 +07:00
2ae8452c90 Update layout 2024-07-20 06:39:43 +07:00
bf7d6453ea Fix layout of the "Create shortcut" button 2024-07-20 06:30:26 +07:00
130a7ffbd7 Put "low-power" before "high-performance" 2024-07-20 05:56:34 +07:00
1d590103ce Fix not able to scroll pass hidden settings 2024-07-20 05:52:55 +07:00
a268e49280 Fix unexpected "false" texts 2024-07-20 05:43:32 +07:00
7db004ede3 Fix issue with <select multiple> and BxSelect element 2024-07-19 21:10:28 +07:00
6a8eecab06 Bump version to 5.4.1 2024-07-19 18:25:57 +07:00
640dd2fb5a Update better-xcloud.user.js 2024-07-19 18:12:35 +07:00
30bb8cfbeb Fix not able to loop around in some cases 2024-07-19 18:08:39 +07:00
42b57a2cf8 Set controller-friendly UI as default on Android TV 2024-07-19 18:01:41 +07:00
210fdfbabe Add "GPU configuration" setting 2024-07-19 17:41:52 +07:00
dbbdc48aab Add "Create shortcut" button to Product Details page 2024-07-19 16:48:31 +07:00
66123bc4ef Fix BxSelect element not showing label correctly (#449) 2024-07-19 06:54:00 +07:00
2ecd995e47 Minor updates 2024-07-19 06:24:17 +07:00
0e03d4dc32 Update better-xcloud.user.js 2024-07-18 20:48:21 +07:00
5b4088cc81 Show debug info 2024-07-18 20:47:58 +07:00
1f3e4b8250 Re-arrange buttons in Guide menu 2024-07-18 20:08:59 +07:00
daf3f72736 Loop around settings 2024-07-18 17:30:34 +07:00
fbebb12965 Close Stream settings dialog when not clicking on any child elements 2024-07-18 09:20:40 +07:00
43ef2b7cd0 Update better-xcloud.user.js 2024-07-18 09:06:45 +07:00
e1eca20792 Fix Stream settings dialog in portrait mode 2024-07-18 09:06:37 +07:00
c2d8f1fbf7 Disable Fire OS's "Update required" screen 2024-07-18 07:21:35 +07:00
64be526b2d Bump version to 5.4.0 2024-07-17 18:13:09 +07:00
13527b9cf6 Bug fixes 2024-07-17 18:08:41 +07:00
6999783c07 Update better-xcloud.user.js 2024-07-17 17:56:18 +07:00
0f88396db8 Allow navigating Stream settings using controller/keyboard all the time 2024-07-17 17:54:06 +07:00
e73b4dfe78 Support navigating Stream settings using left stick 2024-07-17 17:47:23 +07:00
0fb83de0ff Add "Reload page" button to the Guide menu even when not playing 2024-07-17 17:43:57 +07:00
714276e552 Hide Stream settings when navigating to another pages 2024-07-17 17:40:08 +07:00
58b83c4eb2 Add BX_EXPOSED.backButtonPressed() 2024-07-17 17:38:59 +07:00
585ec4a598 Update translations and add support for Traditional Chinese 2024-07-17 17:38:27 +07:00
816249e9a5 Minor fix 2024-07-17 08:04:19 +07:00
30421fcdba Update better-xcloud.user.js 2024-07-17 07:59:09 +07:00
7f43db03df Press LB/RB to focus setting tabs 2024-07-17 07:58:51 +07:00
742fd24b8c Fix bugs with Clarity boost select box 2024-07-17 07:48:36 +07:00
2db246e081 Update layout 2024-07-17 07:18:55 +07:00
d8e87e5c2c Reduce polling rate 2024-07-17 06:50:13 +07:00
d7dc6931d6 Only disable buttons in number-stepper when they're at min/max 2024-07-17 06:49:19 +07:00
44083f2469 Update better-xcloud.user.js 2024-07-16 21:53:04 +07:00
64568532cb Allow controlling settings using gamepad 2024-07-16 21:52:44 +07:00
2a0af5d0ab Make Controller shortcuts settings controller-friendly 2024-07-16 17:59:21 +07:00
b66cb448ec Make Stream settings dialog controller-friendly 2024-07-16 17:08:56 +07:00
be338f3e34 Update bx-select's layout 2024-07-15 21:18:51 +07:00
394dc68ece Add "Controller-friendly UI" option 2024-07-15 20:54:35 +07:00
66120d6970 Update better-xcloud.user.js 2024-07-15 17:12:22 +07:00
368a6f726a Add optionsGroup 2024-07-15 17:10:07 +07:00
7409956616 Show Settings button in header when not signed in 2024-07-15 17:04:04 +07:00
d41fd22a47 Update servers 2024-07-15 09:13:23 +07:00
55a56837c8 Bump version to 5.3.0 2024-07-14 17:53:35 +07:00
df713136d8 Update better-xcloud.user.js 2024-07-14 17:51:37 +07:00
29dfdaf72e Show allocation time instead of total wait time 2024-07-14 17:51:32 +07:00
04cf66a466 Update better-xcloud.user.js 2024-07-14 16:44:38 +07:00
1d55026c6d Add option to show wait time in game card 2024-07-14 16:44:18 +07:00
fcfecf7ff9 Update global-settings.styl 2024-07-14 09:18:06 +07:00
5e22bf097a Optimize checkHeader() 2024-07-14 09:17:12 +07:00
542079d53e Update translations 2024-07-13 20:15:36 +07:00
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
113 changed files with 13710 additions and 10553 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

View File

@ -14,41 +14,12 @@ body:
id: device_type id: device_type
attributes: attributes:
label: Device label: Device
description: "Which device are you using?" description: "Which device type is this feature for?"
options: options:
- All devices
- Phone/Tablet - Phone/Tablet
- Laptop
- Desktop - Desktop
- TV - TV
- Other
multiple: false
validations:
required: true
- type: dropdown
id: os
attributes:
label: "Operating System"
description: "Which operating system is it running?"
options:
- Windows
- macOS
- Linux
- Android
- iOS/iPadOS
- Other
multiple: false
validations:
required: true
- type: dropdown
id: browser
attributes:
label: "Browser"
description: "Which browser are you using?"
options:
- Chrome/Edge/Chromium
- Kiwi Browser
- Safari
- Other
multiple: false multiple: false
validations: validations:
required: true required: true

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.

158
build.ts
View File

@ -2,14 +2,18 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { parseArgs } from "node:util"; import { parseArgs } from "node:util";
import { sys } from "typescript"; import { sys } from "typescript";
// @ts-ignore
import txtScriptHeader from "./src/assets/header_script.txt" with { type: "text" }; import txtScriptHeader from "./src/assets/header_script.txt" with { type: "text" };
// @ts-ignore
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";
import { ESLint } from "eslint";
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 +25,123 @@ 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 // Remove enum's inlining comments
str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS'); str = str.replaceAll(/ \/\* [A-Z0-9_]+ \*\//g, '');
// Remove comments from import
str = str.replaceAll(/\/\/ src.*\n/g, '');
// Add ADDITIONAL CODE block
str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS');
assert(str.includes('/* ADDITIONAL CODE */'));
assert(str.includes('window.BX_EXPOSED = BxExposed'));
assert(str.includes('window.BxEvent = BxEvent'));
assert(str.includes('window.BX_FETCH = window.fetch'));
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`);
// Create meta file // Create meta file
await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version)); await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version));
// Check with ESLint
const eslint = new ESLint();
const results = await eslint.lintFiles([path]);
results[0].messages.forEach((msg: any) => {
console.error(`${path}#${msg.line}: ${msg.message}`);
});
console.log(`---- [${target}] done in ${performance.now() - startTime} ms`);
console.log(`---- [${target}] ${new Date()}`);
} }
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']); async function main() {
const config = {};
console.log('Building: ', values['version']);
for (const target of buildTargets) {
await build(target, values['version']!!, config);
}
const config = {}; console.log('\n** Press Enter to build or Esc to exit');
for (const target of buildTargets) {
await build(target, values['version'], config);
} }
function onKeyPress(data: any) {
const keyCode = data[0];
if (keyCode === 13) { // Enter key
main();
} else if (keyCode === 27) { // Esc key
process.exit(0);
}
}
main();
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', onKeyPress);

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.3 // @version 5.5.2
// ==/UserScript== // ==/UserScript==

File diff suppressed because one or more lines are too long

3
eslint.config.mjs Normal file
View File

@ -0,0 +1,3 @@
import compat from "eslint-plugin-compat";
export default [compat.configs['flat/recommended']];

View File

@ -2,16 +2,21 @@
"name": "better-xcloud", "name": "better-xcloud",
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"browserslist": [
"Chrome >= 80"
],
"bin": { "bin": {
"build": "build.ts" "build": "build.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.3", "@types/bun": "^1.1.6",
"@types/node": "^20.13.0", "@types/node": "^20.14.12",
"@types/stylus": "^0.48.42", "@types/stylus": "^0.48.42",
"eslint": "^9.8.0",
"eslint-plugin-compat": "^6.0.0",
"stylus": "^0.63.0" "stylus": "^0.63.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.4.5" "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

@ -1,5 +1,10 @@
.bx-button { .bx-button {
background-color: var(--bx-default-button-color); --button-rgb: var(--bx-default-button-rgb);
--button-hover-rgb: var(--bx-default-button-hover-rgb);
--button-active-rgb: var(--bx-default-button-active-rgb);
--button-disabled-rgb: var(--bx-default-button-disabled-rgb);
background-color: unquote('rgb(var(--button-rgb))');
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
color: #fff; color: #fff;
@ -14,59 +19,101 @@
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
&:not([disabled]):active {
background-color: unquote('rgb(var(--button-active-rgb))');
}
&:focus { &:focus {
outline: none !important; outline: none !important;
} }
&:hover, &.bx-focusable:focus { &:not([disabled]):not(:active) {
background-color: var(--bx-default-button-hover-color); &:hover, &.bx-focusable:focus {
background-color: unquote('rgb(var(--button-hover-rgb))');
}
} }
&:disabled { &:disabled {
cursor: default; cursor: default;
background-color: var(--bx-default-button-disabled-color); background-color: unquote('rgb(var(--button-disabled-rgb))');
} }
&.bx-ghost { &.bx-ghost {
background-color: transparent; background-color: transparent;
&:hover, &.bx-focusable:focus { &:not([disabled]):not(:active) {
background-color: var(--bx-default-button-hover-color); &:hover, &.bx-focusable:focus {
background-color: unquote('rgb(var(--button-hover-rgb))');
}
} }
} }
&.bx-primary { &.bx-primary {
background-color: var(--bx-primary-button-color); --button-rgb: var(--bx-primary-button-rgb);
&:hover, &.bx-focusable:focus { &:not([disabled]):active {
background-color: var(--bx-primary-button-hover-color); --button-active-rgb: var(--bx-primary-button-active-rgb);
}
&:not([disabled]):not(:active) {
&:hover, &.bx-focusable:focus {
--button-hover-rgb: var(--bx-primary-button-hover-rgb);
}
} }
&:disabled { &:disabled {
background-color: var(--bx-primary-button-disabled-color); --button-disabled-rgb: var(--bx-primary-button-disabled-rgb);
} }
} }
&.bx-danger { &.bx-danger {
background-color: var(--bx-danger-button-color); --button-rgb: var(--bx-danger-button-rgb);
&:hover, &.bx-focusable:focus { &:not([disabled]):active {
background-color: var(--bx-danger-button-hover-color); --button-active-rgb: var(--bx-danger-button-active-rgb);
}
&:not([disabled]):not(:active) {
&:hover, &.bx-focusable:focus {
--button-hover-rgb: var(--bx-danger-button-hover-rgb);
}
} }
&:disabled { &:disabled {
background-color: var(--bx-danger-button-disabled-color); --button-disabled-rgb: var(--bx-danger-button-disabled-rgb);
} }
} }
&.bx-frosted {
--button-alpha: 0.2;
background-color: unquote('rgba(var(--button-rgb), var(--button-alpha))');
backdrop-filter: blur(4px) brightness(1.5);
&:not([disabled]):not(:active) {
&:hover, &.bx-focusable:focus {
background-color: unquote('rgba(var(--button-hover-rgb), var(--button-alpha))');
}
}
}
&.bx-drop-shadow {
box-shadow: 0 0 4px #00000080;
}
&.bx-tall {
height: calc(var(--bx-button-height) * 1.5) !important;
}
&.bx-circular {
border-radius: var(--bx-button-height);
height: var(--bx-button-height);
}
svg { svg {
display: inline-block; display: inline-block;
width: 16px; width: 16px;
height: var(--bx-button-height); height: var(--bx-button-height);
&:not(:only-child) {
margin-right: 4px;
}
} }
span { span {
@ -78,24 +125,43 @@
color: #fff; color: #fff;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
&:not(:only-child) {
margin-left: 10px;
}
}
}
.bx-focusable {
position: relative;
overflow: visible;
&::after {
border: 2px solid transparent;
border-radius: 10px;
} }
&.bx-focusable { &:focus::after {
position: relative; offset = -6px;
content: '';
&::after { border-color: white;
border: 2px solid transparent; position: absolute;
border-radius: 4px; top: offset;
} left: offset;
right: offset;
bottom: offset;
}
body[data-input-mode=Touch] &,
body[data-input-mode=Mouse] & {
&:focus::after { &:focus::after {
content: ''; border-color: transparent !important;
border-color: white; }
position: absolute; }
top: 0;
left: 0; &.bx-circular {
right: 0; &::after {
bottom: 0; border-radius: var(--bx-button-height);
} }
} }
} }
@ -107,3 +173,21 @@ a.bx-button {
text-align: center; text-align: center;
} }
} }
button.bx-inactive {
pointer-events: none;
opacity: 0.2;
background: transparent !important;
}
.bx-button-shortcut {
max-width: max-content;
margin: 10px 0 0 0;
flex: 1 0 auto;
}
@media (min-width: 568px) and (max-height: 480px) {
.bx-button-shortcut {
margin: 8px 0 0 10px;
}
}

View File

@ -1,184 +0,0 @@
.bx-settings-reload-button {
margin-top: 10px;
height: calc(var(--bx-button-height) * 1.5);
}
.bx-settings-container {
background-color: #151515;
user-select: none;
-webkit-user-select: none;
color: #fff;
font-family: var(--bx-normal-font);
}
@media (hover: hover) {
.bx-settings-wrapper a.bx-settings-title:hover {
color: #83f73a;
}
}
.bx-settings-wrapper {
width: 450px;
margin: auto;
padding: 12px 6px;
@media screen and (max-width: 450px) {
width: 100%;
}
*:focus {
outline: none !important;
}
.bx-settings-title-wrapper {
display: flex;
margin-bottom: 10px;
align-items: center;
}
a.bx-settings-title {
font-family: var(--bx-title-font);
font-size: 1.4rem;
text-decoration: none;
font-weight: bold;
display: block;
color: #5dc21e;
flex: 1;
&:focus {
color: #83f73a;
}
}
.bx-button.bx-primary {
margin-top: 8px;
}
a.bx-settings-update {
display: block;
color: #ff834b;
text-decoration: none;
margin-bottom: 8px;
text-align: center;
background: #222;
border-radius: 4px;
padding: 4px;
&:hover {
@media (hover: hover) {
color: #ff9869;
text-decoration: underline;
}
}
&:focus {
color: #ff9869;
text-decoration: underline;
}
}
}
.bx-settings-group-label {
font-weight: bold;
display: block;
font-size: 1.1rem;
}
.bx-settings-row {
display: flex;
padding: 6px 12px;
position: relative;
label {
flex: 1;
align-self: center;
margin-bottom: 0;
}
&:hover, &:focus-within {
background-color: #242424;
}
input {
align-self: center;
accent-color: var(--bx-primary-button-color);
&:focus {
accent-color: var(--bx-danger-button-color);
}
}
select {
&:disabled {
-webkit-appearance: none;
background: transparent;
text-align-last: right;
border: none;
color: #fff;
}
}
input[type=checkbox], select {
&:focus {
filter: drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff);
}
}
&:has(input:focus), &:has(select:focus) {
&::before {
content: ' ';
border-radius: 4px;
border: 2px solid #fff;
position: absolute;
top: 0;
left: 0;
bottom: 0;
}
}
}
.bx-settings-group-label b, .bx-settings-row label b {
display: block;
font-size: 12px;
font-style: italic;
font-weight: normal;
color: #828282;
}
.bx-settings-group-label b {
margin-bottom: 8px;
}
.bx-settings-app-version {
margin-top: 10px;
text-align: center;
color: #747474;
font-size: 12px;
}
.bx-donation-link {
display: block;
text-align: center;
text-decoration: none;
height: 20px;
line-height: 20px;
font-size: 14px;
margin-top: 10px;
color: #5dc21e;
&:hover {
color: #6dd72b;
}
&:focus {
text-decoration: underline;
}
}
.bx-settings-custom-user-agent {
display: block;
width: 100%;
}

View File

@ -4,7 +4,7 @@
svg { svg {
width: 24px; width: 24px;
height: 46px; height: 24px;
} }
} }

View File

@ -16,7 +16,6 @@
} }
.bx-mkb-pointer-lock-msg { .bx-mkb-pointer-lock-msg {
cursor: pointer;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
position: fixed; position: fixed;
@ -24,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;
@ -35,9 +34,11 @@
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;
} }
> div:first-of-type { > div:first-of-type {
@ -51,33 +52,46 @@
&: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 { > div:last-of-type {
display: flex;
flex-flow: row;
margin-top: 10px; margin-top: 10px;
button { &[data-type='native'] {
flex: 1; button {
&:first-of-type {
&:first-of-type { margin-bottom: 8px;
margin-right: 5px; }
}
&:last-of-type {
margin-left: 5px;
} }
} }
button &[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;
}
}
}
}
} }
} }

View File

@ -0,0 +1,18 @@
.bx-navigation-dialog {
position: absolute;
z-index: var(--bx-navigation-dialog-z-index);
}
.bx-navigation-dialog-overlay {
position: fixed;
background: #0b0b0be3;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--bx-navigation-dialog-overlay-z-index);
&[data-is-playing="true"] {
background: transparent;
}
}

View File

@ -5,14 +5,15 @@
display: inline-block; display: inline-block;
min-width: 40px; min-width: 40px;
font-family: var(--bx-monospaced-font); font-family: var(--bx-monospaced-font);
font-size: 14px; font-size: 12px;
margin: 0 4px;
} }
button { button {
border: none; border: none;
width: 24px; width: 24px;
height: 24px; height: 24px;
margin: 0 4px; margin: 0;
line-height: 24px; line-height: 24px;
background-color: var(--bx-default-button-color); background-color: var(--bx-default-button-color);
color: #fff; color: #fff;
@ -20,7 +21,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 +47,10 @@
input[type=range]:disabled, button:disabled { input[type=range]:disabled, button:disabled {
display: none; display: none;
} }
&[data-disabled=true] {
input[type=range], button {
display: none;
}
}
} }

View File

@ -1,3 +1,18 @@
button_color(name, normal, hover, active, disabled)
prefix = unquote('--bx-' + name + '-button');
{prefix + '-color'}: normal;
{prefix + '-rgb'}: red(normal), green(normal), blue(normal);
{prefix + '-hover-color'}: hover;
{prefix + '-hover-rgb'}: red(hover), green(hover), blue(hover);
{prefix + '-active-color'}: active;
{prefix + '-active-rgb'}: red(active), green(active), blue(active);
{prefix + '-disabled-color'}: disabled;
{prefix + '-disabled-rgb'}: red(disabled), green(disabled), blue(disabled);
:root { :root {
--bx-title-font: Bahnschrift, Arial, Helvetica, sans-serif; --bx-title-font: Bahnschrift, Arial, Helvetica, sans-serif;
--bx-title-font-semibold: Bahnschrift Semibold, Arial, Helvetica, sans-serif; --bx-title-font-semibold: Bahnschrift Semibold, Arial, Helvetica, sans-serif;
@ -5,30 +20,28 @@
--bx-monospaced-font: Consolas, "Courier New", Courier, monospace; --bx-monospaced-font: Consolas, "Courier New", Courier, monospace;
--bx-promptfont-font: promptfont; --bx-promptfont-font: promptfont;
--bx-button-height: 36px; --bx-button-height: 40px;
--bx-default-button-color: #2d3036; button_color('default', #2d3036, #515863, #222428, #8e8e8e);
--bx-default-button-hover-color: #515863; button_color('primary', #008746, #04b358, #044e2a, #448262);
--bx-default-button-disabled-color: #8e8e8e; button_color('danger', #c10404, #e61d1d, #a26c6c, #df5656);
--bx-primary-button-color: #008746; --bx-fullscreen-text-z-index: 9999;
--bx-primary-button-hover-color: #04b358; --bx-toast-z-index: 6000;
--bx-primary-button-disabled-color: #448262; --bx-dialog-z-index: 5000;
--bx-danger-button-color: #c10404; --bx-dialog-overlay-z-index: 4020;
--bx-danger-button-hover-color: #e61d1d; --bx-stats-bar-z-index: 4010;
--bx-danger-button-disabled-color: #a26c6c; --bx-mkb-pointer-lock-msg-z-index: 4000;
--bx-toast-z-index: 9999; --bx-navigation-dialog-z-index: 3010;
--bx-dialog-z-index: 9101; --bx-navigation-dialog-overlay-z-index: 3000;
--bx-dialog-overlay-z-index: 9100;
--bx-remote-play-popup-z-index: 9090; --bx-remote-play-popup-z-index: 2000;
--bx-stats-bar-z-index: 9001;
--bx-stream-settings-z-index: 9000; --bx-game-bar-z-index: 1000;
--bx-mkb-pointer-lock-msg-z-index: 8999;
--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: 10;
} }
@font-face { @font-face {
@ -64,6 +77,14 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
overflow: hidden !important; overflow: hidden !important;
} }
.bx-hide-scroll-bar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.bx-gone { .bx-gone {
display: none !important; display: none !important;
} }
@ -79,6 +100,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 +125,18 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
font-family: var(--bx-promptfont-font); font-family: var(--bx-promptfont-font);
} }
.bx-line-through {
text-decoration: line-through !important;
}
.bx-normal-case {
text-transform: none !important;
}
select[multiple] {
overflow: auto;
}
/* Hide UI elements */ /* Hide UI elements */
#headerArea, #uhfSkipToMain, .uhf-footer { #headerArea, #uhfSkipToMain, .uhf-footer {
display: none; display: none;
@ -107,3 +153,75 @@ 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;
}
}
.bx-game-tile-wait-time {
position: absolute;
top: 0;
left: 0;
z-index: 1;
background: #0000008c;
display: none;
border-radius: 0 0 4px 0;
align-items: center;
padding: 4px 8px;
a[class^=BaseItem-module__container]:focus &,
button[class^=BaseItem-module__container]:focus & {
display: flex;
}
svg {
width: 14px;
height: 16px;
margin-right: 2px;
}
span {
display: inline-block;
height: 16px;
line-height: 16px;
font-size: 12px;
font-weight: bold;
}
}
.bx-fullscreen-text {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: #000000cc;
z-index: var(--bx-fullscreen-text-z-index);
line-height: 100vh;
color: #fff;
text-align: center;
font-weight: 400;
font-family: var(--bx-normal-font);
font-size: 1.3rem;
user-select: none;
-webkit-user-select: none;
}
/* Device Code page */
#root {
section[class*=DeviceCodePage-module__page] {
margin-left: 20px !important;
margin-right: 20px !important;
margin-top: 20px !important;
max-width: 800px !important;
}
div[class*=DeviceCodePage-module__back] {
display: none;
}
}

View File

@ -0,0 +1,381 @@
.bx-settings-dialog {
display: flex;
position: fixed;
top: 0;
right: 0;
bottom: 0;
opacity: 0.98;
user-select: none;
-webkit-user-select: none;
.bx-focusable {
&::after {
border-radius: 4px;
}
&:focus::after {
offset = 0;
top: offset;
left: offset;
right: offset;
bottom: offset;
}
}
.bx-settings-reload-note {
font-size: 0.8rem;
display: block;
padding: 8px;
font-style: italic;
font-weight: normal;
height: var(--bx-button-height);
}
}
.bx-settings-tabs-container {
position: fixed;
width: 48px;
max-height: 100vh;
display: flex;
flex-direction: column;
> div:last-of-type {
display: flex;
flex-direction: column;
align-items: end;
button {
flex-shrink: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
margin-top: 8px;
height: unset;
padding: 8px 10px;
svg {
size = 16px;
width: size;
height: size;
}
}
}
}
.bx-settings-tabs {
display: flex;
flex-direction: column;
border-radius: 0 0 0 8px;
box-shadow: 0 0 6px #000;
overflow: overlay;
flex: 1;
svg {
size = 24px;
width: size;
height: size;
padding: 10px;
flex-shrink: 0;
box-sizing: content-box;
background: #131313;
cursor: pointer;
border-left: 4px solid #1e1e1e;
&.bx-active {
background: #222;
border-color: #008746;
}
&:not(.bx-active):hover {
background: #2f2f2f;
border-color: #484848;
}
&:focus {
border-color: #fff;
outline: none;
}
&[data-group=global] {
&[data-need-refresh=true] {
background: var(--bx-danger-button-color) !important;
&:hover {
background: var(--bx-danger-button-hover-color) !important;
}
}
}
}
}
.bx-settings-tab-contents {
tabsWidth = 48px;
flex-direction: column;
padding: 10px;
margin-left: tabsWidth;
width: 450px;
max-width: calc(100vw - tabsWidth);
background: #1a1b1e;
color: #fff;
font-weight: 400;
font-size: 16px;
font-family: var(--bx-title-font);
text-align: center;
box-shadow: 0px 0px 6px #000;
overflow: overlay;
z-index: 1;
> div[data-tab-group=mkb] {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
> div[data-tab-group=shortcuts] {
> div {
&[data-has-gamepad=true] {
> div:first-of-type {
display: none;
}
> div:last-of-type {
display: block;
}
}
&[data-has-gamepad=false] {
> div:first-of-type {
display: block;
}
> div:last-of-type {
display: none;
}
}
}
.bx-shortcut-profile {
width: 100%;
height: 36px;
display: block;
}
.bx-shortcut-note {
margin-top: 10px;
font-size: 14px;
}
.bx-shortcut-row {
display: flex;
margin-bottom: 10px;
label.bx-prompt {
flex: 1;
font-size: 26px;
margin-bottom: 0;
}
.bx-shortcut-actions {
flex: 2;
position: relative;
select {
position: absolute;
width: 100%;
height: 100%;
display: block;
&:last-of-type {
opacity: 0;
z-index: calc(var(--bx-settings-z-index) + 1);
}
}
}
}
}
&:focus,
*:focus {
outline: none !important;
}
.bx-top-buttons {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
.bx-button {
display: block;
}
}
h2 {
margin: 16px 0 8px 0;
display: flex;
align-items: center;
&:first-of-type {
margin-top: 0;
}
span {
display: inline-block;
font-size: 20px;
font-weight: bold;
text-align: left;
flex: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
@media (max-width: 500px) {
.bx-settings-tab-contents {
width: calc(100vw - 48px);
}
}
.bx-settings-row {
display: flex;
gap: 10px;
border-bottom: 1px solid #2c2c2e;
padding: 16px 8px;
margin: 0;
border-left: 2px solid transparent;
&:hover, &:focus-within {
background-color: #242424;
}
&:not(:has(> input[type=checkbox])) {
flex-wrap: wrap;
}
input[type=checkbox],
select {
&:focus {
filter: drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff);
}
}
&:has(input:focus), &:has(select:focus), &:has(button:focus) {
border-left-color: white;
}
> span.bx-settings-label {
font-size: 14px;
display: block;
text-align: left;
align-self: center;
margin-bottom: 0 !important;
+ * {
margin: 0 0 0 auto;
}
}
input {
accent-color: var(--bx-primary-button-color);
&:focus {
accent-color: var(--bx-danger-button-color);
}
}
select:disabled {
-webkit-appearance: none;
background: transparent;
text-align-last: right;
border: none;
color: #fff;
}
select option:disabled {
display: none;
}
}
.bx-settings-dialog-note {
display: block;
color: #afafb0;
font-size: 12px;
font-weight: lighter;
font-style: italic;
&:not(:has(a)) {
margin-top: 4px;
}
a {
display: inline-block;
padding: 4px;
}
}
.bx-settings-custom-user-agent {
display: block;
width: 100%;
padding: 6px;
}
.bx-donation-link {
display: block;
text-align: center;
text-decoration: none;
height: 20px;
line-height: 20px;
font-size: 14px;
margin-top: 10px;
color: #5dc21e;
&:hover {
color: #6dd72b;
}
&:focus {
text-decoration: underline;
}
}
.bx-debug-info {
button {
margin-top: 10px;
}
pre {
margin-top: 10px;
cursor: copy;
color: white;
padding: 8px;
border: 1px solid #2d2d2d;
background: #212121;
white-space: break-spaces;
text-align: left;
&:hover {
background: #272727;
}
}
}
.bx-settings-app-version {
margin-top: 10px;
text-align: center;
color: #747474;
font-size: 12px;
}
.bx-note-unsupported {
display: block;
font-size: 12px;
font-style: italic;
font-weight: normal;
color: #828282;
}

View File

@ -1,189 +0,0 @@
.bx-stream-settings-dialog {
display: flex;
position: fixed;
z-index: var(--bx-stream-settings-z-index);
opacity: 0.98;
user-select: none;
-webkit-user-select: none;
}
.bx-stream-settings-tabs {
position: fixed;
top: 0;
right: 420px;
display: flex;
flex-direction: column;
border-radius: 0 0 0 8px;
box-shadow: 0px 0px 6px #000;
overflow: clip;
svg {
width: 32px;
height: 32px;
padding: 10px;
box-sizing: content-box;
background: #131313;
cursor: pointer;
border-left: 4px solid #1e1e1e;
&.bx-active {
background: #222;
border-color: #008746;
}
&:not(.bx-active):hover {
background: #2f2f2f;
border-color: #484848;
}
}
}
.bx-stream-settings-tab-contents {
flex-direction: column;
position: fixed;
right: 0;
top: 0;
bottom: 0;
padding: 14px 14px 0;
width: 420px;
background: #1a1b1e;
color: #fff;
font-weight: 400;
font-size: 16px;
font-family: var(--bx-title-font);
text-align: center;
box-shadow: 0px 0px 6px #000;
overflow: overlay;
> div[data-group=mkb] {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
*:focus {
outline: none !important;
}
h2 {
margin-bottom: 8px;
display: flex;
align-item: center;
span {
display: inline-block;
font-size: 24px;
font-weight: bold;
text-transform: uppercase;
text-align: left;
flex: 1;
height: var(--bx-button-height);
line-height: calc(var(--bx-button-height) + 4px);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.bx-stream-settings-row {
display: flex;
border-bottom: 1px solid #40404080;
margin-bottom: 16px;
padding-bottom: 16px;
label {
font-size: 16px;
display: block;
text-align: left;
flex: 1;
align-self: center;
margin-bottom: 0 !important;
}
input {
accent-color: var(--bx-primary-button-color);
}
select:disabled {
-webkit-appearance: none;
background: transparent;
text-align-last: right;
border: none;
}
}
.bx-stream-settings-dialog-note {
display: block;
font-size: 12px;
font-weight: lighter;
font-style: italic;
}
.bx-stream-settings-tab-contents {
div[data-group="shortcuts"] {
> div {
&[data-has-gamepad=true] {
> div:first-of-type {
display: none;
}
> div:last-of-type {
display: block;
}
}
&[data-has-gamepad=false] {
> div:first-of-type {
display: block;
}
> div:last-of-type {
display: none;
}
}
}
.bx-shortcut-profile {
width: 100%;
height: 36px;
display: block;
margin-bottom: 10px;
}
.bx-shortcut-note {
font-size: 14px;
}
.bx-shortcut-row {
display: flex;
margin-bottom: 10px;
label.bx-prompt {
flex: 1;
font-size: 26px;
margin-bottom: 0;
}
.bx-shortcut-actions {
flex: 2;
position: relative;
select {
position: absolute;
width: 100%;
height: 100%;
display: block;
&:last-of-type {
opacity: 0;
z-index: calc(var(--bx-stream-settings-z-index) + 1);
}
}
}
}
}
}

View File

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

View File

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

View File

@ -2,15 +2,16 @@
@import 'button.styl'; @import 'button.styl';
@import 'header.styl'; @import 'header.styl';
@import 'global-settings.styl';
@import 'dialog.styl'; @import 'dialog.styl';
@import 'navigation-dialog.styl';
@import 'settings-dialog.styl';
@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';
@import 'game-bar.styl'; @import 'game-bar.styl';
@import 'stream-stats.styl'; @import 'stream-stats.styl';
@import 'stream-settings.styl';
@import 'mkb.styl'; @import 'mkb.styl';

View File

@ -0,0 +1,94 @@
.bx-select {
display: flex;
align-items: center;
flex: 0 1 auto;
select {
display: none !important;
}
> div, button.bx-select-value {
min-width: 110px;
text-align: center;
margin: 0 8px;
line-height: 24px;
vertical-align: middle;
background: #fff;
color: #000;
border-radius: 4px;
padding: 2px 8px;
flex: 1;
}
> div {
display: inline-block;
input {
display: inline-block;
margin-right: 8px;
}
label {
margin-bottom: 0;
font-size: 14px;
width: 100%;
span {
display: block;
font-size: 10px;
font-weight: bold;
text-align: left;
line-height: initial;
}
}
}
button.bx-select-value {
border: none;
display: inline-flex;
cursor: pointer;
min-height: 30px;
font-size: 0.9rem;
align-items: center;
span {
flex: 1;
text-align: center;
display: inline-block;
}
input {
margin: 0 4px;
accent-color: var(--bx-primary-button-color);
}
&:hover,
&:focus {
input {
accent-color: var(--bx-danger-button-color);
}
&::after {
border-color: #4d4d4d !important;
}
}
}
button.bx-button {
border: none;
height: 24px;
width: 24px;
padding: 0;
line-height: 24px;
color: #fff;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
font-family: var(--bx-monospaced-font);
flex-shrink: 0;
span {
line-height: unset;
}
}
}

View File

@ -12,4 +12,4 @@
// @updateURL https://raw.githubusercontent.com/redphx/better-xcloud/typescript/dist/better-xcloud.meta.js // @updateURL https://raw.githubusercontent.com/redphx/better-xcloud/typescript/dist/better-xcloud.meta.js
// @downloadURL https://github.com/redphx/better-xcloud/releases/latest/download/better-xcloud.user.js // @downloadURL https://github.com/redphx/better-xcloud/releases/latest/download/better-xcloud.user.js
// ==/UserScript== // ==/UserScript==
'use strict'; "use strict";

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-miterlimit='2' stroke-width='2' viewBox='0 0 32 32'>
<path d='M16.001 7.236h-2.328c-.443 0-1.941-.851-2.357-.905-.824-.106-1.684 0-2.489.176a13.04 13.04 0 0 0-3.137 1.14c-.392.275-.677.668-.866 1.104v.03l-3.302 8.963-.015.015c-.288.867-.553 3.75-.5 4.279a4.89 4.89 0 0 0 1.022 2.55c.654.823 3.71 1.364 4.057 1.016l4.462-4.475c.185-.186 1.547-.706 2.01-.706h6.884c.463 0 1.825.52 2.01.706l4.462 4.475c.347.348 3.403-.193 4.057-1.016a4.89 4.89 0 0 0 1.022-2.55c.053-.529-.212-3.412-.5-4.279l-.015-.015-3.302-8.963v-.03c-.189-.436-.474-.829-.866-1.104a13.04 13.04 0 0 0-3.137-1.14c-.805-.176-1.665-.282-2.489-.176-.416.054-1.914.905-2.357.905h-2.328' fill='none' stroke='#fff'/>
<path d='M8.172 12.914H6.519c-.235 0-.315.267-.335.452l-.052.578c0 .193.033.384.054.576.023.202.091.511.355.511h1.631l-.001 1.652c0 .234.266.315.452.335l.578.052c.193 0 .384-.033.576-.054.203-.023.511-.091.511-.355V15.03l1.652.001c.234 0 .315-.266.335-.452l.052-.578c-.001-.193-.033-.385-.055-.577-.022-.202-.09-.51-.354-.51h-1.632v-1.652c0-.234-.266-.315-.453-.335l-.577-.052c-.193 0-.385.033-.577.054-.202.023-.51.091-.51.355v1.631m16.546 2.994h-3.487c-.206 0-.413-.043-.604-.121-.177-.072-.339-.183-.476-.316-.149-.144-.259-.315-.341-.504-.156-.361-.172-.788-.032-1.157a1.57 1.57 0 0 1 .459-.641c.106-.089.223-.164.349-.222a1.52 1.52 0 0 1 .423-.123c.167-.024.338-.02.504.012a1.83 1.83 0 0 1 .455-.482 1.62 1.62 0 0 1 .522-.252c.307-.089.651-.09.959-.003a1.75 1.75 0 0 1 1.009.764 1.83 1.83 0 0 1 .251.721c.156 0 .312.031.456.09a1.24 1.24 0 0 1 .372.248c.091.087.165.19.221.302a1.19 1.19 0 0 1-.173 1.299c-.119.132-.276.239-.441.305a1.17 1.17 0 0 1-.426.08z' fill='#fff' stroke='none'/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

4
src/assets/svg/close.svg Normal file
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='M29.928,2.072L2.072,29.928'/>
<path d='M29.928,29.928L2.072,2.072'/>
</svg>

After

Width:  |  Height:  |  Size: 264 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='M13.253 3.639c0-.758-.615-1.373-1.373-1.373H3.639c-.758 0-1.373.615-1.373 1.373v8.241c0 .758.615 1.373 1.373 1.373h8.241c.758 0 1.373-.615 1.373-1.373V3.639zm0 16.481c0-.758-.615-1.373-1.373-1.373H3.639c-.758 0-1.373.615-1.373 1.373v8.241c0 .758.615 1.373 1.373 1.373h8.241c.758 0 1.373-.615 1.373-1.373V20.12zm16.481 0c0-.758-.615-1.373-1.373-1.373H20.12c-.758 0-1.373.615-1.373 1.373v8.241c0 .758.615 1.373 1.373 1.373h8.241c.758 0 1.373-.615 1.373-1.373V20.12zM19.262 7.76h9.957'/>
<path d='M24.24 2.781v9.957'/>
</svg>

After

Width:  |  Height:  |  Size: 711 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

@ -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

@ -0,0 +1,15 @@
import { t } from "@/utils/translation"
export const BypassServers = {
'br': t('brazil'),
'jp': t('japan'),
'pl': t('poland'),
'us': t('united-states'),
}
export const BypassServerIps = {
'br': '169.150.198.66',
'jp': '138.199.21.239',
'pl': '45.134.212.66',
'us': '143.244.47.65',
}

View File

@ -1,4 +1,6 @@
export enum GamePassCloudGallery { export enum GamePassCloudGallery {
TOUCH = '9c86f07a-f3e8-45ad-82a0-a1f759597059',
ALL = '29a81209-df6f-41fd-a528-2ae6b91f719c', ALL = '29a81209-df6f-41fd-a528-2ae6b91f719c',
MOST_POPULAR = 'e7590b22-e299-44db-ae22-25c61405454c',
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,

101
src/enums/pref-keys.ts Normal file
View File

@ -0,0 +1,101 @@
export enum StorageKey {
GLOBAL = 'better_xcloud',
}
export enum PrefKey {
LAST_UPDATE_CHECK = 'version_last_check',
LATEST_VERSION = 'version_latest',
CURRENT_VERSION = 'version_current',
BETTER_XCLOUD_LOCALE = 'bx_locale',
SERVER_REGION = 'server_region',
SERVER_BYPASS_RESTRICTION = 'server_bypass_restriction',
PREFER_IPV6_SERVER = 'prefer_ipv6_server',
STREAM_TARGET_RESOLUTION = 'stream_target_resolution',
STREAM_PREFERRED_LOCALE = 'stream_preferred_locale',
STREAM_CODEC_PROFILE = 'stream_codec_profile',
USER_AGENT_PROFILE = 'user_agent_profile',
STREAM_SIMPLIFY_MENU = 'stream_simplify_menu',
STREAM_COMBINE_SOURCES = 'stream_combine_sources',
STREAM_TOUCH_CONTROLLER = 'stream_touch_controller',
STREAM_TOUCH_CONTROLLER_AUTO_OFF = 'stream_touch_controller_auto_off',
STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY = 'stream_touch_controller_default_opacity',
STREAM_TOUCH_CONTROLLER_STYLE_STANDARD = 'stream_touch_controller_style_standard',
STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM = 'stream_touch_controller_style_custom',
STREAM_DISABLE_FEEDBACK_DIALOG = 'stream_disable_feedback_dialog',
BITRATE_VIDEO_MAX = 'bitrate_video_max',
GAME_BAR_POSITION = 'game_bar_position',
LOCAL_CO_OP_ENABLED = 'local_co_op_enabled',
// LOCAL_CO_OP_SEPARATE_TOUCH_CONTROLLER = 'local_co_op_separate_touch_controller',
CONTROLLER_ENABLE_SHORTCUTS = 'controller_enable_shortcuts',
CONTROLLER_ENABLE_VIBRATION = 'controller_enable_vibration',
CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration',
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',
MKB_ENABLED = 'mkb_enabled',
MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor',
MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse',
MKB_DEFAULT_PRESET_ID = 'mkb_default_preset_id',
SCREENSHOT_APPLY_FILTERS = 'screenshot_apply_filters',
BLOCK_TRACKING = 'block_tracking',
BLOCK_SOCIAL_FEATURES = 'block_social_features',
SKIP_SPLASH_VIDEO = 'skip_splash_video',
HIDE_DOTS_ICON = 'hide_dots_icon',
REDUCE_ANIMATIONS = 'reduce_animations',
UI_LOADING_SCREEN_GAME_ART = 'ui_loading_screen_game_art',
UI_LOADING_SCREEN_WAIT_TIME = 'ui_loading_screen_wait_time',
UI_LOADING_SCREEN_ROCKET = 'ui_loading_screen_rocket',
UI_CONTROLLER_FRIENDLY = 'ui_controller_friendly',
UI_LAYOUT = 'ui_layout',
UI_SCROLLBAR_HIDE = 'ui_scrollbar_hide',
UI_HIDE_SECTIONS = 'ui_hide_sections',
UI_HOME_CONTEXT_MENU_DISABLED = 'ui_home_context_menu_disabled',
UI_GAME_CARD_SHOW_WAIT_TIME = 'ui_game_card_show_wait_time',
VIDEO_PLAYER_TYPE = 'video_player_type',
VIDEO_PROCESSING = 'video_processing',
VIDEO_POWER_PREFERENCE = 'video_power_preference',
VIDEO_SHARPNESS = 'video_sharpness',
VIDEO_RATIO = 'video_ratio',
VIDEO_BRIGHTNESS = 'video_brightness',
VIDEO_CONTRAST = 'video_contrast',
VIDEO_SATURATION = 'video_saturation',
AUDIO_MIC_ON_PLAYING = 'audio_mic_on_playing',
AUDIO_ENABLE_VOLUME_CONTROL = 'audio_enable_volume_control',
AUDIO_VOLUME = 'audio_volume',
STATS_ITEMS = 'stats_items',
STATS_SHOW_WHEN_PLAYING = 'stats_show_when_playing',
STATS_QUICK_GLANCE = 'stats_quick_glance',
STATS_POSITION = 'stats_position',
STATS_TEXT_SIZE = 'stats_text_size',
STATS_TRANSPARENT = 'stats_transparent',
STATS_OPACITY = 'stats_opacity',
STATS_CONDITIONAL_FORMATTING = 'stats_conditional_formatting',
REMOTE_PLAY_ENABLED = 'xhome_enabled',
REMOTE_PLAY_RESOLUTION = 'xhome_resolution',
GAME_FORTNITE_FORCE_CONSOLE = 'game_fortnite_force_console',
}

View File

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

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

@ -0,0 +1,8 @@
export enum UiSection {
ALL_GAMES = 'all-games',
FRIENDS = 'friends',
MOST_POPULAR = 'most-popular',
NATIVE_MKB = 'native-mkb',
NEWS = 'news',
TOUCH = 'touch',
}

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

@ -1,45 +1,60 @@
import "@utils/global"; import "@utils/global";
import { BxEvent, XcloudGuideWhere } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS } from "@utils/bx-flags"; import { BX_FLAGS } from "@utils/bx-flags";
import { BxExposed } from "@utils/bx-exposed"; import { BxExposed } from "@utils/bx-exposed";
import { t } from "@utils/translation"; 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 { 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 { disableAdobeAudienceManager, 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, setupStreamUiEvents } 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 { updateVideoPlayer } from "./modules/stream/stream-settings-utils";
import { UiSection } from "./enums/ui-sections";
import { HeaderSection } from "./modules/ui/header";
import { GameTile } from "./modules/ui/game-tile";
import { ProductDetailsPage } from "./modules/ui/product-details";
import { NavigationDialogManager } from "./modules/ui/dialog/navigation-dialog";
import { PrefKey } from "./enums/pref-keys";
import { getPref } from "./utils/settings-storages/global-settings-storage";
import { compressCss } from "@macros/build" with {type: "macro"};
import { SettingsNavigationDialog } from "./modules/ui/dialog/settings-dialog";
// 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');
} }
@ -51,7 +66,7 @@ if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') {
window.stop(); window.stop();
// Show the reloading overlay // Show the reloading overlay
const css = ` const css = compressCss(`
.bx-reload-overlay { .bx-reload-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@ -65,7 +80,7 @@ if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') {
font-family: "Segoe UI", Arial, Helvetica, sans-serif; font-family: "Segoe UI", Arial, Helvetica, sans-serif;
font-size: 1.3rem; font-size: 1.3rem;
} }
`; `);
const $fragment = document.createDocumentFragment(); const $fragment = document.createDocumentFragment();
$fragment.appendChild(CE('style', {}, css)); $fragment.appendChild(CE('style', {}, css));
$fragment.appendChild(CE('div', {'class': 'bx-reload-overlay'}, t('safari-failed-message'))); $fragment.appendChild(CE('div', {'class': 'bx-reload-overlay'}, t('safari-failed-message')));
@ -80,8 +95,8 @@ if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') {
throw new Error('[Better xCloud] Executing workaround for Safari'); throw new Error('[Better xCloud] Executing workaround for Safari');
} }
// Automatically reload the page when running into the "We are sorry..." error message
window.addEventListener('load', e => { window.addEventListener('load', e => {
// Automatically reload the page when running into the "We are sorry..." error message
window.setTimeout(() => { window.setTimeout(() => {
if (document.body.classList.contains('legacyBackground')) { if (document.body.classList.contains('legacyBackground')) {
// Has error message -> reload page // Has error message -> reload page
@ -92,6 +107,31 @@ window.addEventListener('load', e => {
}, 3000); }, 3000);
}); });
document.addEventListener('readystatechange', e => {
if (document.readyState !== 'interactive') {
return;
}
STATES.isSignedIn = !!((window as any).xbcUser?.isSignedIn);
if (STATES.isSignedIn) {
// Preload Remote Play
getPref(PrefKey.REMOTE_PLAY_ENABLED) && RemotePlay.preload();
} else {
// Show Settings button in the header when not signed in
HeaderSection.watchHeader();
}
// Hide "Play with Friends" skeleton section
if (getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.FRIENDS)) {
const $parent = document.querySelector('div[class*=PlayWithFriendsSkeleton]')?.closest('div[class*=HomePage-module]') as HTMLElement;
$parent && ($parent.style.display = 'none');
}
// Preload fonts
preloadFonts();
})
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 +143,17 @@ 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);
// Open Settings dialog on Unsupported page
SettingsNavigationDialog.getInstance().show();
});
window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => { window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => {
// Start rendering UI STATES.isSignedIn = true;
if (document.querySelector('div[class^=UnsupportedMarketPage]')) { HeaderSection.watchHeader();
window.setTimeout(watchHeader, 2000);
} else {
watchHeader();
}
}); });
window.addEventListener(BxEvent.STREAM_LOADING, e => { window.addEventListener(BxEvent.STREAM_LOADING, e => {
@ -124,9 +168,6 @@ window.addEventListener(BxEvent.STREAM_LOADING, e => {
STATES.currentStream.titleId = 'remote-play'; STATES.currentStream.titleId = 'remote-play';
STATES.currentStream.productId = ''; STATES.currentStream.productId = '';
} }
// Setup UI
setupStreamUi();
}); });
// Setup loading screen // Setup loading screen
@ -144,9 +185,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 +195,51 @@ 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.XCLOUD_RENDERING_COMPONENT, e => {
const component = (e as any).component;
if (component === 'product-details') {
ProductDetailsPage.injectShortcutButton();
}
});
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 NavigationDialogManager.getInstance().hide();
getPref(PrefKey.MKB_ENABLED) && MkbHandler.INSTANCE.destroy();
const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog');
if ($streamSettingsDialog) {
$streamSettingsDialog.classList.add('bx-gone');
}
STATES.currentStream.audioGainNode = null;
STATES.currentStream.$video = null;
StreamStats.getInstance().onStoppedPlaying(); StreamStats.getInstance().onStoppedPlaying();
MouseCursorHider.stop(); MouseCursorHider.stop();
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 => {
@ -210,15 +260,18 @@ function observeRootDialog($root: HTMLElement) {
const $addedElm = mutation.addedNodes[0]; const $addedElm = mutation.addedNodes[0];
if ($addedElm instanceof HTMLElement && $addedElm.className) { if ($addedElm instanceof HTMLElement && $addedElm.className) {
if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) { if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) {
// Find navigation bar // Make sure it's Guide dialog
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true'); if (document.querySelector('#gamepass-dialog-root div[class*=GuideDialog]')) {
if ($selectedTab) { // Find navigation bar
let $elm: Element | null = $selectedTab; const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
let index; if ($selectedTab) {
for (index = 0; ($elm = $elm?.previousElementSibling); index++); let $elm: Element | null = $selectedTab;
let index;
for (index = 0; ($elm = $elm?.previousElementSibling); index++);
if (index === 0) { if (index === 0) {
BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_SHOWN, {where: XcloudGuideWhere.HOME}); BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
}
} }
} }
} }
@ -263,11 +316,16 @@ 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();
STATES.hasTouchSupport && TouchController.updateCustomList(); if (getPref(PrefKey.BLOCK_TRACKING)) {
patchMeControl();
disableAdobeAudienceManager();
}
STATES.userAgent.capabilities.touch && TouchController.updateCustomList();
overridePreloadState(); overridePreloadState();
VibrationManager.initialSetup(); VibrationManager.initialSetup();
@ -279,20 +337,22 @@ function main() {
addCss(); addCss();
Toast.setup(); Toast.setup();
(getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance(); (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance();
BX_FLAGS.PreloadUi && setupStreamUi(); Screenshot.setup();
setupStreamUiEvents(); 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)) {
@ -304,7 +364,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());
}
// Show wait time in game card
getPref(PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME) && GameTile.setup();
} }
main(); main();

View File

@ -1,12 +1,19 @@
import stylus from 'stylus'; import stylus from 'stylus';
import cssStr from "@assets/css/styles.styl" with { type: "text" }; export const renderStylus = async () => {
const file = Bun.file('./src/assets/css/styles.styl');
const cssStr = await file.text();
const generatedCss = await (stylus(cssStr, {}) const generatedCss = await (stylus(cssStr, {})
.set('filename', 'styles.css') .set('filename', 'styles.css')
.include('src/assets/css/')) .set('compress', true)
.render(); .include('src/assets/css/'))
.render();
export const renderStylus = () => {
return generatedCss; return generatedCss;
}; };
export const compressCss = async (css: string) => {
return await (stylus(css, {}).set('compress', true)).render();
};

View File

@ -1,18 +1,24 @@
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";
import { PrefKey, getPref } from "@utils/preferences";
import { SoundShortcut } from "./shortcuts/shortcut-sound"; import { SoundShortcut } from "./shortcuts/shortcut-sound";
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
import { AppInterface } from "@/utils/global"; import { AppInterface } from "@/utils/global";
import { BxSelectElement } from "@/web-components/bx-select";
import { setNearby } from "@/utils/navigation-utils";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { SettingsNavigationDialog } from "./ui/dialog/settings-dialog";
const enum ShortcutAction {
BETTER_XCLOUD_SETTINGS_SHOW = 'bx-settings-show',
enum ShortcutAction {
STREAM_SCREENSHOT_CAPTURE = 'stream-screenshot-capture', STREAM_SCREENSHOT_CAPTURE = 'stream-screenshot-capture',
STREAM_MENU_SHOW = 'stream-menu-show', STREAM_MENU_SHOW = 'stream-menu-show',
@ -41,7 +47,7 @@ export class ControllerShortcut {
static #$selectActions: Partial<{[key in GamepadKey]: HTMLSelectElement}> = {}; static #$selectActions: Partial<{[key in GamepadKey]: HTMLSelectElement}> = {};
static #$container: HTMLElement; static #$container: HTMLElement;
static #ACTIONS: {[key: string]: (ShortcutAction | null)[]} = {}; static #ACTIONS: {[key: string]: (ShortcutAction | null)[]} | null = null;
static reset(index: number) { static reset(index: number) {
ControllerShortcut.#buttonsCache[index] = []; ControllerShortcut.#buttonsCache[index] = [];
@ -49,8 +55,12 @@ export class ControllerShortcut {
} }
static handle(gamepad: Gamepad): boolean { static handle(gamepad: Gamepad): boolean {
if (!ControllerShortcut.#ACTIONS) {
ControllerShortcut.#ACTIONS = ControllerShortcut.#getActionsFromStorage();
}
const gamepadIndex = gamepad.index; const gamepadIndex = gamepad.index;
const actions = ControllerShortcut.#ACTIONS[gamepad.id]; const actions = ControllerShortcut.#ACTIONS![gamepad.id];
if (!actions) { if (!actions) {
return false; return false;
} }
@ -82,6 +92,10 @@ export class ControllerShortcut {
static #runAction(action: ShortcutAction) { static #runAction(action: ShortcutAction) {
switch (action) { switch (action) {
case ShortcutAction.BETTER_XCLOUD_SETTINGS_SHOW:
SettingsNavigationDialog.getInstance().show();
break;
case ShortcutAction.STREAM_SCREENSHOT_CAPTURE: case ShortcutAction.STREAM_SCREENSHOT_CAPTURE:
Screenshot.takeScreenshot(); Screenshot.takeScreenshot();
break; break;
@ -121,15 +135,16 @@ export class ControllerShortcut {
} }
static #updateAction(profile: string, button: GamepadKey, action: ShortcutAction | null) { static #updateAction(profile: string, button: GamepadKey, action: ShortcutAction | null) {
if (!(profile in ControllerShortcut.#ACTIONS)) { const actions = ControllerShortcut.#ACTIONS!;
ControllerShortcut.#ACTIONS[profile] = []; if (!(profile in actions)) {
actions[profile] = [];
} }
if (!action) { if (!action) {
action = null; action = null;
} }
ControllerShortcut.#ACTIONS[profile][button] = action; actions[profile][button] = action;
// Remove empty profiles // Remove empty profiles
for (const key in ControllerShortcut.#ACTIONS) { for (const key in ControllerShortcut.#ACTIONS) {
@ -172,7 +187,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;
} }
@ -182,18 +197,18 @@ export class ControllerShortcut {
$fragment.appendChild($option); $fragment.appendChild($option);
} }
$container.dataset.hasGamepad = hasGamepad.toString();
if (hasGamepad) { if (hasGamepad) {
$select.appendChild($fragment); $select.appendChild($fragment);
$select.selectedIndex = 0; $select.selectedIndex = 0;
$select.dispatchEvent(new Event('change')); $select.dispatchEvent(new Event('input'));
} }
$container.dataset.hasGamepad = hasGamepad.toString();
} }
static #switchProfile(profile: string) { static #switchProfile(profile: string) {
let actions = ControllerShortcut.#ACTIONS[profile]; let actions = ControllerShortcut.#ACTIONS![profile];
if (!actions) { if (!actions) {
actions = []; actions = [];
} }
@ -204,15 +219,22 @@ export class ControllerShortcut {
const $select = ControllerShortcut.#$selectActions[button as GamepadKey]!; const $select = ControllerShortcut.#$selectActions[button as GamepadKey]!;
$select.value = actions[button] || ''; $select.value = actions[button] || '';
BxEvent.dispatch($select, 'change', { BxEvent.dispatch($select, 'input', {
ignoreOnChange: true, ignoreOnChange: true,
}); manualTrigger: true,
});
} }
} }
static #getActionsFromStorage() {
return JSON.parse(window.localStorage.getItem(ControllerShortcut.#STORAGE_KEY) || '{}');
}
static renderSettings() { static renderSettings() {
const PREF_CONTROLLER_FRIENDLY_UI = getPref(PrefKey.UI_CONTROLLER_FRIENDLY);
// Read actions from localStorage // Read actions from localStorage
ControllerShortcut.#ACTIONS = JSON.parse(window.localStorage.getItem(ControllerShortcut.#STORAGE_KEY) || '{}'); ControllerShortcut.#ACTIONS = ControllerShortcut.#getActionsFromStorage();
const buttons: Map<GamepadKey, PrompFont> = new Map(); const buttons: Map<GamepadKey, PrompFont> = new Map();
buttons.set(GamepadKey.Y, PrompFont.Y); buttons.set(GamepadKey.Y, PrompFont.Y);
@ -238,6 +260,10 @@ export class ControllerShortcut {
buttons.set(GamepadKey.R3, PrompFont.R3); buttons.set(GamepadKey.R3, PrompFont.R3);
const actions: {[key: string]: Partial<{[key in ShortcutAction]: string | string[]}>} = { const actions: {[key: string]: Partial<{[key in ShortcutAction]: string | string[]}>} = {
[t('better-xcloud')]: {
[ShortcutAction.BETTER_XCLOUD_SETTINGS_SHOW]: [t('settings'), t('show')],
},
[t('device')]: AppInterface && { [t('device')]: AppInterface && {
[ShortcutAction.DEVICE_SOUND_TOGGLE]: [t('sound'), t('toggle')], [ShortcutAction.DEVICE_SOUND_TOGGLE]: [t('sound'), t('toggle')],
[ShortcutAction.DEVICE_VOLUME_INC]: [t('volume'), t('increase')], [ShortcutAction.DEVICE_VOLUME_INC]: [t('volume'), t('increase')],
@ -257,7 +283,7 @@ export class ControllerShortcut {
[ShortcutAction.STREAM_MENU_SHOW]: [t('menu'), t('show')], [ShortcutAction.STREAM_MENU_SHOW]: [t('menu'), t('show')],
[ShortcutAction.STREAM_STATS_TOGGLE]: [t('stats'), t('show-hide')], [ShortcutAction.STREAM_STATS_TOGGLE]: [t('stats'), t('show-hide')],
[ShortcutAction.STREAM_MICROPHONE_TOGGLE]: [t('microphone'), t('toggle')], [ShortcutAction.STREAM_MICROPHONE_TOGGLE]: [t('microphone'), t('toggle')],
} },
}; };
const $baseSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'}, CE('option', {value: ''}, '---')); const $baseSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'}, CE('option', {value: ''}, '---'));
@ -287,23 +313,34 @@ export class ControllerShortcut {
} }
let $remap: HTMLElement; let $remap: HTMLElement;
let $selectProfile: HTMLSelectElement; const $selectProfile = CE<HTMLSelectElement>('select', {class: 'bx-shortcut-profile', autocomplete: 'off'});
const $container = CE('div', {'data-has-gamepad': 'false'}, const $profile = PREF_CONTROLLER_FRIENDLY_UI ? BxSelectElement.wrap($selectProfile) : $selectProfile;
const $container = CE('div', {
'data-has-gamepad': 'false',
_nearby: {
focus: $profile,
},
},
CE('div', {}, CE('div', {},
CE('p', {'class': 'bx-shortcut-note'}, t('controller-shortcuts-connect-note')), CE('p', {class: 'bx-shortcut-note'}, t('controller-shortcuts-connect-note')),
), ),
$remap = CE('div', {}, $remap = CE('div', {},
$selectProfile = CE('select', {'class': 'bx-shortcut-profile', autocomplete: 'off'}), CE('div', {
CE('p', {'class': 'bx-shortcut-note'}, _nearby: {
CE('span', {'class': 'bx-prompt'}, PrompFont.HOME), focus: $profile,
},
}, $profile),
CE('p', {class: 'bx-shortcut-note'},
CE('span', {class: 'bx-prompt'}, PrompFont.HOME),
': ' + t('controller-shortcuts-xbox-note'), ': ' + t('controller-shortcuts-xbox-note'),
), ),
), ),
); );
$selectProfile.addEventListener('change', e => { $selectProfile.addEventListener('input', e => {
ControllerShortcut.#switchProfile($selectProfile.value); ControllerShortcut.#switchProfile($selectProfile.value);
}); });
@ -314,38 +351,57 @@ export class ControllerShortcut {
const button: unknown = $target.dataset.button; const button: unknown = $target.dataset.button;
const action = $target.value as ShortcutAction; const action = $target.value as ShortcutAction;
const $fakeSelect = $target.previousElementSibling! as HTMLSelectElement; if (!PREF_CONTROLLER_FRIENDLY_UI) {
let fakeText = '---'; const $fakeSelect = $target.previousElementSibling! as HTMLSelectElement;
if (action) { let fakeText = '---';
const $selectedOption = $target.options[$target.selectedIndex]; if (action) {
const $optGroup = $selectedOption.parentElement as HTMLOptGroupElement; const $selectedOption = $target.options[$target.selectedIndex];
fakeText = $optGroup.label + ' ' + $selectedOption.text; const $optGroup = $selectedOption.parentElement as HTMLOptGroupElement;
fakeText = $optGroup.label + ' ' + $selectedOption.text;
}
($fakeSelect.firstElementChild as HTMLOptionElement).text = fakeText;
} }
($fakeSelect.firstElementChild as HTMLOptionElement).text = fakeText;
!(e as any).ignoreOnChange && ControllerShortcut.#updateAction(profile, button as GamepadKey, action); !(e as any).ignoreOnChange && ControllerShortcut.#updateAction(profile, button as GamepadKey, action);
}; };
// @ts-ignore // @ts-ignore
for (const [button, prompt] of buttons) { for (const [button, prompt] of buttons) {
const $row = CE('div', {'class': 'bx-shortcut-row'}); const $row = CE('div', {
class: 'bx-shortcut-row',
});
const $label = CE('label', {'class': 'bx-prompt'}, `${PrompFont.HOME} + ${prompt}`); const $label = CE('label', {class: 'bx-prompt'}, `${PrompFont.HOME} + ${prompt}`);
const $div = CE('div', {'class': 'bx-shortcut-actions'}); const $div = CE('div', {class: 'bx-shortcut-actions'});
const $fakeSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'}, if (!PREF_CONTROLLER_FRIENDLY_UI) {
CE('option', {}, '---'), const $fakeSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'},
); CE('option', {}, '---'),
$div.appendChild($fakeSelect); );
$div.appendChild($fakeSelect);
}
const $select = $baseSelect.cloneNode(true) as HTMLSelectElement; const $select = $baseSelect.cloneNode(true) as HTMLSelectElement;
$select.dataset.button = button.toString(); $select.dataset.button = button.toString();
$select.addEventListener('change', onActionChanged); $select.addEventListener('input', onActionChanged);
ControllerShortcut.#$selectActions[button] = $select; ControllerShortcut.#$selectActions[button] = $select;
$div.appendChild($select); if (PREF_CONTROLLER_FRIENDLY_UI) {
const $bxSelect = BxSelectElement.wrap($select);
$div.appendChild($bxSelect);
setNearby($row, {
focus: $bxSelect,
});
} else {
$div.appendChild($select);
setNearby($row, {
focus: $select,
});
}
$row.appendChild($label); $row.appendChild($label);
$row.appendChild($div); $row.appendChild($div);

View File

@ -5,8 +5,9 @@ import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import type { BaseGameBarAction } from "./action-base"; import type { BaseGameBarAction } from "./action-base";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { PrefKey, getPref } from "@utils/preferences";
import { MicrophoneAction } from "./action-microphone"; import { MicrophoneAction } from "./action-microphone";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
export class GameBar { export class GameBar {
@ -41,7 +42,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

@ -1,8 +1,9 @@
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { getPreferredServerRegion } from "@utils/region"; import { getPreferredServerRegion } from "@utils/region";
import { PrefKey, getPref } from "@utils/preferences";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
export class LoadingScreen { export class LoadingScreen {
static #$bgStyle: HTMLElement; static #$bgStyle: HTMLElement;
@ -109,7 +110,7 @@ export class LoadingScreen {
let $waitTimeBox = LoadingScreen.#$waitTimeBox; let $waitTimeBox = LoadingScreen.#$waitTimeBox;
if (!$waitTimeBox) { if (!$waitTimeBox) {
$waitTimeBox = CE<HTMLElement>('div', {'class': 'bx-wait-time-box'}, $waitTimeBox = CE('div', {'class': 'bx-wait-time-box'},
CE('label', {}, t('server')), CE('label', {}, t('server')),
CE('span', {}, getPreferredServerRegion()), CE('span', {}, getPreferredServerRegion()),
CE('label', {}, t('wait-time-estimated')), CE('label', {}, t('wait-time-estimated')),

View File

@ -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,36 +1,32 @@
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 { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { t } from "@utils/translation"; 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 { SettingsNavigationDialog } from "../ui/dialog/settings-dialog";
import { NavigationDialogManager } from "../ui/dialog/navigation-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
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 +35,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 +53,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 +76,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 +89,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 +103,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 +120,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 +139,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 +164,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 +173,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 +272,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 +326,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 +371,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 +381,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);
} }
@ -418,8 +426,11 @@ export class MkbHandler {
this.#enabled = !this.#enabled; this.#enabled = !this.#enabled;
} }
Toast.show(t('mouse-and-keyboard'), t(this.#enabled ? 'enabled' : 'disabled'), {instant: true}); if (this.#enabled) {
this.#mouseDataProvider?.toggle(this.#enabled); document.body.requestPointerLock();
} else {
document.pointerLockElement && document.exitPointerLock();
}
} }
#getCurrentPreset = (): Promise<MkbStoredPreset> => { #getCurrentPreset = (): Promise<MkbStoredPreset> => {
@ -455,9 +466,100 @@ 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();
// Show Settings dialog & focus the MKB tab
const dialog = SettingsNavigationDialog.getInstance();
dialog.focusTab('mkb');
NavigationDialogManager.getInstance().show(dialog);
},
}),
),
),
);
}
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);
@ -467,48 +569,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);
if (!this.#$message) {
this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg'},
CE('div', {},
CE('p', {}, t('mkb-click-to-activate')),
CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})),
),
CE('div', {},
createButton({
icon: BxIcon.MOUSE_SETTINGS,
label: t('edit'),
style: ButtonStyle.PRIMARY,
onClick: e => {
e.preventDefault();
e.stopPropagation();
showStreamSettings('mkb');
},
}),
createButton({
label: t('disable'),
onClick: e => {
e.preventDefault();
e.stopPropagation();
this.toggle(false);
this.waitForMouseData(false);
},
}),
),
);
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.#$message.classList.add('bx-gone'); if (AppInterface) {
this.waitForMouseData(true); // 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 = () => {
@ -520,6 +603,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();
@ -529,17 +624,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
@ -550,36 +645,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,9 +1,9 @@
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { SettingElementType } from "@utils/settings"; import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "@enums/mkb";
import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "./definitions"; import { EmulatedMkbHandler } from "./mkb-handler";
import { MkbHandler } 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";
import { SettingElementType } from "@/utils/setting-element";
export class MkbPreset { export class MkbPreset {
@ -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,17 @@
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 { 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 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";
import { SettingElement } from "@/utils/setting-element";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
type MkbRemapperElements = { type MkbRemapperElements = {
@ -258,7 +259,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 +293,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;
} }
@ -317,7 +318,7 @@ export class MkbRemapper {
render() { render() {
this.#$.wrapper = CE('div', {'class': 'bx-mkb-settings'}); this.#$.wrapper = CE('div', {'class': 'bx-mkb-settings'});
this.#$.presetsSelect = CE<HTMLSelectElement>('select', {}); this.#$.presetsSelect = CE<HTMLSelectElement>('select', {tabindex: -1});
this.#$.presetsSelect!.addEventListener('change', e => { this.#$.presetsSelect!.addEventListener('change', e => {
this.#switchPreset(parseInt((e.target as HTMLSelectElement).value)); this.#switchPreset(parseInt((e.target as HTMLSelectElement).value));
}); });
@ -336,80 +337,84 @@ export class MkbRemapper {
}; };
const $header = CE('div', {'class': 'bx-mkb-preset-tools'}, const $header = CE('div', {'class': 'bx-mkb-preset-tools'},
this.#$.presetsSelect, this.#$.presetsSelect,
// Rename button // Rename button
createButton({ createButton({
title: t('rename'), title: t('rename'),
icon: BxIcon.CURSOR_TEXT, icon: BxIcon.CURSOR_TEXT,
onClick: e => { tabIndex: -1,
const preset = this.#getCurrentPreset(); onClick: e => {
const preset = this.#getCurrentPreset();
let newName = promptNewName(preset.name); let newName = promptNewName(preset.name);
if (!newName || newName === preset.name) { if (!newName || newName === preset.name) {
return;
}
// Update preset with new name
preset.name = newName;
LocalDb.INSTANCE.updatePreset(preset).then(id => this.#refresh());
},
}),
// New button
createButton({
icon: BxIcon.NEW,
title: t('new'),
tabIndex: -1,
onClick: e => {
let newName = promptNewName('');
if (!newName) {
return; return;
} }
// Update preset with new name // Create new preset selected name
preset.name = newName; LocalDb.INSTANCE.newPreset(newName, MkbPreset.DEFAULT_PRESET).then(id => {
LocalDb.INSTANCE.updatePreset(preset).then(id => this.#refresh()); this.#STATE.currentPresetId = id;
this.#refresh();
});
}, },
}), }),
// New button // Copy button
createButton({ createButton({
icon: BxIcon.NEW, icon: BxIcon.COPY,
title: t('new'), title: t('copy'),
onClick: e => { tabIndex: -1,
let newName = promptNewName(''); onClick: e => {
if (!newName) { const preset = this.#getCurrentPreset();
return;
}
// Create new preset selected name let newName = promptNewName(`${preset.name} (2)`);
LocalDb.INSTANCE.newPreset(newName, MkbPreset.DEFAULT_PRESET).then(id => { if (!newName) {
this.#STATE.currentPresetId = id; return;
this.#refresh(); }
});
},
}),
// Copy button // Create new preset selected name
createButton({ LocalDb.INSTANCE.newPreset(newName, preset.data).then(id => {
icon: BxIcon.COPY, this.#STATE.currentPresetId = id;
title: t('copy'), this.#refresh();
onClick: e => { });
const preset = this.#getCurrentPreset(); },
}),
let newName = promptNewName(`${preset.name} (2)`); // Delete button
if (!newName) { createButton({
return; icon: BxIcon.TRASH,
} style: ButtonStyle.DANGER,
title: t('delete'),
tabIndex: -1,
onClick: e => {
if (!confirm(t('confirm-delete-preset'))) {
return;
}
// Create new preset selected name LocalDb.INSTANCE.deletePreset(this.#STATE.currentPresetId).then(id => {
LocalDb.INSTANCE.newPreset(newName, preset.data).then(id => { this.#STATE.currentPresetId = 0;
this.#STATE.currentPresetId = id; this.#refresh();
this.#refresh(); });
}); },
}, }),
}), );
// Delete button
createButton({
icon: BxIcon.TRASH,
style: ButtonStyle.DANGER,
title: t('delete'),
onClick: e => {
if (!confirm(t('confirm-delete-preset'))) {
return;
}
LocalDb.INSTANCE.deletePreset(this.#STATE.currentPresetId).then(id => {
this.#STATE.currentPresetId = 0;
this.#refresh();
});
},
}),
);
this.#$.wrapper!.appendChild($header); this.#$.wrapper!.appendChild($header);
@ -426,11 +431,11 @@ export class MkbRemapper {
const $fragment = document.createDocumentFragment(); const $fragment = document.createDocumentFragment();
for (let i = 0; i < keysPerButton; i++) { for (let i = 0; i < keysPerButton; i++) {
$elm = CE('button', { $elm = CE('button', {
type: 'button', type: 'button',
'data-prompt': buttonPrompt, 'data-prompt': buttonPrompt,
'data-button-index': buttonIndex, 'data-button-index': buttonIndex,
'data-key-slot': i, 'data-key-slot': i,
}, ' '); }, ' ');
$elm.addEventListener('mouseup', this.#onBindingKey); $elm.addEventListener('mouseup', this.#onBindingKey);
$elm.addEventListener('contextmenu', this.#onContextMenu); $elm.addEventListener('contextmenu', this.#onContextMenu);
@ -440,9 +445,9 @@ export class MkbRemapper {
} }
const $keyRow = CE('div', {'class': 'bx-mkb-key-row'}, const $keyRow = CE('div', {'class': 'bx-mkb-key-row'},
CE('label', {'title': buttonName}, buttonPrompt), CE('label', {'title': buttonName}, buttonPrompt),
$fragment, $fragment,
); );
$rows.appendChild($keyRow); $rows.appendChild($keyRow);
} }
@ -460,10 +465,13 @@ export class MkbRemapper {
const onChange = (e: Event, value: any) => { const onChange = (e: Event, value: any) => {
(this.#STATE.editingPresetData!.mouse as any)[key] = value; (this.#STATE.editingPresetData!.mouse as any)[key] = value;
}; };
const $row = CE('div', {'class': 'bx-stream-settings-row'}, const $row = CE('label', {
CE('label', {'for': `bx_setting_${key}`}, setting.label), class: 'bx-settings-row',
$elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params), for: `bx_setting_${key}`
); },
CE('span', {class: 'bx-settings-label'}, setting.label),
$elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params),
);
$mouseSettings.appendChild($row); $mouseSettings.appendChild($row);
this.#$.allMouseElements[key as MkbPresetKey] = $elm; this.#$.allMouseElements[key as MkbPresetKey] = $elm;
@ -474,59 +482,63 @@ export class MkbRemapper {
// Render action buttons // Render action buttons
const $actionButtons = CE('div', {'class': 'bx-mkb-action-buttons'}, const $actionButtons = CE('div', {'class': 'bx-mkb-action-buttons'},
CE('div', {}, CE('div', {},
// Edit button // Edit button
createButton({ createButton({
label: t('edit'), label: t('edit'),
onClick: e => this.#toggleEditing(true), tabIndex: -1,
}), onClick: e => this.#toggleEditing(true),
}),
// Activate button // Activate button
this.#$.activateButton = createButton({ this.#$.activateButton = createButton({
label: t('activate'), label: t('activate'),
style: ButtonStyle.PRIMARY, style: ButtonStyle.PRIMARY,
onClick: e => { tabIndex: -1,
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId); onClick: e => {
MkbHandler.INSTANCE.refreshPresetData(); setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId);
EmulatedMkbHandler.getInstance().refreshPresetData();
this.#refresh(); this.#refresh();
}, },
}), }),
), ),
CE('div', {}, CE('div', {},
// Cancel button // Cancel button
createButton({ createButton({
label: t('cancel'), label: t('cancel'),
style: ButtonStyle.GHOST, style: ButtonStyle.GHOST,
onClick: e => { tabIndex: -1,
// Restore preset onClick: e => {
this.#switchPreset(this.#STATE.currentPresetId); // Restore preset
this.#toggleEditing(false); this.#switchPreset(this.#STATE.currentPresetId);
}, this.#toggleEditing(false);
}), },
}),
// Save button // Save button
createButton({ createButton({
label: t('save'), label: t('save'),
style: ButtonStyle.PRIMARY, style: ButtonStyle.PRIMARY,
onClick: e => { tabIndex: -1,
const updatedPreset = structuredClone(this.#getCurrentPreset()); onClick: e => {
updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData; const updatedPreset = deepClone(this.#getCurrentPreset());
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);
this.#refresh(); this.#refresh();
}); });
}, },
}), }),
), ),
); );
this.#$.wrapper!.appendChild($actionButtons); this.#$.wrapper!.appendChild($actionButtons);

View File

@ -0,0 +1,320 @@
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 } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
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

@ -1,21 +1,54 @@
import { SCRIPT_VERSION, STATES } from "@utils/global"; import { AppInterface, SCRIPT_VERSION, STATES } from "@utils/global";
import { BX_FLAGS } from "@utils/bx-flags"; import { BX_FLAGS } from "@utils/bx-flags";
import { getPref, PrefKey } from "@utils/preferences";
import { VibrationManager } from "@modules/vibration-manager"; import { VibrationManager } from "@modules/vibration-manager";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { hashCode, renderString } from "@utils/utils"; 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";
import { PrefKey } from "@/enums/pref-keys.js";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { GamePassCloudGallery } from "@/enums/game-pass-gallery.js";
type PatchArray = (keyof typeof PATCHES)[]; type PatchArray = (keyof typeof PATCHES)[];
const ENDING_CHUNKS_PATCH_NAME = 'loadingEndingChunks'; class PatcherUtils {
static indexOf(txt: string, searchString: string, startIndex: number, maxRange: number): number {
const index = txt.indexOf(searchString, startIndex);
if (index < 0 || (maxRange && index - startIndex > maxRange)) {
return -1;
}
return index;
}
static lastIndexOf(txt: string, searchString: string, startIndex: number, maxRange: number): number {
const index = txt.lastIndexOf(searchString, startIndex);
if (index < 0 || (maxRange && startIndex - index > maxRange)) {
return -1;
}
return index;
}
static insertAt(txt: string, index: number, insertString: string): string {
return txt.substring(0, index) + insertString + txt.substring(index);
}
static replaceWith(txt: string, index: number, fromString: string, toString: string): string {
return txt.substring(0, index) + toString + txt.substring(index + fromString.length);
}
}
const ENDING_CHUNKS_PATCH_NAME = 'loadingEndingChunks';
const LOG_TAG = 'Patcher'; const LOG_TAG = 'Patcher';
const PATCHES = { const PATCHES = {
@ -23,15 +56,15 @@ const PATCHES = {
disableAiTrack(str: string) { disableAiTrack(str: string) {
const text = '.track=function('; const text = '.track=function(';
const index = str.indexOf(text); const index = str.indexOf(text);
if (index === -1) { if (index < 0) {
return false; return false;
} }
if (str.substring(0, index + 200).includes('"AppInsightsCore')) { if (PatcherUtils.indexOf(str, '"AppInsightsCore', index, 200) < 0) {
return false; return false;
} }
return str.substring(0, index) + '.track=function(e){},!!function(' + str.substring(index + text.length); return PatcherUtils.replaceWith(str, index, text, '.track=function(e){},!!function(');
}, },
// Set disableTelemetry() to true // Set disableTelemetry() to true
@ -90,7 +123,7 @@ const PATCHES = {
// Replace "/direct-connect" with "/play" // Replace "/direct-connect" with "/play"
remotePlayDirectConnectUrl(str: string) { remotePlayDirectConnectUrl(str: string) {
const index = str.indexOf('/direct-connect'); const index = str.indexOf('/direct-connect');
if (index === -1) { if (index < 0) {
return false; return false;
} }
@ -134,16 +167,6 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
return str.replace(text, text + newCode); return str.replace(text, text + newCode);
}, },
// Disable trackEvent() function
disableTrackEvent(str: string) {
const text = 'this.trackEvent=';
if (!str.includes(text)) {
return false;
}
return str.replace(text, 'this.trackEvent=e=>{},this.uwuwu=');
},
// Block WebRTC stats collector // Block WebRTC stats collector
blockWebRtcStatsCollector(str: string) { blockWebRtcStatsCollector(str: string) {
const text = 'this.shouldCollectStats=!0'; const text = 'this.shouldCollectStats=!0';
@ -156,7 +179,7 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
patchPollGamepads(str: string) { patchPollGamepads(str: string) {
const index = str.indexOf('},this.pollGamepads=()=>{'); const index = str.indexOf('},this.pollGamepads=()=>{');
if (index === -1) { if (index < 0) {
return false; return false;
} }
@ -187,12 +210,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;
}, },
@ -221,19 +250,17 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
// Override website's settings // Override website's settings
overrideSettings(str: string) { overrideSettings(str: string) {
const index = str.indexOf(',EnableStreamGate:'); const index = str.indexOf(',EnableStreamGate:');
if (index === -1) { if (index < 0) {
return false; return false;
} }
// 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;
@ -241,7 +268,7 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
disableGamepadDisconnectedScreen(str: string) { disableGamepadDisconnectedScreen(str: string) {
const index = str.indexOf('"GamepadDisconnected_Title",'); const index = str.indexOf('"GamepadDisconnected_Title",');
if (index === -1) { if (index < 0) {
return false; return false;
} }
@ -278,7 +305,7 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
// Disable StreamGate // Disable StreamGate
disableStreamGate(str: string) { disableStreamGate(str: string) {
const index = str.indexOf('case"partially-ready":'); const index = str.indexOf('case"partially-ready":');
if (index === -1) { if (index < 0) {
return false; return false;
} }
@ -304,6 +331,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 < 0) {
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)) {
@ -413,10 +471,21 @@ BxEvent.dispatch(window, BxEvent.XCLOUD_POLLING_MODE_CHANGED, {mode: e});
return str; return str;
}, },
patchGamepadPolling(str: string) {
let index = str.indexOf('.shouldHandleGamepadInput)())return void');
if (index < 0) {
return false;
}
index = str.indexOf('{', index - 20) + 1;
str = str.substring(0, index) + 'if (window.BX_EXPOSED.disableGamepadPolling) return;' + str.substring(index);
return str;
},
patchXcloudTitleInfo(str: string) { patchXcloudTitleInfo(str: string) {
const text = 'async cloudConnect'; const text = 'async cloudConnect';
let index = str.indexOf(text); let index = str.indexOf(text);
if (index === -1) { if (index < 0) {
return false; return false;
} }
@ -438,7 +507,7 @@ BxLogger.info('patchXcloudTitleInfo', ${titleInfoVar});
patchRemotePlayMkb(str: string) { patchRemotePlayMkb(str: string) {
const text = 'async homeConsoleConnect'; const text = 'async homeConsoleConnect';
let index = str.indexOf(text); let index = str.indexOf(text);
if (index === -1) { if (index < 0) {
return false; return false;
} }
@ -535,20 +604,7 @@ window.dispatchEvent(new Event('${BxEvent.STREAM_EVENT_TARGET_READY}'))
} }
const newCode = `; const newCode = `;
window.BX_EXPOSED.streamSession = this; ${codeExposeStreamSession}
const orgSetMicrophoneState = this.setMicrophoneState.bind(this);
this.setMicrophoneState = state => {
orgSetMicrophoneState(state);
const evt = new Event('${BxEvent.MICROPHONE_STATE_CHANGED}');
evt.microphoneState = state;
window.dispatchEvent(evt);
};
window.dispatchEvent(new Event('${BxEvent.STREAM_SESSION_READY}'))
true` + text; true` + text;
str = str.replace(text, newCode); str = str.replace(text, newCode);
@ -564,19 +620,334 @@ true` + text;
str = str.replace(text, '&& false ' + text); str = str.replace(text, '&& false ' + text);
return str; 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 < 0) {
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 < 0) {
return false;
}
index = PatcherUtils.lastIndexOf(str, 'return', index, 50);
if (index < 0) {
return false;
}
str = PatcherUtils.replaceWith(str, index, 'return', 'return null;');
return str;
},
// Don't render "All Games" sections
ignoreAllGamesSection(str: string) {
let index = str.indexOf('className:"AllGamesRow-module__allGamesRowContainer');
if (index < 0) {
return false;
}
index = PatcherUtils.indexOf(str, 'grid:!0,', index, 1500);
if (index < 0) {
return false;
}
index = PatcherUtils.lastIndexOf(str, '(0,', index, 70);
if (index < 0) {
return false;
}
str = PatcherUtils.insertAt(str, index, 'true ? null :');
return str;
},
// home-page.js
ignorePlayWithTouchSection(str: string) {
let index = str.indexOf('("Play_With_Touch"),');
if (index < 0) {
return false;
}
index = PatcherUtils.lastIndexOf(str, 'const ', index, 30);
if (index < 0) {
return false;
}
str = PatcherUtils.insertAt(str, index, 'return null;');
return str;
},
// home-page.js
ignoreSiglSections(str: string) {
let index = str.indexOf('SiglRow-module__heroCard___');
if (index < 0) {
return false;
}
index = PatcherUtils.lastIndexOf(str, 'const[', index, 300);
if (index < 0) {
return false;
}
const PREF_HIDE_SECTIONS = getPref(PrefKey.UI_HIDE_SECTIONS) as UiSection[];
const siglIds: GamePassCloudGallery[] = [];
const sections: Partial<Record<UiSection, GamePassCloudGallery>> = {
[UiSection.NATIVE_MKB]: GamePassCloudGallery.NATIVE_MKB,
[UiSection.MOST_POPULAR]: GamePassCloudGallery.MOST_POPULAR,
};
PREF_HIDE_SECTIONS.forEach(section => {
const galleryId = sections[section];
galleryId && siglIds.push(galleryId);
});
const checkSyntax = siglIds.map(item => `siglId === "${item}"`).join(' || ');
const newCode = `
if (e && e.id) {
const siglId = e.id;
if (${checkSyntax}) {
return null;
}
}
`;
str = PatcherUtils.insertAt(str, index, newCode);
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 < 0) {
return false;
}
index = str.indexOf('&&(0,', index - 100);
if (index < 0) {
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 < 0) {
return false;
}
index = str.indexOf('{', index) + 1;
str = str.substring(0, index) + codeSetCurrentlyFocusedInteractable + str.substring(index);
return str;
},
// product-details-page.js#2388, 24.17.20
detectProductDetailsPage(str: string) {
let index = str.indexOf('{location:"ProductDetailPage",');
if (index < 0) {
return false;
}
index = str.indexOf('return', index - 40);
if (index < 0) {
return false;
}
str = str.substring(0, index) + 'BxEvent.dispatch(window, BxEvent.XCLOUD_RENDERING_COMPONENT, {component: "product-details"});' + str.substring(index);
return str;
},
detectBrowserRouterReady(str: string) {
const text = 'BrowserRouter:()=>';
if (!str.includes(text)) {
return false;
}
let index = str.indexOf('{history:this.history,');
if (index < 0) {
return false;
}
index = PatcherUtils.lastIndexOf(str, 'return', index, 100);
if (index < 0) {
return false;
}
str = PatcherUtils.insertAt(str, index, 'window.BxEvent.dispatch(window, window.BxEvent.XCLOUD_ROUTER_HISTORY_READY, {history: this.history});');
return str;
},
}; };
let PATCH_ORDERS: PatchArray = [ let PATCH_ORDERS: PatchArray = [
...(getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' ? [
'enableNativeMkb',
'patchMouseAndKeyboardEnabled',
'disableNativeRequestPointerLock',
'exposeInputSink',
] : []),
'detectBrowserRouterReady',
'patchRequestInfoCrash',
'disableStreamGate', 'disableStreamGate',
'overrideSettings', 'overrideSettings',
'broadcastPollingMode', 'broadcastPollingMode',
'patchGamepadPolling',
'exposeStreamSession', 'exposeStreamSession',
'exposeDialogRoutes',
'enableTvRoutes',
AppInterface && 'detectProductDetailsPage',
'overrideStorageGetSettings',
getPref(PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME) && '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.UI_HIDE_SECTIONS).includes(UiSection.TOUCH) && 'ignorePlayWithTouchSection',
(getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.NATIVE_MKB) || getPref(PrefKey.UI_HIDE_SECTIONS).includes(UiSection.MOST_POPULAR)) && 'ignoreSiglSections',
...(getPref(PrefKey.BLOCK_TRACKING) ? [ ...(getPref(PrefKey.BLOCK_TRACKING) ? [
'disableAiTrack', 'disableAiTrack',
'disableTelemetry', 'disableTelemetry',
@ -585,14 +956,13 @@ let PATCH_ORDERS: PatchArray = [
'disableIndexDbLogging', 'disableIndexDbLogging',
'disableTelemetryProvider', 'disableTelemetryProvider',
'disableTrackEvent',
] : []), ] : []),
...(getPref(PrefKey.REMOTE_PLAY_ENABLED) ? [ ...(getPref(PrefKey.REMOTE_PLAY_ENABLED) ? [
'remotePlayKeepAlive', 'remotePlayKeepAlive',
'remotePlayDirectConnectUrl', 'remotePlayDirectConnectUrl',
'remotePlayDisableAchievementToast', 'remotePlayDisableAchievementToast',
STATES.hasTouchSupport && 'patchUpdateInputConfigurationAsync', STATES.userAgent.capabilities.touch && 'patchUpdateInputConfigurationAsync',
] : []), ] : []),
...(BX_FLAGS.EnableXcloudLogging ? [ ...(BX_FLAGS.EnableXcloudLogging ? [
@ -608,6 +978,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
@ -618,11 +990,13 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
// Skip feedback dialog // Skip feedback dialog
getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog', getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog',
...(STATES.userAgent.capabilities.touch ? [
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager',
STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer', (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF) || !STATES.userAgent.capabilities.touch) && 'disableTakRenderer',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity', getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity',
'patchBabylonRendererClass',
] : []),
BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging', BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging',
@ -703,7 +1077,8 @@ export class Patcher {
} }
const func = item[1][id]; const func = item[1][id];
let str = func.toString(); const funcStr = func.toString();
let patchedFuncStr = funcStr;
let modified = false; let modified = false;
@ -718,15 +1093,15 @@ export class Patcher {
} }
// Check function against patch // Check function against patch
const patchedStr = PATCHES[patchName].call(null, str); const tmpStr = PATCHES[patchName].call(null, patchedFuncStr);
// Not patched // Not patched
if (!patchedStr) { if (!tmpStr) {
continue; continue;
} }
modified = true; modified = true;
str = patchedStr; patchedFuncStr = tmpStr;
BxLogger.info(LOG_TAG, `${patchName}`); BxLogger.info(LOG_TAG, `${patchName}`);
appliedPatches.push(patchName); appliedPatches.push(patchName);
@ -739,7 +1114,13 @@ export class Patcher {
// Apply patched functions // Apply patched functions
if (modified) { if (modified) {
item[1][id] = eval(str); try {
item[1][id] = eval(patchedFuncStr);
} catch (e: unknown) {
if (e instanceof Error) {
BxLogger.error(LOG_TAG, 'Error', appliedPatches, e.message, patchedFuncStr);
}
}
} }
// Save to cache // Save to cache

View File

@ -1,3 +1,8 @@
if (window.BX_EXPOSED.disableGamepadPolling) {
this.inputConfiguration.useIntervalWorkerThreadForInput && this.intervalWorker ? this.intervalWorker.scheduleTimer(50) : this.pollGamepadssetTimeoutTimerID = setTimeout(this.pollGamepads, 50);
return;
}
const currentGamepad = ${gamepadVar}; const currentGamepad = ${gamepadVar};
// Share button on XS controller // Share button on XS controller

View File

@ -0,0 +1,33 @@
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 (updateDimensionsStr.startsWith('function ')) {
updateDimensionsStr = updateDimensionsStr.substring(9);
}
// 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

@ -1,6 +1,10 @@
let match; let match;
let onGamepadChangedStr = this.onGamepadChanged.toString(); let onGamepadChangedStr = this.onGamepadChanged.toString();
if (onGamepadChangedStr.startsWith('function ')) {
onGamepadChangedStr = onGamepadChangedStr.substring(9);
}
onGamepadChangedStr = onGamepadChangedStr.replaceAll('0', 'arguments[1]'); onGamepadChangedStr = onGamepadChangedStr.replaceAll('0', 'arguments[1]');
eval(`this.onGamepadChanged = function ${onGamepadChangedStr}`); eval(`this.onGamepadChanged = function ${onGamepadChangedStr}`);

View File

@ -0,0 +1 @@
e && BxEvent.dispatch(window, BxEvent.NAVIGATION_FOCUS_CHANGED, {element: e});

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,254 @@
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";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
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() {
BxLogger.info(LOG_TAG, 'Setting up', getPref(PrefKey.VIDEO_POWER_PREFERENCE));
const gl = this.#$canvas.getContext('webgl2', {
isBx: true,
antialias: true,
alpha: false,
powerPreference: getPref(PrefKey.VIDEO_POWER_PREFERENCE),
}) 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

@ -3,14 +3,16 @@ import { CE, createButton, ButtonStyle } from "@utils/html";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
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";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'RemotePlay'; const LOG_TAG = 'RemotePlay';
enum RemotePlayConsoleState { const enum RemotePlayConsoleState {
ON = 'On', ON = 'On',
OFF = 'Off', OFF = 'Off',
STANDBY = 'ConnectedStandby', STANDBY = 'ConnectedStandby',
@ -52,8 +54,8 @@ export class RemotePlay {
env: { env: {
clientAppId: window.location.host, clientAppId: window.location.host,
clientAppType: 'browser', clientAppType: 'browser',
clientAppVersion: '21.1.98', clientAppVersion: '24.17.36',
clientSdkVersion: '8.5.3', clientSdkVersion: '10.1.14',
httpEnvironment: 'prod', httpEnvironment: 'prod',
sdkInstallId: '', sdkInstallId: '',
}, },
@ -81,7 +83,7 @@ export class RemotePlay {
}, },
browser: { browser: {
browserName: 'chrome', browserName: 'chrome',
browserVersion: '119.0', browserVersion: '125.0',
}, },
}, },
}; };
@ -97,6 +99,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

@ -1,9 +1,9 @@
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { PrefKey, getPref, setPref } from "@utils/preferences";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { BxEvent } from "@/utils/bx-event";
import { ceilToNearest, floorToNearest } from "@/utils/utils"; import { ceilToNearest, floorToNearest } from "@/utils/utils";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
export class SoundShortcut { export class SoundShortcut {
static adjustGainNodeVolume(amount: number): number { static adjustGainNodeVolume(amount: number): number {
@ -27,14 +27,11 @@ export class SoundShortcut {
newValue = currentValue + amount; newValue = currentValue + amount;
} }
newValue = setPref(PrefKey.AUDIO_VOLUME, newValue); newValue = setPref(PrefKey.AUDIO_VOLUME, newValue, true);
SoundShortcut.setGainNodeVolume(newValue); SoundShortcut.setGainNodeVolume(newValue);
// Show toast // Show toast
Toast.show(`${t('stream')} ${t('volume')}`, newValue + '%', {instant: true}); Toast.show(`${t('stream')} ${t('volume')}`, newValue + '%', {instant: true});
BxEvent.dispatch(window, BxEvent.GAINNODE_VOLUME_CHANGED, {
volume: newValue,
});
return newValue; return newValue;
} }
@ -51,10 +48,7 @@ export class SoundShortcut {
let targetValue: number; let targetValue: number;
if (settingValue === 0) { // settingValue is 0 => set to 100 if (settingValue === 0) { // settingValue is 0 => set to 100
targetValue = 100; targetValue = 100;
setPref(PrefKey.AUDIO_VOLUME, targetValue); setPref(PrefKey.AUDIO_VOLUME, targetValue, true);
BxEvent.dispatch(window, BxEvent.GAINNODE_VOLUME_CHANGED, {
volume: targetValue,
});
} else if (gainValue === 0) { // is being muted => set to settingValue } else if (gainValue === 0) { // is being muted => set to settingValue
targetValue = settingValue; targetValue = settingValue;
} else { // not being muted => mute } else { // not being muted => mute

View File

@ -0,0 +1,280 @@
import { CE } from "@/utils/html";
import { WebGL2Player } from "./player/webgl2-player";
import { Screenshot } from "@/utils/screenshot";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { STATES } from "@/utils/global";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
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 (this.#options.processing === StreamVideoProcessing.USM && 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();
}
reloadPlayer() {
this.#cleanUpWebGL2Player();
this.#playerType = StreamPlayerType.VIDEO;
this.setPlayerType(StreamPlayerType.WEBGL2, false);
}
#cleanUpWebGL2Player() {
// Clean up WebGL2 Player
this.#webGL2Player?.destroy();
this.#webGL2Player = null;
}
destroy() {
this.#cleanUpWebGL2Player();
}
}

View File

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

View File

@ -0,0 +1,65 @@
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { STATES } from "@utils/global";
import { UserAgent } from "@utils/user-agent";
import type { StreamPlayerOptions } from "../stream-player";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
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;
const $videoPowerPreference = document.getElementById('bx_setting_video_power_preference') as HTMLElement;
if (!$videoProcessing) {
return;
}
let isDisabled = false;
const $optCas = $videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement;
if (playerType === StreamPlayerType.WEBGL2) {
$optCas && ($optCas.disabled = false);
} else {
// Only allow USM when player type is Video
$videoProcessing.value = StreamVideoProcessing.USM;
setPref(PrefKey.VIDEO_PROCESSING, StreamVideoProcessing.USM);
$optCas && ($optCas.disabled = true);
if (UserAgent.isSafari()) {
isDisabled = true;
}
}
$videoProcessing.disabled = isDisabled;
$videoSharpness.dataset.disabled = isDisabled.toString();
// Hide Power Preference setting if renderer isn't WebGL2
$videoPowerPreference.closest('.bx-settings-row')!.classList.toggle('bx-gone', playerType !== StreamPlayerType.WEBGL2);
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

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

View File

@ -1,10 +1,11 @@
import { STATES } from "@utils/global.ts"; import { STATES } from "@utils/global.ts";
import { ButtonStyle, createButton, 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, XcloudGuideWhere } from "@utils/bx-event.ts"; import { BxEvent } from "@utils/bx-event.ts";
import { t } from "@utils/translation.ts"; import { t } from "@utils/translation.ts";
import { StreamBadges } from "./stream-badges.ts"; import { StreamBadges } from "./stream-badges.ts";
import { StreamStats } from "./stream-stats.ts"; import { StreamStats } from "./stream-stats.ts";
import { SettingsNavigationDialog } from "../ui/dialog/settings-dialog.ts";
function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) { function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) {
@ -34,7 +35,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t
} }
}; };
if (STATES.browserHasTouchSupport) { if (STATES.browser.capabilities.touch) {
$container.addEventListener('transitionstart', onTransitionStart); $container.addEventListener('transitionstart', onTransitionStart);
$container.addEventListener('transitionend', onTransitionEnd); $container.addEventListener('transitionend', onTransitionEnd);
} }
@ -87,27 +88,6 @@ export function injectStreamMenuButtons() {
($screen as any).xObserving = true; ($screen as any).xObserving = true;
const $settingsDialog = document.querySelector('.bx-stream-settings-dialog')!;
const $parent = $screen.parentElement;
const hideSettingsFunc = (e?: MouseEvent | TouchEvent) => {
if (e) {
const $target = e.target as HTMLElement;
e.stopPropagation();
if ($target != $parent && $target.id !== 'MultiTouchSurface' && !$target.querySelector('#BabylonCanvasContainer-main')) {
return;
}
if ($target.id === 'MultiTouchSurface') {
$target.removeEventListener('touchstart', hideSettingsFunc);
}
}
// Hide Stream settings dialog
$settingsDialog.classList.add('bx-gone');
$parent?.removeEventListener('click', hideSettingsFunc);
// $parent.removeEventListener('touchstart', hideSettingsFunc);
}
let $btnStreamSettings: HTMLElement; let $btnStreamSettings: HTMLElement;
let $btnStreamStats: HTMLElement; let $btnStreamStats: HTMLElement;
const streamStats = StreamStats.getInstance(); const streamStats = StreamStats.getInstance();
@ -143,11 +123,6 @@ export function injectStreamMenuButtons() {
return; return;
} }
// Hide Stream Settings dialog when closing HUD
$btnCloseHud.addEventListener('click', e => {
$settingsDialog.classList.add('bx-gone');
});
// Create Refresh button from the Close button // Create Refresh button from the Close button
const $btnRefresh = cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => { const $btnRefresh = cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => {
confirm(t('confirm-reload-stream')) && window.location.reload(); confirm(t('confirm-reload-stream')) && window.location.reload();
@ -165,7 +140,6 @@ export function injectStreamMenuButtons() {
const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]');
$menu?.appendChild(await StreamBadges.getInstance().render()); $menu?.appendChild(await StreamBadges.getInstance().render());
hideSettingsFunc();
return; return;
} }
@ -199,19 +173,13 @@ export function injectStreamMenuButtons() {
// Create Stream Settings button // Create Stream Settings button
if (!$btnStreamSettings) { if (!$btnStreamSettings) {
$btnStreamSettings = cloneStreamHudButton($orgButton, t('stream-settings'), BxIcon.STREAM_SETTINGS); $btnStreamSettings = cloneStreamHudButton($orgButton, t('better-xcloud'), BxIcon.BETTER_XCLOUD);
$btnStreamSettings.addEventListener('click', e => { $btnStreamSettings.addEventListener('click', e => {
hideGripHandle(); hideGripHandle();
e.preventDefault(); e.preventDefault();
// Show Stream Settings dialog // Show Stream Settings dialog
$settingsDialog.classList.remove('bx-gone'); SettingsNavigationDialog.getInstance().show();
$parent?.addEventListener('click', hideSettingsFunc);
//$parent.addEventListener('touchstart', hideSettingsFunc);
const $touchSurface = document.getElementById('MultiTouchSurface');
$touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideSettingsFunc);
}); });
} }
@ -249,77 +217,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);
}
}
export function setupStreamUiEvents() {
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 buttons
const $btnReload = createButton({
label: t('reload-stream'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('confirm-reload-stream')) && window.location.reload();
},
});
const $btnHome = createButton({
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));
},
});
$btnQuit.insertAdjacentElement('afterend', $btnReload);
$btnReload.insertAdjacentElement('afterend', $btnHome);
// Hide xCloud's Home button
const $btnXcloudHome = document.querySelector('#gamepass-dialog-root div[class^=HomeButtonWithDivider]') as HTMLElement;
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
});
}

View File

@ -1,14 +1,27 @@
import { STATES } from "@utils/global";
import { escapeHtml } from "@utils/html"; import { escapeHtml } from "@utils/html";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS, NATIVE_FETCH } from "@utils/bx-flags"; import { NATIVE_FETCH } from "@utils/bx-flags";
import { getPref, PrefKey } from "@utils/preferences";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
const LOG_TAG = 'TouchController'; const LOG_TAG = 'TouchController';
type TouchControlLayout = {
name: string,
author: string,
content: any,
};
type TouchControlDefinition = {
name: string,
product_id: string,
default_layout: string,
layouts: Record<string, TouchControlLayout>,
};
export class TouchController { export class TouchController {
static readonly #EVENT_SHOW_DEFAULT_CONTROLLER = new MessageEvent('message', { static readonly #EVENT_SHOW_DEFAULT_CONTROLLER = new MessageEvent('message', {
data: JSON.stringify({ data: JSON.stringify({
@ -28,25 +41,40 @@ export class TouchController {
static #$style: HTMLStyleElement; static #$style: HTMLStyleElement;
static #enable = false; static #enabled = false;
static #dataChannel: RTCDataChannel | null; static #dataChannel: RTCDataChannel | null;
static #customLayouts: {[index: string]: any} = {}; static #customLayouts: Record<string, TouchControlDefinition | null> = {};
static #baseCustomLayouts: {[index: string]: any} = {}; static #baseCustomLayouts: Record<string, Record<string, TouchControlLayout>> = {};
static #currentLayoutId: string; static #currentLayoutId: string;
static #customList: string[]; static #customList: string[];
static #xboxTitleId: string | null = null;
static setXboxTitleId(xboxTitleId: string) {
TouchController.#xboxTitleId = xboxTitleId;
}
static getCustomLayouts() {
const xboxTitleId = TouchController.#xboxTitleId;
if (!xboxTitleId) {
return null;
}
return TouchController.#customLayouts[xboxTitleId];
}
static enable() { static enable() {
TouchController.#enable = true; TouchController.#enabled = true;
} }
static disable() { static disable() {
TouchController.#enable = false; TouchController.#enabled = false;
} }
static isEnabled() { static isEnabled() {
return TouchController.#enable; return TouchController.#enabled;
} }
static #showDefault() { static #showDefault() {
@ -70,8 +98,9 @@ export class TouchController {
} }
static reset() { static reset() {
TouchController.#enable = false; TouchController.#enabled = false;
TouchController.#dataChannel = null; TouchController.#dataChannel = null;
TouchController.#xboxTitleId = null;
TouchController.#$style && (TouchController.#$style.textContent = ''); TouchController.#$style && (TouchController.#$style.textContent = '');
} }
@ -83,12 +112,18 @@ export class TouchController {
} }
static #dispatchLayouts(data: any) { static #dispatchLayouts(data: any) {
BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED, { // Load default layout
data: data, TouchController.applyCustomLayout(null, 1000);
});
BxEvent.dispatch(window, BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED);
}; };
static async getCustomLayouts(xboxTitleId: string, retries: number=1) { static async requestCustomLayouts(retries: number=1) {
const xboxTitleId = TouchController.#xboxTitleId;
if (!xboxTitleId) {
return;
}
if (xboxTitleId in TouchController.#customLayouts) { if (xboxTitleId in TouchController.#customLayouts) {
TouchController.#dispatchLayouts(TouchController.#customLayouts[xboxTitleId]); TouchController.#dispatchLayouts(TouchController.#customLayouts[xboxTitleId]);
return; return;
@ -102,7 +137,7 @@ export class TouchController {
return; return;
} }
const baseUrl = `https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts${BX_FLAGS.UseDevTouchLayout ? '/dev' : ''}`; const baseUrl = 'https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts';
const url = `${baseUrl}/${xboxTitleId}.json`; const url = `${baseUrl}/${xboxTitleId}.json`;
// Get layout info // Get layout info
@ -137,17 +172,17 @@ export class TouchController {
window.setTimeout(() => TouchController.#dispatchLayouts(json), 1000); window.setTimeout(() => TouchController.#dispatchLayouts(json), 1000);
} catch (e) { } catch (e) {
// Retry // Retry
TouchController.getCustomLayouts(xboxTitleId, retries + 1); TouchController.requestCustomLayouts(retries + 1);
} }
} }
static loadCustomLayout(xboxTitleId: string, layoutId: string, delay: number=0) { static applyCustomLayout(layoutId: string | null, delay: number=0) {
// TODO: fix this // TODO: fix this
if (!window.BX_EXPOSED.touchLayoutManager) { if (!window.BX_EXPOSED.touchLayoutManager) {
const listener = (e: Event) => { const listener = (e: Event) => {
window.removeEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener); window.removeEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener);
if (TouchController.#enable) { if (TouchController.#enabled) {
TouchController.loadCustomLayout(xboxTitleId, layoutId, 0); TouchController.applyCustomLayout(layoutId, 0);
} }
}; };
window.addEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener); window.addEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener);
@ -155,13 +190,30 @@ export class TouchController {
return; return;
} }
const xboxTitleId = TouchController.#xboxTitleId;
if (!xboxTitleId) {
BxLogger.error(LOG_TAG, 'Invalid xboxTitleId');
return;
}
if (!layoutId) {
// Get default layout ID from definition
layoutId = TouchController.#customLayouts[xboxTitleId]?.default_layout || null;
}
if (!layoutId) {
BxLogger.error(LOG_TAG, 'Invalid layoutId, show default controller');
TouchController.#enabled && TouchController.#showDefault();
return;
}
const layoutChanged = TouchController.#currentLayoutId !== layoutId; const layoutChanged = TouchController.#currentLayoutId !== layoutId;
TouchController.#currentLayoutId = layoutId; TouchController.#currentLayoutId = layoutId;
// Get layout data // Get layout data
const layoutData = TouchController.#customLayouts[xboxTitleId]; const layoutData = TouchController.#customLayouts[xboxTitleId];
if (!xboxTitleId || !layoutId || !layoutData) { if (!xboxTitleId || !layoutId || !layoutData) {
TouchController.#enable && TouchController.#showDefault(); TouchController.#enabled && TouchController.#showDefault();
return; return;
} }
@ -223,7 +275,7 @@ export class TouchController {
touchLayoutManager && touchLayoutManager.changeLayoutForScope({ touchLayoutManager && touchLayoutManager.changeLayoutForScope({
type: 'showLayout', type: 'showLayout',
scope: '' + STATES.currentStream?.xboxTitleId, scope: '' + TouchController.#xboxTitleId,
subscope: 'base', subscope: 'base',
layout: { layout: {
id: 'System.Standard', id: 'System.Standard',
@ -249,7 +301,7 @@ export class TouchController {
// Apply touch controller's style // Apply touch controller's style
let filter = ''; let filter = '';
if (TouchController.#enable) { if (TouchController.#enabled) {
if (PREF_STYLE_STANDARD === 'white') { if (PREF_STYLE_STANDARD === 'white') {
filter = 'grayscale(1) brightness(2)'; filter = 'grayscale(1) brightness(2)';
} else if (PREF_STYLE_STANDARD === 'muted') { } else if (PREF_STYLE_STANDARD === 'muted') {
@ -280,9 +332,9 @@ export class TouchController {
// Dispatch a message to display generic touch controller // Dispatch a message to display generic touch controller
if (msg.data.includes('touchcontrols/showtitledefault')) { if (msg.data.includes('touchcontrols/showtitledefault')) {
if (TouchController.#enable) { if (TouchController.#enabled) {
if (focused) { if (focused) {
TouchController.getCustomLayouts(STATES.currentStream?.xboxTitleId!); TouchController.requestCustomLayouts();
} else { } else {
TouchController.#showDefault(); TouchController.#showDefault();
} }
@ -300,7 +352,7 @@ export class TouchController {
TouchController.#show(); TouchController.#show();
} }
STATES.currentStream.xboxTitleId = parseInt(json.titleid, 16).toString(); TouchController.setXboxTitleId(parseInt(json.titleid, 16).toString());
} }
} catch (e) { } catch (e) {
BxLogger.error(LOG_TAG, 'Load custom layout', e); BxLogger.error(LOG_TAG, 'Load custom layout', e);

View File

@ -0,0 +1,608 @@
import { GamepadKey } from "@/enums/mkb";
import { EmulatedMkbHandler } from "@/modules/mkb/mkb-handler";
import { BxEvent } from "@/utils/bx-event";
import { STATES } from "@/utils/global";
import { CE } from "@/utils/html";
import { setNearby } from "@/utils/navigation-utils";
export enum NavigationDirection {
UP = 1,
RIGHT,
DOWN,
LEFT,
}
export type NavigationNearbyElements = Partial<{
orientation: 'horizontal' | 'vertical',
selfOrientation: 'horizontal' | 'vertical',
focus: NavigationElement | (() => boolean),
loop: ((direction: NavigationDirection) => boolean),
[NavigationDirection.UP]: NavigationElement | (() => void) | 'previous' | 'next',
[NavigationDirection.DOWN]: NavigationElement | (() => void) | 'previous' | 'next',
[NavigationDirection.LEFT]: NavigationElement | (() => void) | 'previous' | 'next',
[NavigationDirection.RIGHT]: NavigationElement | (() => void) | 'previous' | 'next',
}>;
export interface NavigationElement extends HTMLElement {
nearby?: NavigationNearbyElements;
}
export abstract class NavigationDialog {
abstract getDialog(): NavigationDialog;
abstract getContent(): HTMLElement;
abstract focusIfNeeded(): void;
abstract $container: HTMLElement;
dialogManager: NavigationDialogManager;
constructor() {
this.dialogManager = NavigationDialogManager.getInstance();
}
show() {
NavigationDialogManager.getInstance().show(this);
const $currentFocus = this.getFocusedElement();
// If not focusing on any element
if (!$currentFocus) {
this.focusIfNeeded();
}
}
hide() {
NavigationDialogManager.getInstance().hide();
}
getFocusedElement() {
const $activeElement = document.activeElement as HTMLElement;
if (!$activeElement) {
return null;
}
// Check if focused element is a child of dialog
if (this.$container.contains($activeElement)) {
return $activeElement;
}
return null;
}
onBeforeMount(): void {}
onMounted(): void {}
onBeforeUnmount(): void {}
onUnmounted(): void {}
handleKeyPress(key: string): boolean {
return false;
}
handleGamepad(button: GamepadKey): boolean {
return true;
}
}
export class NavigationDialogManager {
private static instance: NavigationDialogManager;
public static getInstance(): NavigationDialogManager {
if (!NavigationDialogManager.instance) {
NavigationDialogManager.instance = new NavigationDialogManager();
}
return NavigationDialogManager.instance;
}
private static readonly GAMEPAD_POLLING_INTERVAL = 50;
private static readonly GAMEPAD_KEYS = [
GamepadKey.UP,
GamepadKey.DOWN,
GamepadKey.LEFT,
GamepadKey.RIGHT,
GamepadKey.A,
GamepadKey.B,
GamepadKey.LB,
GamepadKey.RB,
GamepadKey.LT,
GamepadKey.RT,
];
private static readonly GAMEPAD_DIRECTION_MAP = {
[GamepadKey.UP]: NavigationDirection.UP,
[GamepadKey.DOWN]: NavigationDirection.DOWN,
[GamepadKey.LEFT]: NavigationDirection.LEFT,
[GamepadKey.RIGHT]: NavigationDirection.RIGHT,
[GamepadKey.LS_UP]: NavigationDirection.UP,
[GamepadKey.LS_DOWN]: NavigationDirection.DOWN,
[GamepadKey.LS_LEFT]: NavigationDirection.LEFT,
[GamepadKey.LS_RIGHT]: NavigationDirection.RIGHT,
};
private static readonly SIBLING_PROPERTY_MAP = {
'horizontal': {
[NavigationDirection.LEFT]: 'previousElementSibling',
[NavigationDirection.RIGHT]: 'nextElementSibling',
},
'vertical': {
[NavigationDirection.UP]: 'previousElementSibling',
[NavigationDirection.DOWN]: 'nextElementSibling',
},
};
private gamepadPollingIntervalId: number | null = null;
private gamepadLastStates: Array<[number, GamepadKey, boolean] | null> = [];
private gamepadHoldingIntervalId: number | null = null;
private $overlay: HTMLElement;
private $container: HTMLElement;
private dialog: NavigationDialog | null = null;
constructor() {
this.$overlay = CE('div', {class: 'bx-navigation-dialog-overlay bx-gone'});
this.$overlay.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
this.hide();
});
document.documentElement.appendChild(this.$overlay);
this.$container = CE('div', {class: 'bx-navigation-dialog bx-gone'});
document.documentElement.appendChild(this.$container);
// Hide dialog when the Guide menu is shown
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, e => this.hide());
}
handleEvent(event: Event) {
switch (event.type) {
case 'keydown':
const $target = event.target as HTMLElement;
const keyboardEvent = event as KeyboardEvent;
const keyCode = keyboardEvent.code || keyboardEvent.key;
let handled = this.dialog?.handleKeyPress(keyCode);
if (handled) {
event.preventDefault();
event.stopPropagation();
return;
}
if (keyCode === 'ArrowUp' || keyCode === 'ArrowDown') {
handled = true;
this.focusDirection(keyCode === 'ArrowUp' ? NavigationDirection.UP : NavigationDirection.DOWN);
} else if (keyCode === 'ArrowLeft' || keyCode === 'ArrowRight') {
if (!($target instanceof HTMLInputElement && ($target.type === 'text' || $target.type === 'range'))) {
handled = true;
this.focusDirection(keyCode === 'ArrowLeft' ? NavigationDirection.LEFT : NavigationDirection.RIGHT);
}
} else if (keyCode === 'Enter' || keyCode === 'NumpadEnter' || keyCode === 'Space') {
if (!($target instanceof HTMLInputElement && $target.type === 'text')) {
handled = true;
$target.dispatchEvent(new MouseEvent('click'));
}
} else if (keyCode === 'Escape') {
handled = true;
this.hide();
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
break;
}
}
isShowing() {
return this.$container && !this.$container.classList.contains('bx-gone');
}
private pollGamepad() {
const gamepads = window.navigator.getGamepads();
for (const gamepad of gamepads) {
if (!gamepad || !gamepad.connected) {
continue;
}
// Ignore virtual controller
if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
continue;
}
const axes = gamepad.axes;
const buttons = gamepad.buttons;
let releasedButton: GamepadKey | null = null;
let heldButton: GamepadKey | null = null;
let lastState = this.gamepadLastStates[gamepad.index];
let lastTimestamp;
let lastKey;
let lastKeyPressed;
if (lastState) {
[lastTimestamp, lastKey, lastKeyPressed] = lastState;
}
if (lastTimestamp && lastTimestamp === gamepad.timestamp) {
continue;
}
for (const key of NavigationDialogManager.GAMEPAD_KEYS) {
// Key released
if (lastKey === key && !buttons[key].pressed) {
releasedButton = key;
break;
} else if (buttons[key].pressed) {
// Key pressed
heldButton = key;
break;
}
}
// If not pressing any key => check analog sticks
if (heldButton === null && releasedButton === null && axes && axes.length >= 2) {
// [LEFT left-right, LEFT up-down]
if (lastKey) {
const releasedHorizontal = Math.abs(axes[0]) < 0.1 && (lastKey === GamepadKey.LS_LEFT || lastKey === GamepadKey.LS_RIGHT);
const releasedVertical = Math.abs(axes[1]) < 0.1 && (lastKey === GamepadKey.LS_UP || lastKey === GamepadKey.LS_DOWN);
if (releasedHorizontal || releasedVertical) {
releasedButton = lastKey;
} else {
heldButton = lastKey;
}
} else {
if (axes[0] < -0.5) {
heldButton = GamepadKey.LS_LEFT;
} else if (axes[0] > 0.5) {
heldButton = GamepadKey.LS_RIGHT;
} else if (axes[1] < -0.5) {
heldButton = GamepadKey.LS_UP;
} else if (axes[1] > 0.5) {
heldButton = GamepadKey.LS_DOWN;
}
}
}
// Save state if holding a button
if (heldButton !== null) {
this.gamepadLastStates[gamepad.index] = [gamepad.timestamp, heldButton, false];
this.clearGamepadHoldingInterval();
// Only set turbo for d-pad and stick
if (NavigationDialogManager.GAMEPAD_DIRECTION_MAP[heldButton as keyof typeof NavigationDialogManager.GAMEPAD_DIRECTION_MAP]) {
this.gamepadHoldingIntervalId = window.setInterval(() => {
const lastState = this.gamepadLastStates[gamepad.index];
// Avoid pressing the incorrect key
if (lastState) {
[lastTimestamp, lastKey, lastKeyPressed] = lastState;
if (lastKey === heldButton) {
this.handleGamepad(gamepad, heldButton);
return;
}
}
this.clearGamepadHoldingInterval();
}, 200);
}
continue;
}
// Continue if the button hasn't been released
if (releasedButton === null) {
this.clearGamepadHoldingInterval();
continue;
}
// Button released
this.gamepadLastStates[gamepad.index] = null;
if (lastKeyPressed) {
return;
}
if (releasedButton === GamepadKey.A) {
document.activeElement && document.activeElement.dispatchEvent(new MouseEvent('click'));
return;
} else if (releasedButton === GamepadKey.B) {
this.hide();
return;
}
if (this.handleGamepad(gamepad, releasedButton)) {
return;
}
}
}
private handleGamepad(gamepad: Gamepad, key: GamepadKey): boolean {
let handled = this.dialog?.handleGamepad(key);
if (handled) {
return true;
}
// Handle d-pad & sticks
let direction = NavigationDialogManager.GAMEPAD_DIRECTION_MAP[key as keyof typeof NavigationDialogManager.GAMEPAD_DIRECTION_MAP];
if (!direction) {
return false;
}
if (document.activeElement instanceof HTMLInputElement && document.activeElement.type === 'range') {
const $range = document.activeElement;
if (direction === NavigationDirection.LEFT || direction === NavigationDirection.RIGHT) {
$range.value = (parseInt($range.value) + parseInt($range.step) * (direction === NavigationDirection.LEFT ? -1 : 1)).toString();
$range.dispatchEvent(new InputEvent('input'));
handled = true;
}
}
if (!handled) {
this.focusDirection(direction);
}
this.gamepadLastStates[gamepad.index] && (this.gamepadLastStates[gamepad.index]![2] = true);
return true;
}
private clearGamepadHoldingInterval() {
this.gamepadHoldingIntervalId && window.clearInterval(this.gamepadHoldingIntervalId);
this.gamepadHoldingIntervalId = null;
}
show(dialog: NavigationDialog) {
this.clearGamepadHoldingInterval();
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_SHOWN);
// Stop xCloud's navigation polling
(window as any).BX_EXPOSED.disableGamepadPolling = true;
// Lock scroll bar
document.body.classList.add('bx-no-scroll');
// Show overlay
this.$overlay.classList.remove('bx-gone');
if (STATES.isPlaying) {
this.$overlay.classList.add('bx-invisible');
}
// Unmount current dialog
this.unmountCurrentDialog();
// Setup new dialog
this.dialog = dialog;
dialog.onBeforeMount();
this.$container.appendChild(dialog.getContent());
dialog.onMounted();
// Show content
this.$container.classList.remove('bx-gone');
// Add event listeners
this.$container.addEventListener('keydown', this);
// Start gamepad polling
this.startGamepadPolling();
}
hide() {
this.clearGamepadHoldingInterval();
// Unlock scroll bar
document.body.classList.remove('bx-no-scroll');
BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_DISMISSED);
// Hide content
this.$overlay.classList.add('bx-gone');
this.$overlay.classList.remove('bx-invisible');
this.$container.classList.add('bx-gone');
// Remove event listeners
this.$container.removeEventListener('keydown', this);
// Stop gamepad polling
this.stopGamepadPolling();
// Unmount dialog
this.unmountCurrentDialog();
// Enable xCloud's navigation polling
(window as any).BX_EXPOSED.disableGamepadPolling = false;
}
focus($elm: NavigationElement | null): boolean {
if (!$elm) {
return false;
}
// console.log('focus', $elm);
if ($elm.nearby && $elm.nearby.focus) {
if ($elm.nearby.focus instanceof HTMLElement) {
return this.focus($elm.nearby.focus);
} else {
return $elm.nearby.focus();
}
}
$elm.focus();
return $elm === document.activeElement;
}
private getOrientation($elm: NavigationElement): NavigationNearbyElements['orientation'] {
const nearby = $elm.nearby || {};
if (nearby.selfOrientation) {
return nearby.selfOrientation;
}
let orientation;
let $current = $elm.parentElement! as NavigationElement;
while ($current !== this.$container) {
const tmp = $current.nearby?.orientation;
if ($current.nearby && tmp) {
orientation = tmp;
break;
}
$current = $current.parentElement!;
}
orientation = orientation || 'vertical';
setNearby($elm, {
selfOrientation: orientation,
});
return orientation;
}
findNextTarget($focusing: HTMLElement | null, direction: NavigationDirection, checkParent = false, checked: Array<HTMLElement> = []): HTMLElement | null {
if (!$focusing || $focusing === this.$container) {
return null;
}
if (checked.includes($focusing)) {
return null;
}
checked.push($focusing);
let $target: HTMLElement = $focusing;
const $parent = $target.parentElement;
const nearby = ($target as NavigationElement).nearby || {};
const orientation = this.getOrientation($target)!;
// @ts-ignore
let siblingProperty = (NavigationDialogManager.SIBLING_PROPERTY_MAP[orientation])[direction];
if (siblingProperty) {
let $sibling = $target as any;
while ($sibling[siblingProperty]) {
$sibling = $sibling[siblingProperty] as HTMLElement;
const $focusable = this.findFocusableElement($sibling, direction);
if ($focusable) {
return $focusable;
}
}
}
if (nearby.loop) {
// Loop
if (nearby.loop(direction)) {
return null;
}
}
if (checkParent) {
return this.findNextTarget($parent, direction, checkParent, checked);
}
return null;
}
findFocusableElement($elm: HTMLElement | null, direction?: NavigationDirection): HTMLElement | null {
if (!$elm) {
return null;
}
// Ignore disabled element
const isDisabled = !!($elm as any).disabled;
if (isDisabled) {
return null;
}
const rect = $elm.getBoundingClientRect();
const isVisible = !!rect.width && !!rect.height;
// Ignore hidden element
if (!isVisible) {
return null;
}
// Accept element with tabIndex
if ($elm.tabIndex > -1) {
return $elm;
}
const focus = ($elm as NavigationElement).nearby?.focus;
if (focus) {
if (focus instanceof HTMLElement) {
return this.findFocusableElement(focus, direction);
} else if (typeof focus === 'function') {
if (focus()) {
return document.activeElement as HTMLElement;
}
}
}
// Look for child focusable elemnet
const children = Array.from($elm.children);
// Search from right to left if the orientation is horizontal
const orientation = ($elm as NavigationElement).nearby?.orientation;
if (orientation === 'horizontal' || (orientation === 'vertical' && direction === NavigationDirection.UP)) {
children.reverse();
}
for (const $child of children) {
if (!$child || !($child instanceof HTMLElement)) {
return null;
}
const $target = this.findFocusableElement($child, direction);
if ($target) {
return $target;
}
}
return null;
}
private startGamepadPolling() {
this.stopGamepadPolling();
this.gamepadPollingIntervalId = window.setInterval(this.pollGamepad.bind(this), NavigationDialogManager.GAMEPAD_POLLING_INTERVAL);
}
private stopGamepadPolling() {
this.gamepadLastStates = [];
this.gamepadPollingIntervalId && window.clearInterval(this.gamepadPollingIntervalId);
this.gamepadPollingIntervalId = null;
}
private focusDirection(direction: NavigationDirection) {
const dialog = this.dialog;
if (!dialog) {
return;
}
// Get current focused element
const $focusing = dialog.getFocusedElement();
if (!$focusing || !this.findFocusableElement($focusing, direction)) {
dialog.focusIfNeeded();
return null;
}
const $target = this.findNextTarget($focusing, direction, true);
this.focus($target);
}
private unmountCurrentDialog() {
const dialog = this.dialog;
dialog && dialog.onBeforeUnmount();
this.$container.firstChild?.remove();
dialog && dialog.onUnmounted();
this.dialog = null;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
import { CE } from "@/utils/html";
export class FullscreenText {
private static instance: FullscreenText;
public static getInstance(): FullscreenText {
if (!FullscreenText.instance) {
FullscreenText.instance = new FullscreenText();
}
return FullscreenText.instance;
}
$text: HTMLElement;
constructor() {
this.$text = CE('div', {
class: 'bx-fullscreen-text bx-gone',
});
document.documentElement.appendChild(this.$text);
}
show(msg: string) {
document.body.classList.add('bx-no-scroll');
this.$text.classList.remove('bx-gone');
this.$text.textContent = msg;
}
hide() {
document.body.classList.remove('bx-no-scroll');
this.$text.classList.add('bx-gone');
}
}

View File

@ -0,0 +1,87 @@
import { BxEvent } from "@/utils/bx-event";
import { BxIcon } from "@/utils/bx-icon";
import { CE, createSvgIcon, getReactProps } from "@/utils/html";
import { XcloudApi } from "@/utils/xcloud-api";
export class GameTile {
static #timeout: number | null;
static #secondsToHms(seconds: number) {
let h = Math.floor(seconds / 3600);
seconds %= 3600;
let m = Math.floor(seconds / 60);
let s = seconds % 60;
const output = [];
h > 0 && output.push(`${h}h`);
m > 0 && output.push(`${m}m`);
if (s > 0 || output.length === 0) {
output.push(`${s}s`);
}
return output.join(' ');
}
static async #showWaitTime($elm: HTMLElement, productId: string) {
let totalWaitTime;
const api = XcloudApi.getInstance();
const info = await api.getTitleInfo(productId);
if (info) {
const waitTime = await api.getWaitTime(info.titleId);
if (waitTime) {
totalWaitTime = waitTime.estimatedAllocationTimeInSeconds;
}
}
if (typeof totalWaitTime === 'number' && $elm.isConnected) {
const $div = CE('div', {'class': 'bx-game-tile-wait-time'},
createSvgIcon(BxIcon.PLAYTIME),
CE('span', {}, GameTile.#secondsToHms(totalWaitTime)),
);
$elm.insertAdjacentElement('afterbegin', $div);
}
}
static requestWaitTime($elm: HTMLElement, productId: string) {
GameTile.#timeout && clearTimeout(GameTile.#timeout);
GameTile.#timeout = window.setTimeout(async () => {
if (!($elm as any).hasWaitTime) {
($elm as any).hasWaitTime = true;
GameTile.#showWaitTime($elm, productId);
}
}, 1000);
}
static setup() {
window.addEventListener(BxEvent.NAVIGATION_FOCUS_CHANGED, e => {
let productId;
const $elm = (e as any).element;
try {
if (($elm.tagName === 'BUTTON' && $elm.className.includes('MruGameCard')) || (($elm.tagName === 'A' && $elm.className.includes('GameCard')))) {
let props = getReactProps($elm.parentElement);
// When context menu is enabled
if (Array.isArray(props.children)) {
productId = props.children[0].props.productId;
} else {
productId = props.children.props.productId;
}
} else if ($elm.tagName === 'A' && $elm.className.includes('GameItem')) {
let props = getReactProps($elm.parentElement);
props = props.children.props;
if (props.location !== 'NonStreamableGameItem') {
if ('productId' in props) {
productId = props.productId;
} else {
// Search page
productId = props.children.props.productId;
}
}
}
} catch (e) {}
productId && GameTile.requestWaitTime($elm, productId);
});
}
}

View File

@ -1,410 +0,0 @@
import { STATES, AppInterface, SCRIPT_HOME, SCRIPT_VERSION } from "@utils/global";
import { CE, createButton, ButtonStyle } from "@utils/html";
import { BxIcon } from "@utils/bx-icon";
import { getPreferredServerRegion } from "@utils/region";
import { UserAgent, UserAgentProfile } from "@utils/user-agent";
import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences";
import { t, Translations } from "@utils/translation";
import { PatcherCache } from "../patcher";
const SETTINGS_UI = {
'Better xCloud': {
items: [
PrefKey.BETTER_XCLOUD_LOCALE,
PrefKey.REMOTE_PLAY_ENABLED,
],
},
[t('server')]: {
items: [
PrefKey.SERVER_REGION,
PrefKey.STREAM_PREFERRED_LOCALE,
PrefKey.PREFER_IPV6_SERVER,
],
},
[t('stream')]: {
items: [
PrefKey.STREAM_TARGET_RESOLUTION,
PrefKey.STREAM_CODEC_PROFILE,
PrefKey.BITRATE_VIDEO_MAX,
PrefKey.AUDIO_ENABLE_VOLUME_CONTROL,
PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG,
PrefKey.SCREENSHOT_APPLY_FILTERS,
PrefKey.AUDIO_MIC_ON_PLAYING,
PrefKey.GAME_FORTNITE_FORCE_CONSOLE,
PrefKey.STREAM_COMBINE_SOURCES,
],
},
[t('game-bar')]: {
items: [
PrefKey.GAME_BAR_POSITION,
],
},
[t('local-co-op')]: {
items: [
PrefKey.LOCAL_CO_OP_ENABLED,
],
},
[t('mouse-and-keyboard')]: {
items: [
PrefKey.NATIVE_MKB_DISABLED,
PrefKey.MKB_ENABLED,
PrefKey.MKB_HIDE_IDLE_CURSOR,
],
},
[t('touch-controller')]: {
note: !STATES.hasTouchSupport ? '⚠️ ' + t('device-unsupported-touch') : null,
unsupported: !STATES.hasTouchSupport,
items: [
PrefKey.STREAM_TOUCH_CONTROLLER,
PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF,
PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY,
PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD,
PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM,
],
},
[t('loading-screen')]: {
items: [
PrefKey.UI_LOADING_SCREEN_GAME_ART,
PrefKey.UI_LOADING_SCREEN_WAIT_TIME,
PrefKey.UI_LOADING_SCREEN_ROCKET,
],
},
[t('ui')]: {
items: [
PrefKey.UI_LAYOUT,
PrefKey.UI_HOME_CONTEXT_MENU_DISABLED,
PrefKey.STREAM_SIMPLIFY_MENU,
PrefKey.SKIP_SPLASH_VIDEO,
!AppInterface && PrefKey.UI_SCROLLBAR_HIDE,
PrefKey.HIDE_DOTS_ICON,
PrefKey.REDUCE_ANIMATIONS,
],
},
[t('other')]: {
items: [
PrefKey.BLOCK_SOCIAL_FEATURES,
PrefKey.BLOCK_TRACKING,
],
},
[t('advanced')]: {
items: [
PrefKey.USER_AGENT_PROFILE,
],
},
};
export function setupSettingsUi() {
// Avoid rendering the Settings multiple times
if (document.querySelector('.bx-settings-container')) {
return;
}
const PREF_PREFERRED_REGION = getPreferredServerRegion();
const PREF_LATEST_VERSION = getPref(PrefKey.LATEST_VERSION);
let $btnReload: HTMLButtonElement;
// Setup Settings UI
const $container = CE<HTMLElement>('div', {
'class': 'bx-settings-container bx-gone',
});
let $updateAvailable;
const $wrapper = CE<HTMLElement>('div', {'class': 'bx-settings-wrapper'},
CE<HTMLElement>('div', {'class': 'bx-settings-title-wrapper'},
CE('a', {
'class': 'bx-settings-title',
'href': SCRIPT_HOME,
'target': '_blank',
}, 'Better xCloud ' + SCRIPT_VERSION),
createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.FOCUSABLE,
label: t('help'),
url: 'https://better-xcloud.github.io/features/',
}),
)
);
$updateAvailable = CE('a', {
'class': 'bx-settings-update bx-gone',
'href': 'https://github.com/redphx/better-xcloud/releases',
'target': '_blank',
});
$wrapper.appendChild($updateAvailable);
// Show new version indicator
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) {
$updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`;
$updateAvailable.classList.remove('bx-gone');
}
if (AppInterface) {
// Show Android app settings button
const $btn = createButton({
label: t('android-app-settings'),
icon: BxIcon.STREAM_SETTINGS,
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
AppInterface.openAppSettings && AppInterface.openAppSettings();
},
});
$wrapper.appendChild($btn);
} else {
// Show link to Android app
const userAgent = UserAgent.getDefault().toLowerCase();
if (userAgent.includes('android')) {
const $btn = createButton({
label: '🔥 ' + t('install-android'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
url: 'https://better-xcloud.github.io/android',
});
$wrapper.appendChild($btn);
}
}
const onChange = async (e: Event) => {
// Clear PatcherCache;
PatcherCache.clear();
$btnReload.classList.add('bx-danger');
// Highlight the Settings button in the Header to remind user to reload the page
const $btnHeaderSettings = document.querySelector('.bx-header-settings-button');
$btnHeaderSettings && $btnHeaderSettings.classList.add('bx-danger');
if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) {
// Update locale
Translations.refreshCurrentLocale();
await Translations.updateTranslations();
$btnReload.textContent = t('settings-reloading');
$btnReload.click();
}
};
// Render settings
for (let groupLabel in SETTINGS_UI) {
const $group = CE('span', {'class': 'bx-settings-group-label'}, groupLabel);
// Render note
if (SETTINGS_UI[groupLabel].note) {
const $note = CE('b', {}, SETTINGS_UI[groupLabel].note);
$group.appendChild($note);
}
$wrapper.appendChild($group);
// Don't render settings if this is an unsupported feature
if (SETTINGS_UI[groupLabel].unsupported) {
continue;
}
const settingItems = SETTINGS_UI[groupLabel].items;
for (let settingId of settingItems) {
// Don't render custom settings
if (!settingId) {
continue;
}
const setting = Preferences.SETTINGS[settingId];
if (!setting) {
continue;
}
let settingLabel = setting.label;
let settingNote = setting.note || '';
// Add Experimental text
if (setting.experimental) {
settingLabel = '🧪 ' + settingLabel;
if (!settingNote) {
settingNote = t('experimental')
} else {
settingNote = `${t('experimental')}: ${settingNote}`
}
}
let $control: any;
let $inpCustomUserAgent: HTMLInputElement;
let labelAttrs: any = {
tabindex: '-1',
};
if (settingId === PrefKey.USER_AGENT_PROFILE) {
let defaultUserAgent = (window.navigator as any).orgUserAgent || window.navigator.userAgent;
$inpCustomUserAgent = CE('input', {
id: `bx_setting_inp_${settingId}`,
type: 'text',
placeholder: defaultUserAgent,
'class': 'bx-settings-custom-user-agent',
});
$inpCustomUserAgent.addEventListener('change', e => {
const profile = $control.value;
const custom = (e.target as HTMLInputElement).value.trim();
UserAgent.updateStorage(profile, custom);
onChange(e);
});
$control = toPrefElement(PrefKey.USER_AGENT_PROFILE, (e: Event) => {
const value = (e.target as HTMLInputElement).value as UserAgentProfile;
let isCustom = value === UserAgentProfile.CUSTOM;
let userAgent = UserAgent.get(value as UserAgentProfile);
UserAgent.updateStorage(value);
$inpCustomUserAgent.value = userAgent;
$inpCustomUserAgent.readOnly = !isCustom;
$inpCustomUserAgent.disabled = !isCustom;
!(e.target as HTMLInputElement).disabled && onChange(e);
});
} else if (settingId === PrefKey.SERVER_REGION) {
let selectedValue;
$control = CE<HTMLSelectElement>('select', {
id: `bx_setting_${settingId}`,
title: settingLabel,
tabindex: 0,
});
$control.name = $control.id;
$control.addEventListener('change', (e: Event) => {
setPref(settingId, (e.target as HTMLSelectElement).value);
onChange(e);
});
selectedValue = PREF_PREFERRED_REGION;
setting.options = {};
for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName];
let value = regionName;
let label = `${region.shortName} - ${regionName}`;
if (region.isDefault) {
label += ` (${t('default')})`;
value = 'default';
if (selectedValue === regionName) {
selectedValue = 'default';
}
}
setting.options[value] = label;
}
for (let value in setting.options) {
const label = setting.options[value];
const $option = CE('option', {value: value}, label);
$control.appendChild($option);
}
// Select preferred region
$control.value = selectedValue;
} else {
if (settingId === PrefKey.BETTER_XCLOUD_LOCALE) {
$control = toPrefElement(settingId, (e: Event) => {
localStorage.setItem('better_xcloud_locale', (e.target as HTMLSelectElement).value);
onChange(e);
});
} else {
$control = toPrefElement(settingId, onChange);
}
}
if (!!$control.id) {
labelAttrs['for'] = $control.id;
} else {
labelAttrs['for'] = `bx_setting_${settingId}`;
}
// Disable unsupported settings
if (setting.unsupported) {
($control as HTMLInputElement).disabled = true;
}
// Make disabled control elements un-focusable
if ($control.disabled && !!$control.getAttribute('tabindex')) {
$control.setAttribute('tabindex', -1);
}
const $label = CE('label', labelAttrs, settingLabel);
if (settingNote) {
$label.appendChild(CE('b', {}, settingNote));
}
const $elm = CE<HTMLElement>('div', {'class': 'bx-settings-row'},
$label,
$control,
);
$wrapper.appendChild($elm);
// Add User-Agent input
if (settingId === PrefKey.USER_AGENT_PROFILE) {
$wrapper.appendChild($inpCustomUserAgent!);
// Trigger 'change' event
$control.disabled = true;
$control.dispatchEvent(new Event('change'));
$control.disabled = false;
}
}
}
// Setup Reload button
$btnReload = createButton({
label: t('settings-reload'),
classes: ['bx-settings-reload-button'],
style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH,
onClick: e => {
window.location.reload();
$btnReload.disabled = true;
$btnReload.textContent = t('settings-reloading');
},
});
$btnReload.setAttribute('tabindex', '0');
$wrapper.appendChild($btnReload);
// Donation link
const $donationLink = CE('a', {
'class': 'bx-donation-link',
href: 'https://ko-fi.com/redphx',
target: '_blank',
tabindex: 0,
}, `❤️ ${t('support-better-xcloud')}`);
$wrapper.appendChild($donationLink);
// Show Game Pass app version
try {
const appVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement).content;
const appDate = new Date((document.querySelector('meta[name=gamepass-app-date]') as HTMLMetaElement).content).toISOString().substring(0, 10);
$wrapper.appendChild(CE<HTMLElement>('div', {'class': 'bx-settings-app-version'}, `xCloud website version ${appVersion} (${appDate})`));
} catch (e) {}
$container.appendChild($wrapper);
// Add Settings UI to the web page
const $pageContent = document.getElementById('PageContent');
$pageContent?.parentNode?.insertBefore($container, $pageContent);
}

View File

@ -0,0 +1,150 @@
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 { SettingsNavigationDialog } from "./dialog/settings-dialog";
export enum GuideMenuTab {
HOME = 'home',
}
export class GuideMenu {
static #BUTTONS = {
scriptSettings: createButton({
label: t('better-xcloud'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.PRIMARY,
onClick: e => {
// Wait until the Guide dialog is closed
window.addEventListener(BxEvent.XCLOUD_DIALOG_DISMISSED, e => {
setTimeout(() => SettingsNavigationDialog.getInstance().show(), 50);
}, {once: true});
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes.closeAll();
},
}),
appSettings: createButton({
label: t('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();
},
}),
reloadPage: createButton({
label: t('reload-page'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
if (STATES.isPlaying) {
confirm(t('confirm-reload-stream')) && window.location.reload();
} else {
window.location.reload();
}
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes.closeAll();
},
}),
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[] = [];
// "Better xCloud" button
buttons.push(GuideMenu.#BUTTONS.scriptSettings);
// "App settings" button
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
// "Reload page" button
buttons.push(GuideMenu.#BUTTONS.reloadPage);
// "Close app" buttons
AppInterface && 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.scriptSettings);
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
// Reload page
buttons.push(GuideMenu.#BUTTONS.reloadPage);
// 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,85 +1,87 @@
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 { RemotePlay } from "@modules/remote-play"; import { RemotePlay } from "@modules/remote-play";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { setupSettingsUi } from "./global-settings"; import { SettingsNavigationDialog } from "./dialog/settings-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
export class HeaderSection {
function injectSettingsButton($parent?: HTMLElement) { static #$remotePlayBtn = createButton({
if (!$parent) { classes: ['bx-header-remote-play-button', 'bx-gone'],
return; icon: BxIcon.REMOTE_PLAY,
} title: t('remote-play'),
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.CIRCULAR,
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'],
label: PREF_PREFERRED_REGION,
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
onClick: e => { onClick: e => {
setupSettingsUi(); RemotePlay.togglePopup();
const $settings = document.querySelector('.bx-settings-container')!;
$settings.classList.toggle('bx-gone');
window.scrollTo(0, 0);
document.activeElement && (document.activeElement as HTMLElement).blur();
}, },
}); });
// Show new update status static #$settingsBtn = createButton({
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) { classes: ['bx-header-settings-button'],
$settingsBtn.setAttribute('data-update-available', 'true'); label: '???',
} style: ButtonStyle.FROSTED | ButtonStyle.DROP_SHADOW | ButtonStyle.FOCUSABLE | ButtonStyle.FULL_HEIGHT,
onClick: e => {
// Add the Settings button to the web page SettingsNavigationDialog.getInstance().show();
$headerFragment.appendChild($settingsBtn); },
$parent.appendChild($headerFragment);
}
export function checkHeader() {
const $button = document.querySelector('.bx-header-settings-button');
if (!$button) {
const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
injectSettingsButton($rightHeader as HTMLElement);
}
}
export function watchHeader() {
const $header = document.querySelector('#PageContent header');
if (!$header) {
return;
}
let timeout: number | null;
const observer = new MutationObserver(mutationList => {
timeout && clearTimeout(timeout);
timeout = window.setTimeout(checkHeader, 2000);
}); });
observer.observe($header, {subtree: true, childList: true});
checkHeader(); static #$buttonsWrapper = CE('div', {},
getPref(PrefKey.REMOTE_PLAY_ENABLED) ? HeaderSection.#$remotePlayBtn : null,
HeaderSection.#$settingsBtn,
);
static #observer: MutationObserver;
static #timeout: number | null;
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) || t('better-xcloud');
// 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);
}
static checkHeader() {
let $target = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
if (!$target) {
$target = document.querySelector("div[class^=UnsupportedMarketPage-module__buttons]");
}
$target && HeaderSection.#injectSettingsButton($target as HTMLElement);
}
static showRemotePlayButton() {
HeaderSection.#$remotePlayBtn.classList.remove('bx-gone');
}
static watchHeader() {
const $root = document.querySelector('#PageContent header') || document.querySelector('#root');
if (!$root) {
return;
}
HeaderSection.#observer && HeaderSection.#observer.disconnect();
HeaderSection.#observer = new MutationObserver(mutationList => {
HeaderSection.#timeout && clearTimeout(HeaderSection.#timeout);
HeaderSection.#timeout = window.setTimeout(HeaderSection.checkHeader, 2000);
});
HeaderSection.#observer.observe($root, {subtree: true, childList: true});
HeaderSection.checkHeader();
}
} }

View File

@ -0,0 +1,35 @@
import { BX_FLAGS } from "@/utils/bx-flags";
import { BxIcon } from "@/utils/bx-icon";
import { AppInterface } from "@/utils/global";
import { ButtonStyle, createButton } from "@/utils/html";
import { t } from "@/utils/translation";
export class ProductDetailsPage {
private static $btnShortcut = createButton({
classes: ['bx-button-shortcut'],
icon: BxIcon.CREATE_SHORTCUT,
label: t('create-shortcut'),
style: ButtonStyle.FOCUSABLE,
tabIndex: 0,
onClick: e => {
AppInterface && AppInterface.createShortcut(window.location.pathname.substring(6));
},
});
private static shortcutTimeoutId: number | null = null;
static injectShortcutButton() {
if (!AppInterface || BX_FLAGS.DeviceInfo.deviceType !== 'android') {
return;
}
ProductDetailsPage.shortcutTimeoutId && clearTimeout(ProductDetailsPage.shortcutTimeoutId);
ProductDetailsPage.shortcutTimeoutId = window.setTimeout(() => {
// Find action buttons container
const $container = document.querySelector('div[class*=ActionButtons-module__container]');
if ($container) {
$container.parentElement?.appendChild(ProductDetailsPage.$btnShortcut);
}
}, 500);
}
}

View File

@ -1,17 +1,4 @@
import { STATES } from "@utils/global"; import { CE } from "@utils/html";
import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html";
import { BxIcon } from "@utils/bx-icon";
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 +24,4 @@ export function localRedirect(path: string) {
$anchor.click(); $anchor.click();
} }
(window as any).localRedirect = localRedirect;
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) => {
const streamStats = StreamStats.getInstance();
(e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop();
},
},
{
pref: PrefKey.STATS_ITEMS,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_POSITION,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_TEXT_SIZE,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_OPACITY,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_TRANSPARENT,
onChange: StreamStats.refreshStyles,
},
{
pref: PrefKey.STATS_CONDITIONAL_FORMATTING,
onChange: StreamStats.refreshStyles,
},
],
},
],
},
];
let $tabs: HTMLElement;
let $settings: HTMLElement;
const $wrapper = CE<HTMLElement>('div', {'class': 'bx-stream-settings-dialog bx-gone'},
$tabs = CE<HTMLElement>('div', {'class': 'bx-stream-settings-tabs'}),
$settings = CE<HTMLElement>('div', {'class': 'bx-stream-settings-tab-contents'}),
);
for (const settingTab of SETTINGS_UI) {
if (!settingTab) {
continue;
}
const $svg = createSvgIcon(settingTab.icon);
$svg.addEventListener('click', e => {
// Switch tab
for (const $child of Array.from($settings.children)) {
if ($child.getAttribute('data-group') === settingTab.group) {
$child.classList.remove('bx-gone');
} else {
$child.classList.add('bx-gone');
}
}
// Highlight current tab button
for (const $child of Array.from($tabs.children)) {
$child.classList.remove('bx-active');
}
$svg.classList.add('bx-active');
});
$tabs.appendChild($svg);
const $group = CE<HTMLElement>('div', {'data-group': settingTab.group, 'class': 'bx-gone'});
for (const settingGroup of settingTab.items) {
if (!settingGroup) {
continue;
}
$group.appendChild(CE('h2', {},
CE('span', {}, settingGroup.label),
settingGroup.help_url && createButton({
icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST,
url: settingGroup.help_url,
title: t('help'),
}),
));
if (settingGroup.note) {
if (typeof settingGroup.note === 'string') {
settingGroup.note = document.createTextNode(settingGroup.note);
}
$group.appendChild(settingGroup.note);
}
if (settingGroup.content) {
$group.appendChild(settingGroup.content);
continue;
}
if (!settingGroup.items) {
settingGroup.items = [];
}
for (const setting of settingGroup.items) {
if (!setting) {
continue;
}
const pref = setting.pref;
let $control;
if (setting.content) {
$control = setting.content;
} else if (!setting.unsupported) {
$control = toPrefElement(pref, setting.onChange, setting.params);
}
const label = Preferences.SETTINGS[pref as PrefKey]?.label || setting.label;
const note = Preferences.SETTINGS[pref as PrefKey]?.note || setting.note;
const $content = CE('div', {'class': 'bx-stream-settings-row', 'data-type': settingGroup.group},
CE('label', {for: `bx_setting_${pref}`},
label,
note && CE('div', {'class': 'bx-stream-settings-dialog-note'}, note),
setting.unsupported && CE('div', {'class': 'bx-stream-settings-dialog-note'}, t('browser-unsupported-feature')),
),
!setting.unsupported && $control,
);
$group.appendChild($content);
setting.onMounted && setting.onMounted($control);
}
}
$settings.appendChild($group);
}
// Select first tab
$tabs.firstElementChild!.dispatchEvent(new Event('click'));
document.documentElement.appendChild($wrapper);
}
export function updateVideoPlayerCss() {
let $elm = document.getElementById('bx-video-css');
if (!$elm) {
const $fragment = document.createDocumentFragment();
$elm = CE<HTMLStyleElement>('style', {id: 'bx-video-css'});
$fragment.appendChild($elm);
// Setup SVG filters
const $svg = CE('svg', {
'id': 'bx-video-filters',
'xmlns': 'http://www.w3.org/2000/svg',
'class': 'bx-gone',
}, CE('defs', {'xmlns': 'http://www.w3.org/2000/svg'},
CE('filter', {'id': 'bx-filter-clarity', 'xmlns': 'http://www.w3.org/2000/svg'},
CE('feConvolveMatrix', {'id': 'bx-filter-clarity-matrix', 'order': '3', 'xmlns': 'http://www.w3.org/2000/svg'}))
)
);
$fragment.appendChild($svg);
document.documentElement.appendChild($fragment);
}
let filters = getVideoPlayerFilterStyle();
let videoCss = '';
if (filters) {
videoCss += `filter: ${filters} !important;`;
}
// Apply video filters to screenshots
if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
Screenshot.updateCanvasFilters(filters);
}
let css = '';
if (videoCss) {
css = `
#game-stream video {
${videoCss}
}
`;
}
$elm.textContent = css;
resizeVideoPlayer();
}
function resizeVideoPlayer() {
const $video = STATES.currentStream.$video;
if (!$video || !$video.parentElement) {
return;
}
const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO);
if (PREF_RATIO.includes(':')) {
const tmp = PREF_RATIO.split(':');
// Get preferred ratio
const videoRatio = parseFloat(tmp[0]) / parseFloat(tmp[1]);
let width = 0;
let height = 0;
// Get parent's ratio
const parentRect = $video.parentElement.getBoundingClientRect();
const parentRatio = parentRect.width / parentRect.height;
// Get target width & height
if (parentRatio > videoRatio) {
height = parentRect.height;
width = height * videoRatio;
} else {
width = parentRect.width;
height = width / videoRatio;
}
// Prevent floating points
width = Math.min(parentRect.width, Math.ceil(width));
height = Math.min(parentRect.height, Math.ceil(height));
// Update size
$video.style.width = `${width}px`;
$video.style.height = `${height}px`;
$video.style.objectFit = 'contain';
} 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() {
// Prevent initializing multiple times
if (!document.querySelector('.bx-stream-settings-dialog')) {
preloadFonts();
window.addEventListener('resize', updateVideoPlayerCss);
setupStreamSettingsDialog();
Screenshot.setup();
}
updateVideoPlayerCss();
}

View File

@ -1,6 +1,7 @@
import { AppInterface } from "@utils/global"; import { AppInterface } from "@utils/global";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
const VIBRATION_DATA_MAP = { const VIBRATION_DATA_MAP = {
'gamepadIndex': 8, 'gamepadIndex': 8,
@ -53,7 +54,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 +64,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 +135,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;

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

@ -24,20 +24,35 @@ interface NavigatorBattery extends Navigator {
} }
type BxStates = { type BxStates = {
supportedRegion: boolean;
serverRegions: any;
selectedRegion: any;
gsToken: string;
isSignedIn: boolean;
isPlaying: boolean; isPlaying: boolean;
appContext: any | null; appContext: any | null;
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;
xboxTitleId: string;
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,15 +66,20 @@ type BxStates = {
serverId: string; serverId: string;
}; };
}>; }>;
pointerServerPort: number;
} }
type DualEnum = {[index: string]: number} & {[index: number]: string}; type DualEnum = {[index: string]: number} & {[index: number]: string};
type XcloudTitleInfo = { type XcloudTitleInfo = {
titleId: string,
details: { details: {
productId: string; productId: string;
supportedInputTypes: InputType[]; supportedInputTypes: InputType[];
supportedTabs: any[]; supportedTabs: any[];
hasNativeTouchSupport: boolean;
hasTouchSupport: boolean; hasTouchSupport: boolean;
hasFakeTouchSupport: boolean; hasFakeTouchSupport: boolean;
hasMkbSupport: boolean; hasMkbSupport: boolean;
@ -72,26 +92,31 @@ type XcloudTitleInfo = {
}; };
}; };
type XcloudWaitTimeInfo = Partial<{
estimatedAllocationTimeInSeconds: number,
estimatedProvisioningTimeInSeconds: number,
estimatedTotalWaitTimeInSeconds: number,
}>;
declare module '*.js'; 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[]};

3
src/types/network.ts Normal file
View File

@ -0,0 +1,3 @@
export type RemotePlayConsoleAddresses = {
[key: string]: number[],
}

View File

@ -1,5 +1,6 @@
export type PreferenceSetting = { export type PreferenceSetting = {
default: any; default: any;
optionsGroup?: string;
options?: {[index: string]: string}; options?: {[index: string]: string};
multipleOptions?: {[index: string]: string}; multipleOptions?: {[index: string]: string};
unsupported?: string | boolean; unsupported?: string | boolean;

19
src/types/setting-definition.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
export type SettingDefinition = {
default: any;
optionsGroup?: string;
options?: {[index: string]: string};
multipleOptions?: {[index: string]: string};
unsupported?: string | boolean;
note?: string | HTMLElement;
type?: SettingElementType;
ready?: (setting: SettingDefinition) => void;
// migrate?: (this: Preferences, savedPrefs: any, value: any) => void;
min?: number;
max?: number;
steps?: number;
experimental?: boolean;
params?: any;
label?: string;
};
export type SettingDefinitions = {[index in PrefKey]: SettingDefinition};

View File

@ -1,59 +1,65 @@
import { AppInterface } from "@utils/global"; import { AppInterface } from "@utils/global";
export enum BxEvent {
JUMP_BACK_IN_READY = 'bx-jump-back-in-ready',
POPSTATE = 'bx-popstate',
TITLE_INFO_READY = 'bx-title-info-ready',
STREAM_LOADING = 'bx-stream-loading',
STREAM_STARTING = 'bx-stream-starting',
STREAM_STARTED = 'bx-stream-started',
STREAM_PLAYING = 'bx-stream-playing',
STREAM_STOPPED = 'bx-stream-stopped',
STREAM_ERROR_PAGE = 'bx-stream-error-page',
STREAM_WEBRTC_CONNECTED = 'bx-stream-webrtc-connected',
STREAM_WEBRTC_DISCONNECTED = 'bx-stream-webrtc-disconnected',
// STREAM_EVENT_TARGET_READY = 'bx-stream-event-target-ready',
STREAM_SESSION_READY = 'bx-stream-session-ready',
CUSTOM_TOUCH_LAYOUTS_LOADED = 'bx-custom-touch-layouts-loaded',
TOUCH_LAYOUT_MANAGER_READY = 'bx-touch-layout-manager-ready',
REMOTE_PLAY_READY = 'bx-remote-play-ready',
REMOTE_PLAY_FAILED = 'bx-remote-play-failed',
XCLOUD_SERVERS_READY = 'bx-servers-ready',
DATA_CHANNEL_CREATED = 'bx-data-channel-created',
GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated',
MICROPHONE_STATE_CHANGED = 'bx-microphone-state-changed',
CAPTURE_SCREENSHOT = 'bx-capture-screenshot',
GAINNODE_VOLUME_CHANGED = 'bx-gainnode-volume-changed',
// xCloud Dialog events
XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown',
XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed',
XCLOUD_GUIDE_SHOWN = 'bx-xcloud-guide-shown',
XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed',
}
export enum XcloudEvent {
MICROPHONE_STATE_CHANGED = 'microphoneStateChanged',
}
export enum XcloudGuideWhere {
HOME,
}
export namespace BxEvent { export namespace BxEvent {
export function dispatch(target: HTMLElement | Window, eventName: string, data?: any) { export const JUMP_BACK_IN_READY = 'bx-jump-back-in-ready';
export const POPSTATE = 'bx-popstate';
export const TITLE_INFO_READY = 'bx-title-info-ready';
export const SETTINGS_CHANGED = 'bx-settings-changed';
export const STREAM_LOADING = 'bx-stream-loading';
export const STREAM_STARTING = 'bx-stream-starting';
export const STREAM_STARTED = 'bx-stream-started';
export const STREAM_PLAYING = 'bx-stream-playing';
export const STREAM_STOPPED = 'bx-stream-stopped';
export const STREAM_ERROR_PAGE = 'bx-stream-error-page';
export const STREAM_WEBRTC_CONNECTED = 'bx-stream-webrtc-connected';
export const STREAM_WEBRTC_DISCONNECTED = 'bx-stream-webrtc-disconnected';
// export const STREAM_EVENT_TARGET_READY = 'bx-stream-event-target-ready';
export const STREAM_SESSION_READY = 'bx-stream-session-ready';
export const CUSTOM_TOUCH_LAYOUTS_LOADED = 'bx-custom-touch-layouts-loaded';
export const TOUCH_LAYOUT_MANAGER_READY = 'bx-touch-layout-manager-ready';
export const REMOTE_PLAY_READY = 'bx-remote-play-ready';
export const REMOTE_PLAY_FAILED = 'bx-remote-play-failed';
export const XCLOUD_SERVERS_READY = 'bx-servers-ready';
export const XCLOUD_SERVERS_UNAVAILABLE = 'bx-servers-unavailable';
export const DATA_CHANNEL_CREATED = 'bx-data-channel-created';
export const GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated';
export const MICROPHONE_STATE_CHANGED = 'bx-microphone-state-changed';
export const CAPTURE_SCREENSHOT = 'bx-capture-screenshot';
export const POINTER_LOCK_REQUESTED = 'bx-pointer-lock-requested';
export const POINTER_LOCK_EXITED = 'bx-pointer-lock-exited';
export const NAVIGATION_FOCUS_CHANGED = 'bx-nav-focus-changed';
// xCloud Dialog events
export const XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown';
export const XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed';
export const XCLOUD_GUIDE_MENU_SHOWN = 'bx-xcloud-guide-menu-shown';
export const XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed';
export const XCLOUD_RENDERING_COMPONENT = 'bx-xcloud-rendering-page';
export const XCLOUD_ROUTER_HISTORY_READY = 'bx-xcloud-router-history-ready';
export function dispatch(target: Element | Window | null, eventName: string, data?: any) {
if (!target) {
return;
}
if (!eventName) { if (!eventName) {
alert('BxEvent.dispatch(): eventName is null'); alert('BxEvent.dispatch(): eventName is null');
return; return;
@ -67,8 +73,8 @@ export namespace BxEvent {
} }
} }
AppInterface && AppInterface.onEvent(eventName);
target.dispatchEvent(event); target.dispatchEvent(event);
AppInterface && AppInterface.onEvent(eventName);
} }
} }

View File

@ -1,36 +1,43 @@
import { ControllerShortcut } from "@/modules/controller-shortcut"; import { ControllerShortcut } from "@/modules/controller-shortcut";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { STATES } from "@utils/global"; import { deepClone, STATES } from "@utils/global";
import { getPref, PrefKey } from "@utils/preferences";
import { UserAgent } from "@utils/user-agent";
import { BxLogger } from "./bx-logger"; import { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags";
import { NavigationDialogManager } from "@/modules/ui/dialog/navigation-dialog";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
export enum InputType { export enum SupportedInputType {
CONTROLLER = 'Controller', CONTROLLER = 'Controller',
MKB = 'MKB', MKB = 'MKB',
CUSTOM_TOUCH_OVERLAY = 'CustomTouchOverlay', CUSTOM_TOUCH_OVERLAY = 'CustomTouchOverlay',
GENERIC_TOUCH = 'GenericTouch', GENERIC_TOUCH = 'GenericTouch',
NATIVE_TOUCH = 'NativeTouch', NATIVE_TOUCH = 'NativeTouch',
BATIVE_SENSOR = 'NativeSensor', BATIVE_SENSOR = 'NativeSensor',
} };
export type SupportedInputTypeValue = (typeof SupportedInputType)[keyof typeof SupportedInputType];
export const BxExposed = { export const BxExposed = {
getTitleInfo: () => STATES.currentStream.titleInfo, getTitleInfo: () => STATES.currentStream.titleInfo,
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;
// Remove native MKB support on mobile browsers or by user's choice if (BX_FLAGS.ForceNativeMkbTitles?.includes(titleInfo.details.productId)) {
if (getPref(PrefKey.NATIVE_MKB_DISABLED) || UserAgent.isMobile()) { supportedInputTypes.push(SupportedInputType.MKB);
supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.MKB);
} }
titleInfo.details.hasMkbSupport = supportedInputTypes.includes(InputType.MKB); // Remove native MKB support on mobile browsers or by user's choice
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
supportedInputTypes = supportedInputTypes.filter(i => i !== SupportedInputType.MKB);
}
if (STATES.hasTouchSupport) { titleInfo.details.hasMkbSupport = supportedInputTypes.includes(SupportedInputType.MKB);
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
@ -50,20 +57,21 @@ export const BxExposed = {
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 !== SupportedInputType.CUSTOM_TOUCH_OVERLAY && i !== SupportedInputType.GENERIC_TOUCH);
// Empty TABs // Empty TABs
titleInfo.details.supportedTabs = []; 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(SupportedInputType.NATIVE_TOUCH);
supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) || titleInfo.details.hasTouchSupport = titleInfo.details.hasNativeTouchSupport ||
supportedInputTypes.includes(InputType.GENERIC_TOUCH); supportedInputTypes.includes(SupportedInputType.CUSTOM_TOUCH_OVERLAY) ||
supportedInputTypes.includes(SupportedInputType.GENERIC_TOUCH);
if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === 'all') { if (!titleInfo.details.hasTouchSupport && touchControllerAvailability === 'all') {
// Add generic touch support for non touch-supported games // Add generic touch support for non touch-supported games
titleInfo.details.hasFakeTouchSupport = true; titleInfo.details.hasFakeTouchSupport = true;
supportedInputTypes.push(InputType.GENERIC_TOUCH); supportedInputTypes.push(SupportedInputType.GENERIC_TOUCH);
} }
} }
@ -104,4 +112,22 @@ export const BxExposed = {
handleControllerShortcut: ControllerShortcut.handle, handleControllerShortcut: ControllerShortcut.handle,
resetControllerShortcut: ControllerShortcut.reset, resetControllerShortcut: ControllerShortcut.reset,
overrideSettings: {
'Tv_settings': {
hasCompletedOnboarding: true,
},
},
disableGamepadPolling: false,
backButtonPressed: () => {
const navigationDialogManager = NavigationDialogManager.getInstance();
if (navigationDialogManager.isShowing()) {
navigationDialogManager.hide();
return true;
}
return false;
},
}; };

View File

@ -1,27 +1,38 @@
type BxFlags = { type BxFlags = {
CheckForUpdate?: boolean; CheckForUpdate: boolean;
PreloadRemotePlay?: boolean; EnableXcloudLogging: boolean;
PreloadUi?: boolean; SafariWorkaround: boolean;
EnableXcloudLogging?: boolean;
SafariWorkaround?: boolean;
UseDevTouchLayout?: boolean; ForceNativeMkbTitles: string[];
FeatureGates: {[key: string]: boolean} | null,
DeviceInfo: {
deviceType: 'android' | 'android-tv' | 'webos' | 'unknown',
userAgent?: string,
}
} }
// Setup flags // Setup flags
const DEFAULT_FLAGS: BxFlags = { const DEFAULT_FLAGS: BxFlags = {
CheckForUpdate: true, CheckForUpdate: true,
PreloadRemotePlay: true,
PreloadUi: false,
EnableXcloudLogging: false, EnableXcloudLogging: false,
SafariWorkaround: true, SafariWorkaround: true,
UseDevTouchLayout: false, ForceNativeMkbTitles: [],
FeatureGates: null,
DeviceInfo: {
deviceType: 'unknown',
},
} }
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) {}
if (!BX_FLAGS.DeviceInfo.userAgent) {
BX_FLAGS.DeviceInfo.userAgent = window.navigator.userAgent;
}
export const NATIVE_FETCH = window.fetch; export const NATIVE_FETCH = window.fetch;

View File

@ -1,20 +1,23 @@
import iconBetterXcloud from "@assets/svg/better-xcloud.svg" with { type: "text" };
import iconClose from "@assets/svg/close.svg" with { type: "text" };
import iconCommand from "@assets/svg/command.svg" with { type: "text" }; import iconCommand from "@assets/svg/command.svg" with { type: "text" };
import iconController from "@assets/svg/controller.svg" with { type: "text" }; 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 iconCreateShortcut from "@assets/svg/create-shortcut.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 iconHome from "@assets/svg/home.svg" with { type: "text" }; import iconHome from "@assets/svg/home.svg" with { type: "text" };
import iconMouseSettings from "@assets/svg/mouse-settings.svg" with { type: "text" }; import iconNativeMkb from "@assets/svg/native-mkb.svg" with { type: "text" };
import iconMouse from "@assets/svg/mouse.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" };
import iconRemotePlay from "@assets/svg/remote-play.svg" with { type: "text" }; import iconRemotePlay from "@assets/svg/remote-play.svg" with { type: "text" };
import iconStreamSettings from "@assets/svg/stream-settings.svg" with { type: "text" }; import iconStreamSettings from "@assets/svg/stream-settings.svg" with { type: "text" };
import iconStreamStats from "@assets/svg/stream-stats.svg" with { type: "text" }; import iconStreamStats from "@assets/svg/stream-stats.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 iconTouchControlDisable from "@assets/svg/touch-control-disable.svg" with { type: "text" }; import iconTouchControlDisable from "@assets/svg/touch-control-disable.svg" with { type: "text" };
import iconTouchControlEnable from "@assets/svg/touch-control-enable.svg" with { type: "text" };
import iconTrash from "@assets/svg/trash.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" };
@ -33,20 +36,23 @@ import iconUpload from "@assets/svg/upload.svg" with { type: "text" };
export const BxIcon = { export const BxIcon = {
BETTER_XCLOUD: iconBetterXcloud,
STREAM_SETTINGS: iconStreamSettings, STREAM_SETTINGS: iconStreamSettings,
STREAM_STATS: iconStreamStats, STREAM_STATS: iconStreamStats,
CLOSE: iconClose,
COMMAND: iconCommand, COMMAND: iconCommand,
CONTROLLER: iconController, CONTROLLER: iconController,
CREATE_SHORTCUT: iconCreateShortcut,
DISPLAY: iconDisplay, DISPLAY: iconDisplay,
HOME: iconHome, HOME: iconHome,
MOUSE: iconMouse, NATIVE_MKB: iconNativeMkb,
MOUSE_SETTINGS: iconMouseSettings,
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,

View File

@ -1,4 +1,4 @@
enum TextColor { const enum TextColor {
INFO = '#008746', INFO = '#008746',
WARNING = '#c1a404', WARNING = '#c1a404',
ERROR = '#c10404', ERROR = '#c10404',
@ -19,7 +19,7 @@ export class BxLogger {
BxLogger.#log(TextColor.ERROR, tag, ...args); BxLogger.#log(TextColor.ERROR, tag, ...args);
} }
static #log(color: TextColor, tag: string, ...args: any) { static #log(color: string, tag: string, ...args: any) {
console.log(`%c${BxLogger.#PREFIX}`, `color:${color};font-weight:bold;`, tag, '//', ...args); console.log(`%c${BxLogger.#PREFIX}`, `color:${color};font-weight:bold;`, tag, '//', ...args);
} }
} }

View File

@ -1,35 +1,61 @@
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { PrefKey, getPref } from "@utils/preferences"; import { compressCss, renderStylus } from "@macros/build" with {type: "macro"};
import { renderStylus } from "@macros/build" with {type: "macro"}; import { UiSection } from "@/enums/ui-sections";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
export function addCss() { export function addCss() {
let css = renderStylus(); const STYLUS_CSS = renderStylus() as unknown as string;
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]');
selectorToHide.push('#BodyContent div[class*=AllGamesRow-module__rowHeader]');
}
// Hide "Most popular" section
if (PREF_HIDE_SECTIONS.includes(UiSection.MOST_POPULAR)) {
selectorToHide.push('#BodyContent div[class*=HomePage-module__bottomSpacing]:has(a[href="/play/gallery/popular"])');
}
// Hide "Play with touch" section
if (PREF_HIDE_SECTIONS.includes(UiSection.TOUCH)) {
selectorToHide.push('#BodyContent div[class*=HomePage-module__bottomSpacing]:has(a[href="/play/gallery/touch"])');
}
// 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
if (getPref(PrefKey.REDUCE_ANIMATIONS)) { if (getPref(PrefKey.REDUCE_ANIMATIONS)) {
css += ` css += compressCss(`
div[class*=GameCard-module__gameTitleInnerWrapper], div[class*=GameCard-module__gameTitleInnerWrapper],
div[class*=GameCard-module__card], div[class*=GameCard-module__card],
div[class*=ScrollArrows-module] { div[class*=ScrollArrows-module] {
transition: none !important; transition: none !important;
} }
`; `);
} }
// Hide the top-left dots icon while playing // Hide the top-left dots icon while playing
if (getPref(PrefKey.HIDE_DOTS_ICON)) { if (getPref(PrefKey.HIDE_DOTS_ICON)) {
css += ` css += compressCss(`
div[class*=Grip-module__container] { div[class*=Grip-module__container] {
visibility: hidden; visibility: hidden;
} }
@ -51,17 +77,18 @@ button[class*=GripHandle-module__container][aria-expanded=false] {
div[class*=StreamHUD-module__buttonsContainer] { div[class*=StreamHUD-module__buttonsContainer] {
padding: 0px !important; padding: 0px !important;
} }
`; `);
} }
// Simplify Stream's menu css += compressCss(`
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 += compressCss(`
div[class*=Menu-module__scrollable] { div[class*=Menu-module__scrollable] {
--bxStreamMenuItemSize: 80px; --bxStreamMenuItemSize: 80px;
--streamMenuItemSize: calc(var(--bxStreamMenuItemSize) + 40px) !important; --streamMenuItemSize: calc(var(--bxStreamMenuItemSize) + 40px) !important;
@ -92,9 +119,9 @@ svg[class*=MenuItem-module__icon] {
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;
} }
`; `);
} else { } else {
css += ` css += compressCss(`
body[data-media-type=tv] .bx-badges { body[data-media-type=tv] .bx-badges {
top: calc(var(--streamMenuItemSize) + 30px); top: calc(var(--streamMenuItemSize) + 30px);
} }
@ -116,12 +143,12 @@ body:not([data-media-type=tv]) div[class*=MenuItem-module__label] {
margin-left: 8px !important; margin-left: 8px !important;
margin-right: 8px !important; margin-right: 8px !important;
} }
`; `);
} }
// Hide scrollbar // Hide scrollbar
if (getPref(PrefKey.UI_SCROLLBAR_HIDE)) { if (getPref(PrefKey.UI_SCROLLBAR_HIDE)) {
css += ` css += compressCss(`
html { html {
scrollbar-width: none; scrollbar-width: none;
} }
@ -129,9 +156,22 @@ html {
body::-webkit-scrollbar { body::-webkit-scrollbar {
display: none; display: none;
} }
`; `);
} }
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,24 @@
import { PrefKey } from "@/enums/pref-keys";
import { BX_FLAGS } from "./bx-flags";
import { getPref } from "./settings-storages/global-settings-storage";
export let FeatureGates: {[key: string]: boolean} = {
'PwaPrompt': false,
'EnableWifiWarnings': false,
'EnableUpdateRequiredPage': false,
'ShowForcedUpdateScreen': 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,13 +1,14 @@
import { MkbHandler } from "@modules/mkb/mkb-handler"; import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler";
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";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
// 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,7 @@
import type { BaseSettingsStore } from "./settings-storages/base-settings-storage";
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 +11,48 @@ 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,
serverRegions: {},
selectedRegion: {},
gsToken: '',
isSignedIn: false,
isPlaying: false, isPlaying: false,
appContext: {}, appContext: {},
serverRegions: {},
hasTouchSupport: hasTouchSupport, browser: {
browserHasTouchSupport: browserHasTouchSupport, capabilities: {
touch: browserHasTouchSupport,
batteryApi: 'getBattery' in window.navigator,
},
},
userAgent: {
isTv: isTv,
capabilities: {
touch: userAgentHasTouchSupport,
}
},
currentStream: {}, currentStream: {},
remotePlay: {}, remotePlay: {},
pointerServerPort: 9269,
}; };
export const STORAGE: {[key: string]: BaseSettingsStore} = {};
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,8 @@
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";
import { NavigationDialogManager } from "@/modules/ui/dialog/navigation-dialog";
export function patchHistoryMethod(type: 'pushState' | 'replaceState') { export function patchHistoryMethod(type: 'pushState' | 'replaceState') {
const orig = window.history[type]; const orig = window.history[type];
@ -25,16 +26,20 @@ export function onHistoryChanged(e: PopStateEvent) {
window.setTimeout(RemotePlay.detect, 10); window.setTimeout(RemotePlay.detect, 10);
// Hide Global settings
const $settings = document.querySelector('.bx-settings-container'); const $settings = document.querySelector('.bx-settings-container');
if ($settings) { if ($settings) {
$settings.classList.add('bx-gone'); $settings.classList.add('bx-gone');
} }
// Hide Navigation dialog
NavigationDialogManager.getInstance().hide();
// Hide Remote Play popup // Hide Remote Play popup
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,9 @@
import type { BxIcon } from "@utils/bx-icon"; import type { BxIcon } from "@utils/bx-icon";
import { setNearby } from "./navigation-utils";
import type { NavigationNearbyElements } from "@/modules/ui/dialog/navigation-dialog";
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,12 +11,20 @@ type BxButton = {
title?: string; title?: string;
disabled?: boolean; disabled?: boolean;
onClick?: EventListener; onClick?: EventListener;
tabIndex?: number;
attributes?: {[key: string]: any},
} }
type ButtonStyle = {[index: string]: number} & {[index: number]: string}; type ButtonStyle = {[index: string]: number} & {[index: number]: string};
// Quickly create a tree of elements without having to use innerHTML // Quickly create a tree of elements without having to use innerHTML
function createElement<T=HTMLElement>(elmName: string, props: {[index: string]: any}={}, ..._: any): T { type CreateElementOptions = {
[index: string]: any;
_nearby?: NavigationNearbyElements;
};
function createElement<T=HTMLElement>(elmName: string, props: CreateElementOptions={}, ..._: any): T {
let $elm; let $elm;
const hasNs = 'xmlns' in props; const hasNs = 'xmlns' in props;
@ -25,6 +35,11 @@ function createElement<T=HTMLElement>(elmName: string, props: {[index: string]:
$elm = document.createElement(elmName); $elm = document.createElement(elmName);
} }
if (props['_nearby']) {
setNearby($elm, props['_nearby']);
delete props['_nearby'];
}
for (const key in props) { for (const key in props) {
if ($elm.hasOwnProperty(key)) { if ($elm.hasOwnProperty(key)) {
continue; continue;
@ -33,18 +48,23 @@ function createElement<T=HTMLElement>(elmName: string, props: {[index: string]:
if (hasNs) { if (hasNs) {
$elm.setAttributeNS(null, key, props[key]); $elm.setAttributeNS(null, key, props[key]);
} else { } else {
$elm.setAttribute(key, props[key]); if (key === 'on') {
for (const eventName in props[key]) {
$elm.addEventListener(eventName, props[key][eventName]);
}
} else {
$elm.setAttribute(key, props[key]);
}
} }
} }
for (let i = 2, size = arguments.length; i < size; i++) { for (let i = 2, size = arguments.length; i < size; i++) {
const arg = arguments[i]; const arg = arguments[i];
const argType = typeof arg;
if (argType === 'string' || argType === 'number') { if (arg instanceof Node) {
$elm.appendChild(document.createTextNode(arg));
} else if (arg) {
$elm.appendChild(arg); $elm.appendChild(arg);
} else if (arg !== null && arg !== false && typeof arg !== 'undefined') {
$elm.appendChild(document.createTextNode(arg));
} }
} }
@ -64,9 +84,14 @@ export const ButtonStyle: DualEnum = {};
ButtonStyle[ButtonStyle.PRIMARY = 1] = 'bx-primary'; ButtonStyle[ButtonStyle.PRIMARY = 1] = 'bx-primary';
ButtonStyle[ButtonStyle.DANGER = 2] = 'bx-danger'; ButtonStyle[ButtonStyle.DANGER = 2] = 'bx-danger';
ButtonStyle[ButtonStyle.GHOST = 4] = 'bx-ghost'; ButtonStyle[ButtonStyle.GHOST = 4] = 'bx-ghost';
ButtonStyle[ButtonStyle.FOCUSABLE = 8] = 'bx-focusable'; ButtonStyle[ButtonStyle.FROSTED = 8] = 'bx-frosted';
ButtonStyle[ButtonStyle.FULL_WIDTH = 16] = 'bx-full-width'; ButtonStyle[ButtonStyle.DROP_SHADOW = 16] = 'bx-drop-shadow';
ButtonStyle[ButtonStyle.FULL_HEIGHT = 32] = 'bx-full-height'; ButtonStyle[ButtonStyle.FOCUSABLE = 32] = 'bx-focusable';
ButtonStyle[ButtonStyle.FULL_WIDTH = 64] = 'bx-full-width';
ButtonStyle[ButtonStyle.FULL_HEIGHT = 128] = 'bx-full-height';
ButtonStyle[ButtonStyle.TALL = 256] = 'bx-tall';
ButtonStyle[ButtonStyle.CIRCULAR = 512] = 'bx-circular';
ButtonStyle[ButtonStyle.NORMAL_CASE = 1024] = 'bx-normal-case';
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));
@ -92,10 +117,27 @@ export const createButton = <T=HTMLButtonElement>(options: BxButton): T => {
options.title && $btn.setAttribute('title', options.title); options.title && $btn.setAttribute('title', options.title);
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);
$btn.tabIndex = typeof options.tabIndex === 'number' ? options.tabIndex : 0;
for (const key in options.attributes) {
if (!$btn.hasOwnProperty(key)) {
$btn.setAttribute(key, options.attributes[key]);
}
}
return $btn as T; return $btn as T;
} }
export function getReactProps($elm: HTMLElement): any | null {
for (const key in $elm) {
if (key.startsWith('__reactProps')) {
return ($elm as any)[key];
}
}
return null;
}
export function escapeHtml(html: string): string { export function escapeHtml(html: string): string {
const text = document.createTextNode(html); const text = document.createTextNode(html);
const $span = document.createElement('span'); const $span = document.createElement('span');

View File

@ -1,7 +1,8 @@
import { MkbPreset } from "@modules/mkb/mkb-preset"; import { MkbPreset } from "@modules/mkb/mkb-preset";
import { PrefKey, setPref } from "@utils/preferences";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import type { MkbStoredPreset, MkbStoredPresets } from "@/types/mkb"; import type { MkbStoredPreset, MkbStoredPresets } from "@/types/mkb";
import { PrefKey } from "@/enums/pref-keys";
import { setPref } from "./settings-storages/global-settings-storage";
export class LocalDb { export class LocalDb {
static #instance: LocalDb; static #instance: LocalDb;

View File

@ -1,8 +1,10 @@
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
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";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
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 +12,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 +45,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 +65,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 +80,30 @@ 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);
}
// @ts-ignore
return nativeSetLocalDescription.apply(this, arguments);
};
}
const OrgRTCPeerConnection = window.RTCPeerConnection; const OrgRTCPeerConnection = window.RTCPeerConnection;
// @ts-ignore // @ts-ignore
@ -132,6 +124,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 +156,14 @@ export function patchMeControl() {
}; };
const MSA = { const MSA = {
MeControl: {}, MeControl: {
API: {
setDisplayMode: () => {},
setMobileState: () => {},
addEventListener: () => {},
removeEventListener: () => {},
},
},
}; };
const MeControl = {}; const MeControl = {};
@ -198,6 +201,14 @@ export function patchMeControl() {
(window as any).MeControl = new Proxy(MeControl, MeControlHandler); (window as any).MeControl = new Proxy(MeControl, MeControlHandler);
} }
/**
* Disable Adobe Audience Manager (AAM)
*/
export function disableAdobeAudienceManager() {
(window as any).adobe = Object.freeze({});
}
/** /**
* Use power-saving flags for touch control * Use power-saving flags for touch control
*/ */
@ -207,15 +218,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

@ -0,0 +1,14 @@
import type { NavigationElement, NavigationNearbyElements } from "@/modules/ui/dialog/navigation-dialog";
export class NavigationUtils {
static setNearby($elm: NavigationElement, nearby: NavigationNearbyElements) {
$elm.nearby = $elm.nearby || {};
let key: keyof typeof nearby;
for (key in nearby) {
$elm.nearby[key] = nearby[key] as any;
}
}
}
export const setNearby = NavigationUtils.setNearby;

View File

@ -1,20 +1,17 @@
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 { 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";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
import type { RemotePlayConsoleAddresses } from "@/types/network";
enum RequestType { type RequestType = 'xcloud' | 'xhome';
XCLOUD = 'xcloud',
XHOME = 'xhome',
};
function clearApplicationInsightsBuffers() { function clearApplicationInsightsBuffers() {
window.sessionStorage.removeItem('AI_buffer'); window.sessionStorage.removeItem('AI_buffer');
@ -43,7 +40,7 @@ function clearAllLogs() {
clearDbLogs('XCloudAppLogs', 'logs'); clearDbLogs('XCloudAppLogs', 'logs');
} }
function updateIceCandidates(candidates: any, options: any) { function updateIceCandidates(candidates: any, options: {preferIpv6Server: boolean, consoleAddrs?: RemotePlayConsoleAddresses}) {
const pattern = new RegExp(/a=candidate:(?<foundation>\d+) (?<component>\d+) UDP (?<priority>\d+) (?<ip>[^\s]+) (?<port>\d+) (?<the_rest>.*)/); const pattern = new RegExp(/a=candidate:(?<foundation>\d+) (?<component>\d+) UDP (?<priority>\d+) (?<ip>[^\s]+) (?<port>\d+) (?<the_rest>.*)/);
const lst = []; const lst = [];
@ -87,20 +84,20 @@ function updateIceCandidates(candidates: any, options: any) {
if (options.consoleAddrs) { if (options.consoleAddrs) {
for (const ip in options.consoleAddrs) { for (const ip in options.consoleAddrs) {
const port = options.consoleAddrs[ip]; for (const port of options.consoleAddrs[ip]) {
newCandidates.push(newCandidate(`a=candidate:${newCandidates.length + 1} 1 UDP 1 ${ip} ${port} typ host`));
newCandidates.push(newCandidate(`a=candidate:${newCandidates.length + 1} 1 UDP 1 ${ip} ${port} typ host`)); }
} }
} }
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?: RemotePlayConsoleAddresses) {
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,373 +121,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);
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;
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)) {
@ -570,14 +200,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);
@ -588,7 +214,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();
@ -614,14 +240,29 @@ export function interceptHttpRequests() {
return response; return response;
} }
let requestType: RequestType; if (BX_FLAGS.ForceNativeMkbTitles && url.includes('catalog.gamepass.com/sigls/') && url.includes(GamePassCloudGallery.NATIVE_MKB)) {
if (url.includes('/sessions/home') || url.includes('xhome.') || (STATES.remotePlay.isPlaying && url.endsWith('/inputconfigs'))) { const response = await NATIVE_FETCH(request, init);
requestType = RequestType.XHOME; const obj = await response.clone().json();
} else {
requestType = RequestType.XCLOUD; 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;
} }
if (requestType === RequestType.XHOME) { let requestType: RequestType;
if (url.includes('/sessions/home') || url.includes('xhome.') || (STATES.remotePlay.isPlaying && url.endsWith('/inputconfigs'))) {
requestType = 'xhome';
} else {
requestType = 'xcloud';
}
if (requestType === 'xhome') {
return XhomeInterceptor.handle(request as Request); return XhomeInterceptor.handle(request as Request);
} }

View File

@ -1,8 +1,10 @@
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 { BX_FLAGS } from "./bx-flags";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
const LOG_TAG = 'PreloadState'; const LOG_TAG = 'PreloadState';
@ -23,7 +25,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 +39,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 +60,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,8 +1,9 @@
import { getPref, PrefKey } from "@utils/preferences";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "./settings-storages/global-settings-storage";
export function getPreferredServerRegion(shortName = false) { export function getPreferredServerRegion(shortName = false): string | null {
let preferredRegion = getPref(PrefKey.SERVER_REGION); let preferredRegion = getPref(PrefKey.SERVER_REGION);
if (preferredRegion in STATES.serverRegions) { if (preferredRegion in STATES.serverRegions) {
if (shortName && STATES.serverRegions[preferredRegion].shortName) { if (shortName && STATES.serverRegions[preferredRegion].shortName) {
@ -25,5 +26,5 @@ export function getPreferredServerRegion(shortName = false) {
} }
} }
return '???'; return null;
} }

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