Compare commits

..

303 Commits

Author SHA1 Message Date
e75fa397ee Bump version to 5.1.1 2024-07-02 18:11:08 +07:00
98a9f4fc37 Update better-xcloud.user.js 2024-07-02 18:10:52 +07:00
dee8c9dbd0 Refactor buttons in guide-menu 2024-07-02 18:06:33 +07:00
d31a06be89 Use {once: true} in some event listeners 2024-07-02 17:20:23 +07:00
277c777121 Add "Show controller connection status" setting 2024-07-02 17:08:40 +07:00
385fd71e86 Update better-xcloud.user.js 2024-07-02 06:49:40 +07:00
986d9fe088 Show "Stream settings" and "App settings" in the Guide menu 2024-07-02 06:41:23 +07:00
6de235ce2f Fix overriding experimentation stopped working 2024-07-02 05:50:02 +07:00
f027565534 Bump version to 5.1.0 2024-07-01 18:08:46 +07:00
0213b860fd No longer need "Kiwi Browser v123" profile 2024-07-01 17:52:12 +07:00
13feb36aae Update better-xcloud.user.js 2024-07-01 17:44:49 +07:00
d83261d816 Dim Stream settings' overlay when not playing 2024-07-01 17:42:42 +07:00
c1502b5552 Prepare for webOS & Tizen support 2024-07-01 17:26:04 +07:00
64d60aedfa Create bun.lockb 2024-07-01 17:23:13 +07:00
889a97e56b Stop using setCodecPreferences() as it causes stuttering on Chromium 124+ 2024-07-01 17:22:24 +07:00
7aee4d5148 Compress CSS 2024-07-01 17:20:39 +07:00
2000d6d80e Update translations 2024-06-26 21:00:38 +07:00
297c0848d5 Bump version to 5.0.1 2024-06-26 18:14:57 +07:00
51ef9f9e8f Update package.json 2024-06-26 18:14:29 +07:00
9717315b79 Update README.md 2024-06-26 08:44:16 +07:00
e176ef6fc0 Update package.json 2024-06-23 17:59:24 +07:00
52694d8f8e Update better-xcloud.user.js 2024-06-23 17:30:00 +07:00
b7928ebe68 Fix stream badge showing "1h60m" instead of "2h" 2024-06-23 17:27:11 +07:00
05eddce11e Update better-xcloud.user.js 2024-06-22 16:43:22 +07:00
057da5b3ea Fix exception with navigator.vibrate() on start up 2024-06-22 16:43:18 +07:00
11ef014c74 Update build.ts 2024-06-22 16:30:22 +07:00
fa82f0ba95 Update better-xcloud.user.js 2024-06-22 16:30:02 +07:00
36db8db1e7 Minify syntax in dist file 2024-06-22 16:29:54 +07:00
d906de7803 Update better-xcloud.user.js 2024-06-22 10:36:27 +07:00
cf546123db Show "Off" when Sharpness is 0 2024-06-22 10:36:02 +07:00
d6a4d1741b Update NumberStepper 2024-06-22 10:35:45 +07:00
22e7400e06 Bump version to 5.0.0 2024-06-21 18:10:50 +07:00
f169c17e18 Add WebGL2 renderer 2024-06-21 17:45:43 +07:00
6150c2ea70 Update better-xcloud.user.js 2024-06-20 20:46:15 +07:00
2cdf92b159 Update better-xcloud.user.js 2024-06-19 18:17:42 +07:00
6f6a9e223e Show WS error in toast 2024-06-10 08:57:07 +07:00
f71904c30b Bump version to 4.7.1 2024-06-10 08:29:53 +07:00
3a16187504 Update Guide menu detection 2024-06-10 08:03:13 +07:00
00ebb3f672 Fix dispatching STREAM_PLAYING event when playing normal video 2024-06-09 18:31:57 +07:00
ebb4d3c141 Add FeatureGates 2024-06-09 18:31:15 +07:00
902918d7fb Update URLs 2024-06-09 15:56:32 +07:00
b780e4e63b Minor fix 2024-06-09 15:45:41 +07:00
32889e0cf1 Minor fix 2024-06-09 15:43:19 +07:00
d8b9fcc951 Update better-xcloud.user.js 2024-06-09 15:40:15 +07:00
c7734245ae Don't check update for beta version 2024-06-09 11:50:46 +07:00
35e7fdacb5 Disable context menu on devices with touch support by default 2024-06-09 11:48:12 +07:00
504f16b802 Rename "hasTouchSupport" to "userAgentHasTouchSupport" 2024-06-09 11:47:08 +07:00
a3a7a57b51 Get PointerServer's port from the app 2024-06-09 11:41:00 +07:00
0d59ab2ee2 Bump version to 4.7.0 2024-06-08 17:22:43 +07:00
ff794c44b5 Update better-xcloud.user.js 2024-06-08 17:21:55 +07:00
ccc824d544 Add vscode files 2024-06-08 17:05:02 +07:00
eb8490a798 Add native MKB support for Android app 2024-06-08 17:04:49 +07:00
a41d0cda0c Update better-xcloud.user.js 2024-06-07 21:04:52 +07:00
559c3c52c3 Update better-xcloud.user.js 2024-06-07 07:57:10 +07:00
2ed1e8735f Update better-xcloud.user.js 2024-06-07 07:20:25 +07:00
03d5550f05 Update better-xcloud.user.js 2024-06-06 20:53:53 +07:00
fb1ce5306d Update better-xcloud.user.js 2024-06-05 21:37:38 +07:00
e8e37aa575 Update better-xcloud.user.js 2024-06-05 18:28:31 +07:00
9f1f28a2d7 Update better-xcloud.user.js 2024-06-03 15:52:55 +07:00
44cf4f1d19 Fix video ratio not working properly 2024-06-03 15:52:52 +07:00
bb20f408a3 Update custom-flags.user.js 2024-06-03 05:44:58 +07:00
c03737e224 Update custom-flags.user.js 2024-06-02 11:13:47 +07:00
5b137f7791 Update 01-bug-report.yml 2024-06-02 10:54:14 +07:00
7f52479f0a Update better-xcloud.user.js 2024-06-02 10:44:23 +07:00
2e0a59cbe1 Add back the ability to use native MKB feature on unofficial titles 2024-06-02 10:43:59 +07:00
850afb4ca7 Disable xCloud analytics also remove the Feedback button in the Guide menu 2024-06-02 09:57:04 +07:00
e98fa29271 Disable social features also hide the "Start a party" button in the Guide menu 2024-06-02 09:52:41 +07:00
d79aaecb54 Bump version to 4.6.3 2024-06-01 18:39:30 +07:00
148b60cccb Update better-xcloud.user.js 2024-06-01 18:39:08 +07:00
db78918d34 Don't process further when vibration intensity is 0 2024-06-01 18:37:06 +07:00
ddc4346da8 Update better-xcloud.user.js 2024-06-01 18:28:04 +07:00
4db25e8d62 Don't show stream badges in the Guide menu until xCloud removes the Stream menu 2024-06-01 18:27:46 +07:00
e10a98c245 Fix not disabling vibration when intensity is 0 2024-06-01 18:26:23 +07:00
c9b070253c Fix button styles in WebView 2024-06-01 18:23:14 +07:00
ba07e0498e Fix disabling the MKB dialog not making it go away 2024-06-01 18:23:00 +07:00
6c8f336e9c Update better-xcloud.user.js 2024-06-01 17:29:11 +07:00
522e4dddd2 Update home icon 2024-06-01 17:29:09 +07:00
e1627dca61 Update better-xcloud.user.js 2024-06-01 17:11:53 +07:00
b5a19cd211 Replace double-quote with single-quote in SVG files 2024-06-01 17:11:29 +07:00
d5d81f3374 Add "Back to home" button in the Stream menu 2024-06-01 17:04:33 +07:00
2db78d01a0 Hide xCloud's Home button in the Guide menu 2024-06-01 16:34:54 +07:00
8f9976da28 Add "Reload stream" & "Back to home" buttons in the Guide menu 2024-06-01 16:29:01 +07:00
322418ec5b Reposition badges in the Guide menu 2024-06-01 16:00:11 +07:00
28049e5d22 Update package.json 2024-06-01 15:49:30 +07:00
9593cdf8dd Update better-xcloud.user.js 2024-06-01 10:21:29 +07:00
732bd19f3a Add Catalan 2024-06-01 10:21:11 +07:00
562c1c95f5 Update translations 2024-06-01 10:21:05 +07:00
5d1a0a3428 Update better-xcloud.user.js 2024-06-01 10:12:52 +07:00
758501bcd3 Change objectFit to "contain" 2024-06-01 10:12:45 +07:00
e10eadc832 Fix first time activating controller shortcut will also open the Guide menu (#409) 2024-06-01 10:11:53 +07:00
ed3c4041ff Show stats in the Guide menu & refactor 2024-06-01 10:11:06 +07:00
60cadb4b04 Update better-xcloud.user.js 2024-05-31 21:29:36 +07:00
b5c033498e Move "AUDIO_MIC_ON_PLAYING" setting 2024-05-31 07:31:36 +07:00
a24446a6b4 Update better-xcloud.user.js 2024-05-31 07:22:01 +07:00
bee190b867 Use a better method to skip feedback dialog 2024-05-31 07:14:58 +07:00
941ed0a00f Fix loading screen not working properly 2024-05-31 06:55:31 +07:00
1f632db6b4 Bump version to 4.6.2 2024-05-30 17:24:50 +07:00
c07e3297ca Update better-xcloud.user.js 2024-05-30 17:22:19 +07:00
5e43915ff7 Add a Disable button in the MKB dialog 2024-05-30 17:22:06 +07:00
e21375821d Update better-xcloud.user.js 2024-05-30 16:46:56 +07:00
6438e533d6 Hide rocket animation in Smart TV profile 2024-05-30 16:46:48 +07:00
e9671cbe5d Fix video not being full screen (#415) 2024-05-30 16:28:05 +07:00
b99ec65cc9 Update better-xcloud.user.js 2024-05-30 09:34:47 +07:00
addcf56abf Minor fix 2024-05-30 09:22:04 +07:00
db17bda673 Bump version to 4.6.1 2024-05-30 07:09:39 +07:00
0a60119c3b Update better-xcloud.user.js 2024-05-30 07:09:14 +07:00
ef14c78941 Fix settings being reset after refreshing page 2024-05-30 07:04:01 +07:00
f2dc102996 Update better-xcloud.user.js 2024-05-29 20:19:43 +07:00
02db103a72 Fix pink border when using Clarity feature in Logitech G Cloud 2024-05-29 20:19:36 +07:00
f291047b64 Update better-xcloud.user.js 2024-05-29 20:09:22 +07:00
5866644673 Clear TABs when disabling touch control 2024-05-29 20:09:20 +07:00
5baad2d89a Bump version to 4.6.0 2024-05-29 17:41:27 +07:00
381f3fb679 Update better-xcloud.user.js 2024-05-29 17:29:12 +07:00
0f48cb891f Support emulated MKB in Android app
commit ad365d4ee854971122f0e8cb9157ed44b3aac0d8
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 17:19:57 2024 +0700

    Fix not able to reconnect to WebSocket server when switching game

commit ca9369318d4cbb831650e8ca631e7997dc7706cb
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 17:19:23 2024 +0700

    Stop emulated MKB when losing pointer capture

commit 8cca1a0554c46b8f61455e79d5b16f1dff9a8014
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 17:17:42 2024 +0700

    Allow fine-tuning maximum video bitrate

commit 763d414d560d9d2aa6710fd60e3f80bf43a534d6
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 08:13:56 2024 +0700

    Update mouse settings

commit d65c5ab4e4a33ed8ad13acf0a15c4bb5ace870eb
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 08:10:49 2024 +0700

    Increase MKB dialog's bg opacity

commit 3e72f2ad2700737c8148ef47629528954a606578
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 08:02:57 2024 +0700

    Show/hide MKB dialog properly

commit e7786f36508e3aa843604d9886861930bada5d60
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 07:47:21 2024 +0700

    Fix connecting to WebSocket server when it's not ready

commit 512d8c227a057e5c0399bf128bc1c52a88fcf853
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Wed May 29 07:18:06 2024 +0700

    Fix arrow keys not working in Android app

commit 0ce90f47f37d057d5a4fab0003e2bec8960d1eee
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Tue May 28 17:36:56 2024 +0700

    Set mouse's default sensitivities to 50

commit 16eb48660dd44497e16ca22343a880d9a2e53a30
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Tue May 28 17:33:37 2024 +0700

    Allow emulated MKB feature in Android app

commit c3d0e64f8502e19cd4f167fea4cdbdfc2e14b65e
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Tue May 28 17:32:49 2024 +0700

    Remove stick decay settings

commit d289d2a0dea61a440c1bc6b9392920b8e6ab6298
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Tue May 28 17:21:39 2024 +0700

    Remove stick decaying feature

commit 76bd001d98bac53f757f4ae793b2850aad055007
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Tue May 28 17:21:14 2024 +0700

    Update data structure

commit c5d3c87da9e6624ebefb288f6d7c8d06dc00916b
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Tue May 28 08:14:27 2024 +0700

    Fix not toggling the MKB feature correctly

commit 9615535cf0e4d4372e201aefb6f1231ddbc22536
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Mon May 27 20:51:57 2024 +0700

    Handle mouse data from the app
2024-05-29 17:28:39 +07:00
228c2ad008 Update better-xcloud.user.js 2024-05-28 17:48:07 +07:00
5604664b66 Bump version to 4.5.0 2024-05-26 15:45:29 +07:00
beb02796b3 Update better-xcloud.user.js 2024-05-26 15:18:06 +07:00
9041f70dbd Bug fixes 2024-05-26 15:18:04 +07:00
c13845ffe1 Update better-xcloud.user.js 2024-05-26 15:10:11 +07:00
0d0ecca155 Update translations when version changed 2024-05-26 15:05:30 +07:00
c09bd9be83 Update better-xcloud.user.js 2024-05-26 11:59:53 +07:00
15a2c67703 Observe root dialog 2024-05-26 11:59:38 +07:00
9166761780 Rename "Quick Bar" to "Stream Settings dialog" 2024-05-26 11:42:19 +07:00
ac37fe05bc Show note for Video Ratio setting 2024-05-26 11:16:52 +07:00
030791d9c4 Format 2024-05-26 10:54:32 +07:00
5523be1b7f Update better-xcloud.user.js 2024-05-26 07:46:13 +07:00
2a9b070373 Minor optimization for the shortcuts feature 2024-05-26 07:46:09 +07:00
8ba305af2b Rearrange shortcut buttons 2024-05-26 07:40:30 +07:00
29813fbaf2 Update better-xcloud.user.js 2024-05-25 18:55:44 +07:00
02f33875e4 Add L3 & R3 buttons and rearrange buttons 2024-05-25 18:55:33 +07:00
474f655707 Update better-xcloud.user.js 2024-05-25 18:10:40 +07:00
78021020ce Support device shortcuts 2024-05-25 18:10:22 +07:00
7c206bd079 Rearrange shortcuts 2024-05-25 15:09:51 +07:00
298a40d156 Update better-xcloud.user.js 2024-05-25 14:55:46 +07:00
498123af85 Add notes to Shortcuts UI 2024-05-25 14:55:24 +07:00
579dc6bf40 Update better-xcloud.user.js 2024-05-25 11:41:10 +07:00
17e02e5b32 Improve shortcut actions selection box 2024-05-25 11:41:02 +07:00
bf135d34d1 Update better-xcloud.user.js 2024-05-25 10:29:14 +07:00
9fec033173 Add shortcut to mute/unmute sound 2024-05-25 10:28:59 +07:00
78d74cfd23 Set audioGainNode to null when couldn't setup GainNode 2024-05-25 10:09:33 +07:00
3418cdd666 Update better-xcloud.user.js 2024-05-25 09:55:24 +07:00
567770c86e Fix crashing with GainNode when the stream has no sound 2024-05-25 09:55:20 +07:00
18027ed1c5 Update better-xcloud.user.js 2024-05-25 09:52:25 +07:00
dcbae39042 Add shortcuts to control stream's volume 2024-05-25 09:50:41 +07:00
90df5d655f Move MicrophoneState to shortcut-microphone 2024-05-25 07:51:51 +07:00
774a822e69 Clean up 2024-05-25 07:44:25 +07:00
5623f3f02f Use different arrow symbol in action selection box 2024-05-25 07:43:55 +07:00
4eda413da6 Update translations 2024-05-25 07:43:32 +07:00
f5b4bd2f40 Update better-xcloud.user.js 2024-05-24 20:10:29 +07:00
a702d29f22 Try to fix problem with Dualsense controller 2024-05-24 20:10:23 +07:00
71576439fd Update better-xcloud.user.js 2024-05-24 18:11:02 +07:00
07c1757237 Squashed commit of the following:
commit 2faed50e5c2165647e389d794de673038d56241e
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 18:09:25 2024 +0700

    Make shortcuts work with controller

commit b8f6c503ba7969de3a232644d3f6b53532a4b7bb
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 17:01:15 2024 +0700

    Update translations

commit 6f6c0899e5a09cd5534e06a9e272bf78c74536dc
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 17:00:50 2024 +0700

    Preload PrompFont

commit 1bf0f2b9dae77890d35091bed970b942c4d61fbc
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 07:08:05 2024 +0700

    Render Controller shortcuts settings

commit 2f24965c73a941be2ebc8a3509dc540a47b4e38d
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Thu May 23 17:21:55 2024 +0700

    Fix not able to capture screenshot after switching games

commit 6ac791e2dfb17215ee82d449047d0cd11d185c42
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Thu May 23 17:11:19 2024 +0700

    Hijack the Home button
2024-05-24 18:10:38 +07:00
22e29e1d92 Move patchPollGamepads code to external file 2024-05-23 06:55:41 +07:00
e18e05589a Move some patch code to external files 2024-05-23 06:22:25 +07:00
88df490c50 Update better-xcloud.user.js 2024-05-22 18:38:55 +07:00
e2e2322d94 Minor fixes 2024-05-22 18:38:51 +07:00
a4a1743062 Update better-xcloud.user.js 2024-05-22 18:23:08 +07:00
a3600dfd75 Fix not downloading translations when needed 2024-05-22 18:19:27 +07:00
c4ad50906e Move foreign translations to external files 2024-05-22 18:10:53 +07:00
a87b26b077 Create config.yml 2024-05-22 06:50:59 +07:00
6874d64ceb Update better-xcloud.user.js 2024-05-21 16:52:06 +07:00
a376f443ef Map the Share button on Xbox Series controller with the capturing screenshot feature 2024-05-21 16:51:55 +07:00
3bfe11280e Update 01-bug-report.yml 2024-05-21 06:28:44 +07:00
b6a3e56d9f Bump version to 4.4.0 2024-05-19 17:58:06 +07:00
a6f06fe0f1 Update better-xcloud.user.js 2024-05-19 17:49:17 +07:00
229df61f53 Update translations 2024-05-19 17:49:08 +07:00
0c712b6a31 Update better-xcloud.user.js 2024-05-19 12:14:32 +07:00
f06e36e46b Use PWA version in Android app 2024-05-19 12:14:30 +07:00
1db19f69ac Update better-xcloud.user.js 2024-05-19 11:42:44 +07:00
dc62c13c21 Optimize video player 2024-05-19 11:42:40 +07:00
d5f02550c7 Update better-xcloud.user.js 2024-05-19 10:44:35 +07:00
88b63a5518 App option to disable context menu in Home page 2024-05-19 10:43:44 +07:00
afd851861a Update translations 2024-05-19 07:54:29 +07:00
378f186ee2 Update better-xcloud.user.js 2024-05-18 19:54:49 +07:00
423b171964 Improve emulated mouse's responsiveness 2024-05-18 19:54:43 +07:00
4acf9eba11 Update better-xcloud.user.js 2024-05-18 17:53:39 +07:00
0f5c4f004b Fix button text's vertical alignment in WebView 2024-05-18 17:52:35 +07:00
c7dfacf5c4 Update default MKB preset 2024-05-18 16:06:02 +07:00
0e724b0e4f Update better-xcloud.user.js 2024-05-18 16:00:18 +07:00
47078da413 Fix not fully disable native MKB 2024-05-18 16:00:13 +07:00
e52a296872 Minor fixes 2024-05-18 15:38:03 +07:00
4c593a298e Update better-xcloud.user.js 2024-05-17 18:20:49 +07:00
962b57f0a6 Add option to disable native MKB 2024-05-17 18:19:45 +07:00
22fc730fa1 Update translations 2024-05-17 18:07:08 +07:00
5bd25bf31c Put back the Reload page button into the global settings UI 2024-05-17 17:57:41 +07:00
aba9340e91 Fix HTML issues 2024-05-17 17:24:10 +07:00
d07d6127df Update stylings of global settings UI 2024-05-17 16:57:46 +07:00
e45ed6f9ea Update better-xcloud.user.js 2024-05-15 17:46:45 +07:00
07b477a738 Add "Android app settings" button 2024-05-15 17:46:36 +07:00
fcaab4ce77 Update translations 2024-05-15 17:43:04 +07:00
3954a5d934 Update 01-bug-report.yml 2024-05-13 08:20:14 +07:00
2ef1d17901 Bump version to 4.3.0 2024-05-12 22:10:54 +07:00
8334a79f5d Update better-xcloud.user.js 2024-05-12 21:50:36 +07:00
a80da85098 Misc 2024-05-12 21:50:18 +07:00
0ffa6b55b2 Change the max value of video bitrate to 14 to discourage people from using it 2024-05-12 21:49:54 +07:00
8f8b7c6f22 Update translations 2024-05-12 21:48:58 +07:00
31804ea8cc Update better-xcloud.user.js 2024-05-12 19:00:17 +07:00
99c81cfb90 Change "Default" to "Unlimited" in maximum video bitrate setting 2024-05-12 18:59:50 +07:00
761e58254a Update better-xcloud.user.js 2024-05-12 18:06:40 +07:00
1dee720f77 Add "Maximum video bitrate" option 2024-05-12 18:05:21 +07:00
c1b41663db Update better-xcloud.user.js 2024-05-12 15:51:20 +07:00
5e1c5c5420 Remove "exposeEventTarget" patch 2024-05-12 15:51:13 +07:00
99a9396d5b Update better-xcloud.user.js 2024-05-12 15:20:44 +07:00
bd3f8c9f50 Reorder settings 2024-05-12 15:20:41 +07:00
5e8db626c5 Update better-xcloud.user.js 2024-05-12 15:19:23 +07:00
5d9319b831 Remove "experimental" flag from AUDIO_ENABLE_VOLUME_CONTROL 2024-05-12 15:18:15 +07:00
e867f156e8 Trying to fix custom touch control sometimes not showing 2024-05-12 15:07:18 +07:00
4068930db7 Fix bug with Game Bar when showing it on the right side 2024-05-12 14:40:15 +07:00
8a1dff3372 Update better-xcloud.user.js 2024-05-12 12:26:40 +07:00
41effff226 Show gyroscope settings if the custom layout supports it 2024-05-12 12:26:34 +07:00
be897848fe Cache screenshot's canvas context 2024-05-12 11:13:32 +07:00
453a45a995 Update better-xcloud.user.js 2024-05-12 08:28:26 +07:00
30e2193fe7 Update caret icons 2024-05-12 08:28:22 +07:00
f06346457a Update better-xcloud.user.js 2024-05-12 08:09:05 +07:00
cec2bdf807 Fix emulated MKB not being disabled on native MKB games (#391) 2024-05-12 08:07:35 +07:00
1be9bd8ee1 Add option for Game Bar's position 2024-05-12 08:01:49 +07:00
84adf9989e Remove empty translations at the end of arrays 2024-05-12 07:39:33 +07:00
bc429088ca Ignore translations that are the same in English 2024-05-12 07:35:42 +07:00
7d79b12d4d Update translations 2024-05-12 07:32:54 +07:00
952af5c274 Update better-xcloud.user.js 2024-05-11 21:23:42 +07:00
362c5386d1 Add microphone action for Game Bar 2024-05-11 21:15:22 +07:00
5c9202119b Update style of the show/hide touch control button 2024-05-11 21:12:14 +07:00
0092417a6e Add microphone icons 2024-05-11 20:56:00 +07:00
328372878e Update better-xcloud.user.js 2024-05-11 15:45:57 +07:00
ae37c0660f Add taking screenshot animation 2024-05-11 15:45:46 +07:00
e9b0d900b0 Update better-xcloud.user.js 2024-05-11 12:18:47 +07:00
85eac4be14 Move screenshot functions to a separate file 2024-05-11 12:18:36 +07:00
40b61b173f Use singleton in GameBar 2024-05-11 11:48:07 +07:00
b3033089ed Fix unexpected behavior with Stream bar when using Quest VR profile 2024-05-11 10:47:29 +07:00
6b88f73e34 Add setting to enable/disable Game Bar feature 2024-05-11 10:42:30 +07:00
72579249b1 Update translations 2024-05-11 10:20:11 +07:00
b866cc95a3 Detect hasTouchSupport based on spoofed User-Agent 2024-05-11 10:09:10 +07:00
8bee5b2073 Init UserAgent before STATES 2024-05-11 09:39:50 +07:00
011b75057a Refactor UserAgent class 2024-05-11 09:35:38 +07:00
daaaea1f16 Minor fixes 2024-05-11 09:16:01 +07:00
84182ffe77 Update User-Agent values 2024-05-11 09:13:21 +07:00
9ce906c0b2 Move User-Agent values to a separate localStorage item 2024-05-11 09:08:08 +07:00
77f7b647da Improve ready() in Preferences 2024-05-11 07:58:27 +07:00
9988a55601 Prevent clicking when hiding game bar & toast 2024-05-10 20:34:54 +07:00
49af04a3e0 Update better-xcloud.user.js 2024-05-10 18:38:35 +07:00
b2e932cc4c Game bar (#392)
* Fix games with custom touch control sometimes not showing touch icon

* Create game-bar with screenshot button

* Disable Game bar when opening the Guide

* Remove SCREENSHOT_BUTTON_POSITION pref

* Make the touch control action functional

* Show game bar when the game starts

* Fix 720p/High not working (#387)

* Update icons

* Update game bar's animations

* Reset states of Game bar actions before playing

* Don't show Touch control action on non-touch-supported devices

* Clean up

* Update translations

* Update actions' texts

* Clean up
2024-05-10 18:35:40 +07:00
b66ca192b2 Bump version to 4.2.0 2024-05-08 17:38:45 +07:00
660aac4e8c Update better-xcloud.user.js 2024-05-08 17:18:42 +07:00
3b1f5155c6 Update translations 2024-05-08 17:18:23 +07:00
500f6671c6 Rename "touchLayoutManager" and "testTouchLayout" 2024-05-08 17:11:15 +07:00
26bf14eda6 Show custom touch layout's author name in toast message 2024-05-08 17:04:14 +07:00
d8fada8f5d Fix not able to get Chromium version in WebView 2024-05-08 16:48:42 +07:00
4e8848d2fb Update better-xcloud.user.js 2024-05-08 08:55:10 +07:00
8e23ca51de Remove debuggers 2024-05-08 08:55:05 +07:00
9ac988e894 Update dist 2024-05-08 08:04:09 +07:00
c2efbd9c1d Remove non-cloud games from touch games list 2024-05-08 08:03:58 +07:00
7eda0b61cc Update dist 2024-05-07 21:40:28 +07:00
c948b63b8d Show touch icon on games with custom layouts 2024-05-07 21:40:12 +07:00
fc56d486a7 Update dist 2024-05-07 18:03:00 +07:00
7dacc8f23a Fix problems when holding NumberStepper's buttons 2024-05-07 18:01:36 +07:00
2df3bb4611 Add "Default opacity" setting for touch controller 2024-05-07 17:37:49 +07:00
b9355d5c01 Optimize touch control's canvas, use low-power profile and disable antialiasing 2024-05-07 17:07:15 +07:00
d1b99705e6 Bump version to 4.1.2 2024-05-05 21:32:41 +07:00
52896c94ae Update dist 2024-05-05 18:29:14 +07:00
cadc7987b7 Update "disableIndexDbLogging" patch 2024-05-05 18:29:04 +07:00
8fb1787222 Disable telemetry flags in meversion.js 2024-05-05 18:04:20 +07:00
4231d7e9c6 Update logs 2024-05-05 11:54:59 +07:00
ba05eab47b Update dist 2024-05-05 09:37:06 +07:00
e852b246d3 Rewrite volume control feature 2024-05-05 09:36:53 +07:00
23fb50cb6f Update dist 2024-05-04 17:32:17 +07:00
443bf93c9a Fix the refresh button not focusable using gamepad 2024-05-04 17:32:08 +07:00
df2af43c64 Bump version to 4.1.1 2024-05-04 17:01:57 +07:00
fca3bee6dd Update dist 2024-05-04 16:20:38 +07:00
9bf8a2ef66 Add a standalone Refresh stream button (#315) 2024-05-04 16:20:12 +07:00
b1df189c7d Support "styles" param in touch control 2024-05-04 15:33:19 +07:00
d91fdb798e Update dist 2024-05-04 15:15:15 +07:00
a291443d43 Update dist 2024-05-04 15:01:49 +07:00
8a7be5d523 Add ability to use normal website's layout on TV 2024-05-04 15:00:27 +07:00
7588f37472 Add "Smart TV" + "Meta Quest VR" User-Agent profiles 2024-05-04 14:52:54 +07:00
a597d52585 Update dist 2024-05-04 14:34:19 +07:00
f945a3adde Move PatcherCache.init() to patchFunctionBind() 2024-05-04 14:34:06 +07:00
438afe086a Update dist 2024-05-04 11:23:19 +07:00
f6ee79770c Clear PatcherCache when changing settings 2024-05-04 11:22:42 +07:00
f36c77e727 Fix touch control not showing when using "caches" as variable name 2024-05-04 11:21:18 +07:00
176e86c9bb Bump version to 4.1.0 2024-05-03 18:24:52 +07:00
ddb8628e57 Update dist 2024-05-03 18:09:43 +07:00
f144fac81e Fix not clearing "appliedPatches" correctly 2024-05-03 18:09:31 +07:00
07a4034cc1 Fix screenshot button now working after switching game 2024-05-03 18:05:59 +07:00
d30efb2bed Update dist 2024-05-03 17:29:14 +07:00
3670946da4 Refactor BxIcon 2024-05-03 17:27:13 +07:00
3d3a013a5c Disable achievement toast in Remote Play 2024-05-03 17:17:00 +07:00
db1ce23b53 Refactor Dialog 2024-05-03 17:06:10 +07:00
bcd61833b2 Fix touch controller not working in Remote Play 2024-05-03 17:05:58 +07:00
ba0ccf5213 Fix disabling social features also disables notifications 2024-05-03 17:05:30 +07:00
6ff81971b0 Don't refresh the page when signature has changed 2024-05-03 08:55:55 +07:00
ea57e04d4f Add PatcherCache class 2024-05-03 07:36:03 +07:00
43e6f3083e Update dist 2024-05-02 17:54:24 +07:00
e5ab7c93f9 Fix imports 2024-05-02 17:53:17 +07:00
06c6b8c5af Simplify import paths again 2024-05-01 22:14:28 +07:00
0114108bdf Simplify import paths 2024-05-01 22:01:39 +07:00
006e21f477 Replace DualEnum with normal enum 2024-05-01 21:36:17 +07:00
7883949b94 Update dist 2024-05-01 18:57:58 +07:00
17afd364da Refactor icons 2024-05-01 18:57:50 +07:00
29a1fa9f10 Update IPv6's priority value 2024-05-01 18:31:53 +07:00
594c9d3f2e Update dist 2024-04-30 20:29:09 +07:00
edc8991a6a Create BxLogger to show colored logs 2024-04-30 18:52:56 +07:00
26c318fb8d Format code 2024-04-30 18:24:58 +07:00
9f0097fd8c Handle xHome's /play request 2024-04-30 18:18:02 +07:00
135 changed files with 12614 additions and 13867 deletions

View File

@ -4,12 +4,28 @@ title: "[Bug] "
labels: labels:
- bug - bug
body: body:
- type: markdown - type: checkboxes
id: checklist
attributes: attributes:
value: | label: Checklist
Please fill out the following information to help us resolve the issue. options:
> [!warning] - label: I will only use English in my report.
> Only use English. Any other languages will be deleted. 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.
required: true
- label: I will describe the problem with as much detail as possible.
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:
@ -24,40 +40,28 @@ body:
multiple: false multiple: false
validations: validations:
required: true required: true
- type: dropdown - type: input
id: device_name
attributes:
label: "Device"
description: "Name of the device"
placeholder: "e.g., Google Pixel 8"
validations:
required: true
- type: input
id: os id: os
attributes: attributes:
label: "Operating System" label: "Operating System"
description: "Which operating system is it running?" description: "Which operating system is it running?"
options: placeholder: "e.g., Android 14"
- 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
validations: validations:
required: true required: true
- type: input - type: input
id: browser_version id: browser_version
attributes: attributes:
label: "Browser Version" label: "Android app/Browser Version"
description: "What is the version of the browser?" description: "What is the name and version of the browser/Android app?"
placeholder: "e.g., 122.0" placeholder: "e.g., Chrome 124.0, Android app 0.15.0"
validations: validations:
required: true required: true
- type: input - type: input
@ -68,12 +72,20 @@ body:
placeholder: "e.g., 3.5.0" placeholder: "e.g., 3.5.0"
validations: validations:
required: true required: true
- type: input
id: game_list
attributes:
label: "Game list"
description: "Name the game(s) where you saw this bug"
placeholder: "e.g., Halo"
validations:
required: false
- type: textarea - type: textarea
id: repro id: reproduction
attributes: attributes:
label: "Reproduction Steps" label: "Reproduction Steps"
description: | description: |
How did you trigger this bug? Please provide screenshot/video if possible. How did you trigger this bug?
placeholder: | placeholder: |
Example: Example:
1. Open game X 1. Open game X
@ -81,3 +93,11 @@ body:
3. Error 3. Error
validations: validations:
required: true required: true
- type: textarea
id: media
attributes:
label: "Screenshot/video"
description: |
Please provide screenshot/video if possible.
validations:
required: false

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

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.

111
build.ts
View File

@ -6,10 +6,10 @@ import txtScriptHeader from "./src/assets/header_script.txt" with { type: "text"
import txtMetaHeader from "./src/assets/header_meta.txt" with { type: "text" }; import txtMetaHeader from "./src/assets/header_meta.txt" with { type: "text" };
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 +21,86 @@ const postProcess = (str: string): string => {
// Replace "globalThis." with "var"; // Replace "globalThis." with "var";
str = str.replaceAll('globalThis.', 'var '); str = str.replaceAll('globalThis.', 'var ');
// Add ADDITIONAL CODE block // Add ADDITIONAL CODE block
str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS'); str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS');
return str; return str;
} }
const build = async (target: BuildTarget, version: string, config: any={}) => { const build = async (target: BuildTarget, version: string, config: any={}) => {
console.log('-- Target:', target); console.log('-- Target:', target);
const startTime = performance.now(); const startTime = performance.now();
let outputScriptName = 'better-xcloud'; let outputScriptName = 'better-xcloud';
if (target !== BuildTarget.ALL) { if (target !== BuildTarget.ALL) {
outputScriptName += `.${target}`; outputScriptName += `.${target}`;
} }
let outputMetaName = outputScriptName; let outputMetaName = outputScriptName;
outputScriptName += '.user.js'; outputScriptName += '.user.js';
outputMetaName += '.meta.js'; outputMetaName += '.meta.js';
const outDir = './dist'; const outDir = './dist';
let output = await Bun.build({ let output = await Bun.build({
entrypoints: ['src/index.ts'], entrypoints: ['src/index.ts'],
outdir: outDir, outdir: outDir,
naming: outputScriptName, naming: outputScriptName,
define: { minify: {
'Bun.env.BUILD_TARGET': JSON.stringify(target), syntax: true,
'Bun.env.SCRIPT_VERSION': JSON.stringify(version), },
}, define: {
}); 'Bun.env.BUILD_TARGET': JSON.stringify(target),
'Bun.env.SCRIPT_VERSION': JSON.stringify(version),
},
});
if (!output.success) { if (!output.success) {
console.log(output); console.log(output);
process.exit(1); process.exit(1);
} }
const {path} = output.outputs[0]; const {path} = output.outputs[0];
// Get generated file // Get generated file
let result = postProcess(await readFile(path, 'utf-8')); let result = postProcess(await readFile(path, 'utf-8'));
// Replace [[VERSION]] with real value // Replace [[VERSION]] with real value
const scriptHeader = txtScriptHeader.replace('[[VERSION]]', version); const scriptHeader = txtScriptHeader.replace('[[VERSION]]', version);
// Save to script // Save to script
await Bun.write(path, scriptHeader + result); await Bun.write(path, scriptHeader + result);
console.log(`---- [${target}] done in ${performance.now() - startTime} ms`); console.log(`---- [${target}] done in ${performance.now() - startTime} ms`);
// Create meta file // Create meta file
await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version)); await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version));
} }
const buildTargets = [ const buildTargets = [
BuildTarget.ALL, BuildTarget.ALL,
// BuildTarget.ANDROID_APP, // BuildTarget.ANDROID_APP,
// BuildTarget.MOBILE, // BuildTarget.MOBILE,
// BuildTarget.WEBOS, // BuildTarget.WEBOS,
]; ];
const { values, positionals } = parseArgs({ const { values, positionals } = parseArgs({
args: Bun.argv, args: Bun.argv,
options: { options: {
version: { version: {
type: 'string', type: 'string',
}, },
}, },
strict: true, strict: true,
allowPositionals: true, allowPositionals: true,
}); });
if (!values['version']) { if (!values['version']) {
console.log('Missing --version param'); console.log('Missing --version param');
sys.exit(-1); sys.exit(-1);
} }
console.log('Building: ', values['version']); console.log('Building: ', values['version']);
const config = {}; const config = {};
for (const target of buildTargets) { for (const target of buildTargets) {
await build(target, values['version'], config); await build(target, values['version']!!, config);
} }

BIN
bun.lockb Executable file

Binary file not shown.

View File

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

File diff suppressed because one or more lines are too long

View File

@ -6,12 +6,12 @@
"build": "build.ts" "build": "build.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "^1.1.5",
"@types/node": "^20.12.7", "@types/node": "^20.14.9",
"@types/stylus": "^0.48.42", "@types/stylus": "^0.48.42",
"stylus": "^0.63.0" "stylus": "^0.63.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.0.0" "typescript": "^5.5.2"
} }
} }

View File

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

View File

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

View File

@ -0,0 +1,118 @@
#bx-game-bar {
z-index: var(--bx-game-bar-z-index);
position: fixed;
bottom: 0;
width: 40px;
height: 90px;
overflow: visible;
cursor: pointer;
> svg {
display: none;
pointer-events: none;
position: absolute;
height: 28px;
margin-top: 16px;
}
@media (hover: hover) {
&:hover {
> svg {
display: block;
}
}
}
.bx-game-bar-container {
opacity: 0;
position absolute;
display: flex;
overflow: hidden;
background: #1a1b1ee8;
box-shadow: 0px 0px 6px #1c1c1c;
transition: opacity 0.1s ease-in;
&.bx-show {
opacity: 0.9;
+ svg {
display: none !important;
}
}
&.bx-hide {
opacity: 0;
pointer-events: none;
}
button {
width: 60px;
height: 60px;
border-radius: 0;
svg {
width: 28px;
height: 28px;
transition: transform 0.08s ease 0s;
}
&:hover {
border-radius: 0;
}
&:active {
svg {
transform: scale(0.75);
}
}
&.bx-activated {
background-color: white;
svg {
filter: invert(1);
}
}
}
/* Touch controller buttons */
div[data-enabled] {
button {
display: none;
}
}
/* Show enabled button */
div[data-enabled='true'] {
button:first-of-type {
display: block;
}
}
/* Show enable button */
div[data-enabled='false'] {
button:last-of-type {
display: block;
}
}
}
&[data-position="bottom-left"] {
left: 0;
direction: ltr;
.bx-game-bar-container {
border-radius: 0 10px 10px 0;
}
}
&[data-position="bottom-right"] {
right: 0;
direction: rtl;
.bx-game-bar-container {
direction: ltr;
border-radius: 10px 0 0 10px;
}
}
}

View File

@ -1,17 +1,5 @@
.bx-settings-reload-button-wrapper { .bx-settings-reload-button {
z-index: var(--bx-reload-button-z-index); margin-top: 10px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
text-align: center;
background: #000000cf;
padding: 10px;
button {
max-width: 450px;
margin: 0 !important;
}
} }
.bx-settings-container { .bx-settings-container {
@ -41,6 +29,13 @@
outline: none !important; outline: none !important;
} }
.bx-top-buttons {
.bx-button {
display: block;
margin-bottom: 8px;
}
}
.bx-settings-title-wrapper { .bx-settings-title-wrapper {
display: flex; display: flex;
margin-bottom: 10px; margin-bottom: 10px;
@ -61,10 +56,6 @@
} }
} }
.bx-button.bx-primary {
margin-top: 8px;
}
a.bx-settings-update { a.bx-settings-update {
display: block; display: block;
color: #ff834b; color: #ff834b;
@ -98,34 +89,56 @@
.bx-settings-row { .bx-settings-row {
display: flex; display: flex;
margin-bottom: 8px; padding: 6px 12px;
padding: 2px 4px; position: relative;
label { label {
flex: 1; flex: 1;
align-self: center; align-self: center;
margin-bottom: 0; margin-bottom: 0;
padding-left: 10px;
} }
&:focus-within { &:hover, &:focus-within {
@media (hover: none) { background-color: #242424;
background-color: #242424;
}
} }
input { input {
align-self: center; align-self: center;
accent-color: var(--bx-primary-button-color); accent-color: var(--bx-primary-button-color);
&:focus {
accent-color: var(--bx-danger-button-color);
}
} }
select:disabled { select {
-webkit-appearance: none; &:disabled {
background: transparent; -webkit-appearance: none;
text-align-last: right; background: transparent;
border: none; text-align-last: right;
color: #fff; 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;
}
} }
} }
@ -161,6 +174,10 @@
&:hover { &:hover {
color: #6dd72b; color: #6dd72b;
} }
&:focus {
text-decoration: underline;
}
} }
.bx-settings-custom-user-agent { .bx-settings-custom-user-agent {

View File

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

View File

@ -1,7 +1,9 @@
.bx-number-stepper { .bx-number-stepper {
text-align: center;
span { span {
display: inline-block; display: inline-block;
width: 40px; min-width: 40px;
font-family: var(--bx-monospaced-font); font-family: var(--bx-monospaced-font);
font-size: 14px; font-size: 14px;
} }
@ -18,7 +20,6 @@
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
font-family: var(--bx-monospaced-font); font-family: var(--bx-monospaced-font);
color: #fff;
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@ -35,7 +36,20 @@
} }
} }
input[type="range"] {
display: block;
margin: 12px auto 2px;
width: 180px;
color: #959595 !important;
}
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

@ -20,16 +20,16 @@
--bx-danger-button-disabled-color: #a26c6c; --bx-danger-button-disabled-color: #a26c6c;
--bx-toast-z-index: 9999; --bx-toast-z-index: 9999;
--bx-reload-button-z-index: 9200;
--bx-dialog-z-index: 9101; --bx-dialog-z-index: 9101;
--bx-dialog-overlay-z-index: 9100; --bx-dialog-overlay-z-index: 9100;
--bx-remote-play-popup-z-index: 9090; --bx-remote-play-popup-z-index: 9090;
--bx-stats-bar-z-index: 9001; --bx-stats-bar-z-index: 9010;
--bx-stream-settings-z-index: 9000; --bx-stream-settings-z-index: 9001;
--bx-mkb-pointer-lock-msg-z-index: 8999; --bx-mkb-pointer-lock-msg-z-index: 9000;
--bx-screenshot-z-index: 8888; --bx-stream-settings-overlay-z-index: 8999;
--bx-touch-controller-bar-z-index: 5555; --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;
} }
@font-face { @font-face {
@ -80,6 +80,19 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
visibility: hidden !important; visibility: hidden !important;
} }
.bx-invisible {
opacity: 0;
}
.bx-unclickable {
pointer-events: none;
}
.bx-pixel {
width: 1px !important;
height: 1px !important;
}
.bx-no-margin { .bx-no-margin {
margin: 0 !important; margin: 0 !important;
} }
@ -88,6 +101,10 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
padding: 0 !important; padding: 0 !important;
} }
.bx-prompt {
font-family: var(--bx-promptfont-font);
}
/* Hide UI elements */ /* Hide UI elements */
#headerArea, #uhfSkipToMain, .uhf-footer { #headerArea, #uhfSkipToMain, .uhf-footer {
display: none; display: none;
@ -104,3 +121,10 @@ div[class*=NotFocusedDialog] {
#game-stream video:not([src]) { #game-stream video:not([src]) {
visibility: hidden; visibility: hidden;
} }
/* Hide Controller icon in Game tiles */
div[class*=SupportedInputsBadge] {
&:not(:has(:nth-child(2))), svg:first-of-type {
display: none;
}
}

View File

@ -1,46 +0,0 @@
.bx-screenshot-button {
display: none;
opacity: 0;
position: fixed;
bottom: 0;
box-sizing: border-box;
width: 60px;
height: 90px;
padding: 16px 16px 46px 16px;
background-size: cover;
background-repeat: no-repeat;
background-origin: content-box;
filter: drop-shadow(0 0 2px #000000B0);
transition: opacity 0.1s ease-in-out 0s, padding 0.1s ease-in 0s;
z-index: var(--bx-screenshot-z-index);
/* Credit: https://phosphoricons.com */
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDMyIDMyIiBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMjguMzA4IDUuMDM4aC00LjI2NWwtMi4wOTctMy4xNDVhMS4yMyAxLjIzIDAgMCAwLTEuMDIzLS41NDhoLTkuODQ2YTEuMjMgMS4yMyAwIDAgMC0xLjAyMy41NDhMNy45NTYgNS4wMzhIMy42OTJBMy43MSAzLjcxIDAgMCAwIDAgOC43MzF2MTcuMjMxYTMuNzEgMy43MSAwIDAgMCAzLjY5MiAzLjY5MmgyNC42MTVBMy43MSAzLjcxIDAgMCAwIDMyIDI1Ljk2MlY4LjczMWEzLjcxIDMuNzEgMCAwIDAtMy42OTItMy42OTJ6bS02Ljc2OSAxMS42OTJjMCAzLjAzOS0yLjUgNS41MzgtNS41MzggNS41MzhzLTUuNTM4LTIuNS01LjUzOC01LjUzOCAyLjUtNS41MzggNS41MzgtNS41MzggNS41MzggMi41IDUuNTM4IDUuNTM4eiIvPjwvc3ZnPgo=');
&[data-showing=true] {
opacity: 0.9;
}
&[data-capturing=true] {
padding: 8px 8px 38px 8px;
}
}
.bx-screenshot-canvas {
display: none;
}
#bx-touch-controller-bar {
display: none;
opacity: 0;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 6vh;
z-index: var(--bx-touch-controller-bar-z-index);
&[data-showing=true] {
display: block;
}
}

View File

@ -1,4 +1,4 @@
.bx-quick-settings-bar { .bx-stream-settings-dialog {
display: flex; display: flex;
position: fixed; position: fixed;
z-index: var(--bx-stream-settings-z-index); z-index: var(--bx-stream-settings-z-index);
@ -7,7 +7,21 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
.bx-quick-settings-tabs { .bx-stream-settings-overlay {
position: fixed;
background: #0b0b0be3;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--bx-stream-settings-overlay-z-index);
&[data-is-playing="true"] {
background: transparent;
}
}
.bx-stream-settings-tabs {
position: fixed; position: fixed;
top: 0; top: 0;
right: 420px; right: 420px;
@ -39,7 +53,7 @@
} }
.bx-quick-settings-tab-contents { .bx-stream-settings-tab-contents {
flex-direction: column; flex-direction: column;
position: fixed; position: fixed;
right: 0; right: 0;
@ -86,23 +100,16 @@
white-space: nowrap; white-space: nowrap;
} }
} }
input[type="range"] {
display: block;
margin: 12px auto 2px;
width: 180px;
color: #959595 !important;
}
} }
.bx-quick-settings-row { .bx-stream-settings-row {
display: flex; display: flex;
border-bottom: 1px solid #40404080; border-bottom: 1px solid #40404080;
margin-bottom: 16px; margin-bottom: 16px;
padding-bottom: 16px; padding-bottom: 16px;
label { > label {
font-size: 16px; font-size: 16px;
display: block; display: block;
text-align: left; text-align: left;
@ -120,14 +127,82 @@
background: transparent; background: transparent;
text-align-last: right; text-align-last: right;
border: none; border: none;
color: #fff;
}
select option:disabled {
display: none;
} }
} }
.bx-quick-settings-bar-note { .bx-stream-settings-dialog-note {
display: block; display: block;
text-align: center;
font-size: 12px; font-size: 12px;
font-weight: lighter; font-weight: lighter;
font-style: italic; font-style: italic;
padding-top: 16px; }
.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

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

View File

@ -7,3 +7,76 @@ div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] {
background-color: #2d2d2d !important; background-color: #2d2d2d !important;
color: #000 !important; color: #000 !important;
} }
.bx-stream-refresh-button {
top: calc(env(safe-area-inset-top, 0px) + 10px + 50px) !important;
}
body[data-media-type=default] .bx-stream-refresh-button {
left: calc(env(safe-area-inset-left, 0px) + 11px) !important;
}
body[data-media-type=tv] .bx-stream-refresh-button {
top: calc(var(--gds-focus-borderSize) + 80px) !important;
}
.bx-stream-home-button {
top: calc(env(safe-area-inset-top, 0px) + 10px + 50px * 2) !important;
}
body[data-media-type=default] .bx-stream-home-button {
left: calc(env(safe-area-inset-left, 0px) + 12px) !important;
}
body[data-media-type=tv] .bx-stream-home-button {
top: calc(var(--gds-focus-borderSize) + 80px * 2) !important;
}
@keyframes bx-anim-taking-screenshot {
0% {
border: 0px solid #ffffff80;
}
50% {
border: 8px solid #ffffff80;
}
100% {
border: 0px solid #ffffff80;
}
}
div[data-testid=media-container] {
display: flex;
&.bx-taking-screenshot:before {
animation: bx-anim-taking-screenshot 0.5s ease;
content: ' ';
position: absolute;
width: 100%;
height: 100%;
z-index: var(--bx-screenshot-animation-z-index);
}
}
#game-stream video {
margin: auto;
align-self: center;
background: #000;
}
#game-stream canvas {
position: absolute;
align-self: center;
margin: auto;
left: 0;
right: 0;
}
#gamepass-dialog-root div[class^=Guide-module__guide] {
.bx-button {
overflow: visible;
margin-bottom: 12px;
}
}

View File

@ -7,10 +7,11 @@
@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 'stream-actions.styl'; @import 'game-bar.styl';
@import 'stream-stats.styl'; @import 'stream-stats.styl';
@import 'stream-settings.styl'; @import 'stream-settings.styl';
@import 'mkb.styl'; @import 'mkb.styl';

View File

@ -23,6 +23,7 @@
&.bx-hide { &.bx-hide {
opacity: 0; opacity: 0;
pointer-events: none;
} }
} }

View File

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

View File

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

After

Width:  |  Height:  |  Size: 821 B

View File

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

After

Width:  |  Height:  |  Size: 494 B

View File

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

After

Width:  |  Height:  |  Size: 386 B

View File

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

After

Width:  |  Height:  |  Size: 385 B

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

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

After

Width:  |  Height:  |  Size: 374 B

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

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

After

Width:  |  Height:  |  Size: 427 B

View File

@ -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='M25.425 1.5c2.784 0 5.075 2.291 5.075 5.075s-2.291 5.075-5.075 5.075H20.35V6.575c0-2.784 2.291-5.075 5.075-5.075zM11.65 11.65H6.575C3.791 11.65 1.5 9.359 1.5 6.575S3.791 1.5 6.575 1.5s5.075 2.291 5.075 5.075v5.075zm8.7 8.7h5.075c2.784 0 5.075 2.291 5.075 5.075S28.209 30.5 25.425 30.5s-5.075-2.291-5.075-5.075V20.35zM6.575 30.5c-2.784 0-5.075-2.291-5.075-5.075s2.291-5.075 5.075-5.075h5.075v5.075c0 2.784-2.291 5.075-5.075 5.075z'/>
<path d='M11.65 11.65h8.7v8.7h-8.7z'/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M19.193 12.807h3.193m-13.836 0h4.257'/><path d='M10.678 10.678v4.257'/><path d='M13.061 19.193l-5.602 6.359c-.698.698-1.646 1.09-2.633 1.09-2.044 0-3.725-1.682-3.725-3.725a3.73 3.73 0 0 1 .056-.646l2.177-11.194a6.94 6.94 0 0 1 6.799-5.721h11.722c3.795 0 6.918 3.123 6.918 6.918s-3.123 6.918-6.918 6.918h-8.793z'/><path d='M18.939 19.193l5.602 6.359c.698.698 1.646 1.09 2.633 1.09 2.044 0 3.725-1.682 3.725-3.725a3.73 3.73 0 0 0-.056-.646l-2.177-11.194'/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

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

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' viewBox='0 0 32 32'>
<path d='M1.498 6.772h23.73v23.73H1.498zm5.274-5.274h23.73v23.73'/>
</svg>

After

Width:  |  Height:  |  Size: 250 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' viewBox='0 0 32 32'>
<path d='M16 7.3a5.83 5.83 0 0 1 5.8-5.8h2.9m0 29h-2.9a5.83 5.83 0 0 1-5.8-5.8'/><path d='M7.3 30.5h2.9a5.83 5.83 0 0 0 5.8-5.8V7.3a5.83 5.83 0 0 0-5.8-5.8H7.3'/><path d='M11.65 16h8.7'/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M1.238 21.119c0 1.928 1.565 3.493 3.493 3.493H27.27c1.928 0 3.493-1.565 3.493-3.493V5.961c0-1.928-1.565-3.493-3.493-3.493H4.731c-1.928 0-3.493 1.565-3.493 3.493v15.158zm19.683 8.413H11.08'/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View File

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

After

Width:  |  Height:  |  Size: 355 B

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

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

After

Width:  |  Height:  |  Size: 358 B

View File

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

After

Width:  |  Height:  |  Size: 551 B

View File

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

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' viewBox='0 0 32 32'>
<g transform='matrix(1.10403 0 0 1.10403 -4.17656 -.560429)' fill='none' stroke='#fff'><g stroke-width='1.755'><path d='M24.49 16.255l.01-8.612A6.15 6.15 0 0 0 18.357 1.5h-5.714A6.15 6.15 0 0 0 6.5 7.643v13.715a6.15 6.15 0 0 0 6.143 6.143h5.714'/><path d='M15.5 12.501v-6'/></g><circle cx='48' cy='48' r='15' stroke-width='7.02' transform='matrix(.142357 0 0 .142357 17.667421 16.541885)'/><path d='M24.61 27.545h-.214l-1.711.955c-.666-.224-1.284-.572-1.821-1.025l-.006-1.922-.107-.182-1.701-.969c-.134-.678-.134-1.375 0-2.053l1.7-.966.107-.182.009-1.922c.537-.454 1.154-.803 1.82-1.029l1.708.955h.214l1.708-.955c.666.224 1.284.572 1.821 1.025l.006 1.922.107.182 1.7.968c.134.678.134 1.375 0 2.053l-1.7.966-.107.182-.009 1.922c-.536.455-1.154.804-1.819 1.029l-1.706-.955z' stroke-width='.999'/></g>
</svg>

After

Width:  |  Height:  |  Size: 981 B

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

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M26.256 8.185c0-3.863-3.137-7-7-7h-6.512c-3.863 0-7 3.137-7 7v15.629c0 3.863 3.137 7 7 7h6.512c3.863 0 7-3.137 7-7V8.185z'/><path d='M16 13.721V6.883'/>
</svg>

After

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

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

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' viewBox='0 0 32 32'>
<path d='M26.875 30.5H5.125c-.663 0-1.208-.545-1.208-1.208V2.708c0-.663.545-1.208 1.208-1.208h14.5l8.458 8.458v19.333c0 .663-.545 1.208-1.208 1.208z'/><path d='M19.625 1.5v8.458h8.458m-15.708 9.667h7.25'/><path d='M16 16v7.25'/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' viewBox='0 0 32 32'>
<g transform='matrix(.256867 0 0 .256867 -16.878964 -18.049342)'><circle cx='128' cy='180' r='12' fill='#fff'/><path d='M128 144v-8c17.67 0 32-12.54 32-28s-14.33-28-32-28-32 12.54-32 28v4' fill='none' stroke='#fff' stroke-width='16'/></g>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

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

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' viewBox='0 0 32 32'>
<g transform='matrix(.492308 0 0 .581818 -14.7692 -11.6364)'><clipPath id='A'><path d='M30 20h65v55H30z'/></clipPath><g clip-path='url(#A)'><g transform='matrix(.395211 0 0 .334409 11.913 7.01124)'><g transform='matrix(.555556 0 0 .555556 57.8889 -20.2417)' fill='none' stroke='#fff' stroke-width='13.88'><path d='M200 140.564c-42.045-33.285-101.955-33.285-144 0M168 165c-23.783-17.3-56.217-17.3-80 0'/></g><g transform='matrix(-.555556 0 0 -.555556 200.111 262.393)'><g transform='matrix(1 0 0 1 0 11.5642)'><path d='M200 129c-17.342-13.728-37.723-21.795-58.636-24.198C111.574 101.378 80.703 109.444 56 129' fill='none' stroke='#fff' stroke-width='13.88'/></g><path d='M168 165c-23.783-17.3-56.217-17.3-80 0' fill='none' stroke='#fff' stroke-width='13.88'/></g><g transform='matrix(.75 0 0 .75 32 32)'><path d='M24 72h208v93.881H24z' fill='none' stroke='#fff' stroke-linejoin='miter' stroke-width='9.485'/><circle cx='188' cy='128' r='12' stroke-width='10' transform='matrix(.708333 0 0 .708333 71.8333 12.8333)'/><path d='M24.358 103.5h110' fill='none' stroke='#fff' stroke-linecap='butt' stroke-width='10.282'/></g></g></g></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 410 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<g transform='matrix(.142357 0 0 .142357 -2.22021 -2.22164)' fill='none' stroke='#fff' stroke-width='16'><circle cx='128' cy='128' r='40'/><path d='M130.05 206.11h-4L94 224c-12.477-4.197-24.049-10.711-34.11-19.2l-.12-36c-.71-1.12-1.38-2.25-2-3.41L25.9 147.24a99.16 99.16 0 0 1 0-38.46l31.84-18.1c.65-1.15 1.32-2.29 2-3.41l.16-36C69.951 42.757 81.521 36.218 94 32l32 17.89h4L162 32c12.477 4.197 24.049 10.711 34.11 19.2l.12 36c.71 1.12 1.38 2.25 2 3.41l31.85 18.14a99.16 99.16 0 0 1 0 38.46l-31.84 18.1c-.65 1.15-1.32 2.29-2 3.41l-.16 36A104.59 104.59 0 0 1 162 224l-31.95-17.89z'/></g>
</svg>

After

Width:  |  Height:  |  Size: 768 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M1.181 24.55v-3.259c0-8.19 6.576-14.952 14.767-14.98H16c8.13 0 14.819 6.69 14.819 14.819v3.42c0 .625-.515 1.14-1.14 1.14H2.321c-.625 0-1.14-.515-1.14-1.14z'/><path d='M16 6.311v4.56M12.58 25.69l9.12-12.54m4.559 5.7h4.386m-29.266 0H5.74'/>
</svg>

After

Width:  |  Height:  |  Size: 430 B

View File

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

After

Width:  |  Height:  |  Size: 915 B

View File

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

After

Width:  |  Height:  |  Size: 796 B

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

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' viewBox='0 0 32 32'>
<path d='M29.5 6.182h-27m9.818 7.363v9.818m7.364-9.818v9.818'/><path d='M27.045 6.182V29.5c0 .673-.554 1.227-1.227 1.227H6.182c-.673 0-1.227-.554-1.227-1.227V6.182m17.181 0V3.727a2.47 2.47 0 0 0-2.455-2.455h-7.364a2.47 2.47 0 0 0-2.455 2.455v2.455'/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

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

After

Width:  |  Height:  |  Size: 349 B

View File

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

After

Width:  |  Height:  |  Size: 680 B

View File

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

102
src/enums/mkb.ts Normal file
View File

@ -0,0 +1,102 @@
import type { GamepadKeyNameType } from "@/types/mkb";
import { PrompFont } from "@enums/prompt-font";
export enum GamepadKey {
A = 0,
B = 1,
X = 2,
Y = 3,
LB = 4,
RB = 5,
LT = 6,
RT = 7,
SELECT = 8,
START = 9,
L3 = 10,
R3 = 11,
UP = 12,
DOWN = 13,
LEFT = 14,
RIGHT = 15,
HOME = 16,
SHARE = 17,
LS_UP = 100,
LS_DOWN = 101,
LS_LEFT = 102,
LS_RIGHT = 103,
RS_UP = 200,
RS_DOWN = 201,
RS_LEFT = 202,
RS_RIGHT = 203,
};
export const GamepadKeyName: GamepadKeyNameType = {
[GamepadKey.A]: ['A', PrompFont.A],
[GamepadKey.B]: ['B', PrompFont.B],
[GamepadKey.X]: ['X', PrompFont.X],
[GamepadKey.Y]: ['Y', PrompFont.Y],
[GamepadKey.LB]: ['LB', PrompFont.LB],
[GamepadKey.RB]: ['RB', PrompFont.RB],
[GamepadKey.LT]: ['LT', PrompFont.LT],
[GamepadKey.RT]: ['RT', PrompFont.RT],
[GamepadKey.SELECT]: ['Select', PrompFont.SELECT],
[GamepadKey.START]: ['Start', PrompFont.START],
[GamepadKey.HOME]: ['Home', PrompFont.HOME],
[GamepadKey.UP]: ['D-Pad Up', PrompFont.UP],
[GamepadKey.DOWN]: ['D-Pad Down', PrompFont.DOWN],
[GamepadKey.LEFT]: ['D-Pad Left', PrompFont.LEFT],
[GamepadKey.RIGHT]: ['D-Pad Right', PrompFont.RIGHT],
[GamepadKey.L3]: ['L3', PrompFont.L3],
[GamepadKey.LS_UP]: ['Left Stick Up', PrompFont.LS_UP],
[GamepadKey.LS_DOWN]: ['Left Stick Down', PrompFont.LS_DOWN],
[GamepadKey.LS_LEFT]: ['Left Stick Left', PrompFont.LS_LEFT],
[GamepadKey.LS_RIGHT]: ['Left Stick Right', PrompFont.LS_RIGHT],
[GamepadKey.R3]: ['R3', PrompFont.R3],
[GamepadKey.RS_UP]: ['Right Stick Up', PrompFont.RS_UP],
[GamepadKey.RS_DOWN]: ['Right Stick Down', PrompFont.RS_DOWN],
[GamepadKey.RS_LEFT]: ['Right Stick Left', PrompFont.RS_LEFT],
[GamepadKey.RS_RIGHT]: ['Right Stick Right', PrompFont.RS_RIGHT],
};
export enum GamepadStick {
LEFT = 0,
RIGHT = 1,
};
export enum MouseButtonCode {
LEFT_CLICK = 'Mouse0',
RIGHT_CLICK = 'Mouse2',
MIDDLE_CLICK = 'Mouse1',
};
export enum MouseMapTo {
OFF = 0,
LS = 1,
RS = 2,
}
export enum WheelCode {
SCROLL_UP = 'ScrollUp',
SCROLL_DOWN = 'ScrollDown',
SCROLL_LEFT = 'ScrollLeft',
SCROLL_RIGHT = 'ScrollRight',
};
export enum MkbPresetKey {
MOUSE_MAP_TO = 'map_to',
MOUSE_SENSITIVITY_X = 'sensitivity_x',
MOUSE_SENSITIVITY_Y = 'sensitivity_y',
MOUSE_DEADZONE_COUNTERWEIGHT = 'deadzone_counterweight',
}

32
src/enums/prompt-font.ts Normal file
View File

@ -0,0 +1,32 @@
export enum PrompFont {
A = '⇓',
B = '⇒',
X = '⇐',
Y = '⇑',
LB = '↘',
RB = '↙',
LT = '↖',
RT = '↗',
SELECT = '⇺',
START = '⇻',
HOME = '',
UP = '≻',
DOWN = '≽',
LEFT = '≺',
RIGHT = '≼',
L3 = '↺',
LS_UP = '↾',
LS_DOWN = '⇂',
LS_LEFT = '↼',
LS_RIGHT = '⇀',
R3 = '↻',
RS_UP = '↿',
RS_DOWN = '⇃',
RS_LEFT = '↽',
RS_RIGHT = '⇁',
}

View File

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

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',
SMARTTV_GENERIC = 'smarttv-generic',
SMARTTV_TIZEN = 'smarttv-tizen',
VR_OCULUS = 'vr-oculus',
DEFAULT = 'default',
CUSTOM = 'custom',
}

View File

@ -1,31 +1,39 @@
import "./utils/global"; import "@utils/global";
import { BxEvent } 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 { setupBxUi, updateVideoPlayerCss } from "./modules/ui/ui"; import { setupStreamUi } from "@modules/ui/ui";
import { PrefKey, getPref } from "./utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { LoadingScreen } from "./modules/loading-screen"; import { LoadingScreen } from "@modules/loading-screen";
import { MouseCursorHider } from "./modules/mkb/mouse-cursor-hider"; import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider";
import { TouchController } from "./modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { watchHeader } from "./modules/ui/header"; import { 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 { PreloadedState } from "./utils/titles-info"; import { overridePreloadState } from "@utils/preload-state";
import { patchAudioContext, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "./utils/monkey-patches"; import { patchAudioContext, patchCanvasContext, patchMeControl, patchPointerLockApi, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches";
import { STATES } from "./utils/global"; import { AppInterface, STATES } from "@utils/global";
import { injectStreamMenuButtons } from "./modules/stream/stream-ui"; import { injectStreamMenuButtons } from "@modules/stream/stream-ui";
import { BxLogger } from "@utils/bx-logger";
import { GameBar } from "./modules/game-bar/game-bar";
import { Screenshot } from "./utils/screenshot";
import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler";
import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu";
import { StreamSettings } from "./modules/stream/stream-settings";
import { updateVideoPlayer } from "./modules/stream/stream-settings-utils";
// Handle login page // Handle login page
if (window.location.pathname.includes('/auth/msa')) { if (window.location.pathname.includes('/auth/msa')) {
@ -40,7 +48,7 @@ if (window.location.pathname.includes('/auth/msa')) {
throw new Error('[Better xCloud] Refreshing the page after logging in'); throw new Error('[Better xCloud] Refreshing the page after logging in');
} }
console.log(`[Better xCloud] readyState: ${document.readyState}`); BxLogger.info('readyState', document.readyState);
if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') { if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') {
// Stop loading // Stop loading
@ -122,9 +130,7 @@ window.addEventListener(BxEvent.STREAM_LOADING, e => {
} }
// Setup UI // Setup UI
setupBxUi(); setupStreamUi();
}); });
// Setup loading screen // Setup loading screen
@ -142,80 +148,137 @@ window.addEventListener(BxEvent.STREAM_STARTING, e => {
}); });
window.addEventListener(BxEvent.STREAM_PLAYING, e => { window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const $video = (e as any).$video;
STATES.currentStream.$video = $video;
STATES.isPlaying = true; STATES.isPlaying = true;
injectStreamMenuButtons(); injectStreamMenuButtons();
/*
if (getPref(Preferences.CONTROLLER_ENABLE_SHORTCUTS)) { if (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') {
GamepadHandler.startPolling(); const gameBar = GameBar.getInstance();
gameBar.reset();
gameBar.enable();
gameBar.showBar();
} }
*/
const PREF_SCREENSHOT_BUTTON_POSITION = getPref(PrefKey.SCREENSHOT_BUTTON_POSITION); const $video = (e as any).$video as HTMLVideoElement;
STATES.currentStream.$screenshotCanvas!.width = $video.videoWidth; Screenshot.updateCanvasSize($video.videoWidth, $video.videoHeight);
STATES.currentStream.$screenshotCanvas!.height = $video.videoHeight;
updateVideoPlayerCss();
// Setup screenshot button updateVideoPlayer();
if (PREF_SCREENSHOT_BUTTON_POSITION !== 'none') {
const $btn = document.querySelector('.bx-screenshot-button')! as HTMLElement;
$btn.style.display = 'block';
if (PREF_SCREENSHOT_BUTTON_POSITION === 'bottom-right') {
$btn.style.right = '0';
} else {
$btn.style.left = '0';
}
}
}); });
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 => { function unload() {
if (!STATES.isPlaying) { if (!STATES.isPlaying) {
return; return;
} }
STATES.isPlaying = false;
// Stop MKB listeners // Stop MKB listeners
getPref(PrefKey.MKB_ENABLED) && MkbHandler.INSTANCE.destroy(); EmulatedMkbHandler.getInstance().destroy();
NativeMkbHandler.getInstance().destroy();
const $quickBar = document.querySelector('.bx-quick-settings-bar'); // Destroy StreamPlayer
if ($quickBar) { STATES.currentStream.streamPlayer?.destroy();
$quickBar.classList.add('bx-gone');
}
STATES.currentStream.audioGainNode = null; STATES.isPlaying = false;
STATES.currentStream.$video = null; STATES.currentStream = {};
StreamStats.onStoppedPlaying(); window.BX_EXPOSED.shouldShowSensorControls = false;
window.BX_EXPOSED.stopTakRendering = false;
const $screenshotBtn = document.querySelector('.bx-screenshot-button'); StreamSettings.getInstance().hide();
if ($screenshotBtn) { StreamStats.getInstance().onStoppedPlaying();
$screenshotBtn.removeAttribute('style');
}
MouseCursorHider.stop(); MouseCursorHider.stop();
TouchController.reset(); TouchController.reset();
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 => {
Screenshot.takeScreenshot();
}); });
function observeRootDialog($root: HTMLElement) {
let currentShown = false;
const observer = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
if (mutation.type !== 'childList') {
continue;
}
if (mutation.addedNodes.length === 1) {
const $addedElm = mutation.addedNodes[0];
if ($addedElm instanceof HTMLElement && $addedElm.className) {
if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) {
// Make sure it's Guide dialog
if (document.querySelector('#gamepass-dialog-root div[class*=GuideDialog]')) {
// Find navigation bar
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
if ($selectedTab) {
let $elm: Element | null = $selectedTab;
let index;
for (index = 0; ($elm = $elm?.previousElementSibling); index++);
if (index === 0) {
BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
}
}
}
}
}
}
const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false;
if (shown !== currentShown) {
currentShown = shown;
BxEvent.dispatch(window, shown ? BxEvent.XCLOUD_DIALOG_SHOWN : BxEvent.XCLOUD_DIALOG_DISMISSED);
}
}
});
observer.observe($root, {subtree: true, childList: true});
}
function waitForRootDialog() {
const observer = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
if (mutation.type !== 'childList') {
continue;
}
const $target = mutation.target as HTMLElement;
if ($target.id && $target.id === 'gamepass-dialog-root') {
observer.disconnect();
observeRootDialog($target);
break;
}
};
});
observer.observe(document.documentElement, {subtree: true, childList: true});
}
function main() { function main() {
waitForRootDialog();
// Monkey patches // Monkey patches
patchRtcPeerConnection(); patchRtcPeerConnection();
patchRtcCodecs(); patchRtcCodecs();
interceptHttpRequests(); interceptHttpRequests();
patchVideoApi(); patchVideoApi();
patchCanvasContext();
AppInterface && patchPointerLockApi();
if (getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL)) { getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext();
patchAudioContext(); getPref(PrefKey.BLOCK_TRACKING) && patchMeControl();
}
PreloadedState.override(); STATES.userAgentHasTouchSupport && TouchController.updateCustomList();
overridePreloadState();
VibrationManager.initialSetup(); VibrationManager.initialSetup();
@ -224,20 +287,26 @@ function main() {
// Setup UI // Setup UI
addCss(); addCss();
preloadFonts();
Toast.setup(); Toast.setup();
BX_FLAGS.PreloadUi && setupBxUi(); (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance();
BX_FLAGS.PreloadUi && setupStreamUi();
Screenshot.setup();
GuideMenu.observe();
StreamBadges.setupEvents(); StreamBadges.setupEvents();
StreamStats.setupEvents(); StreamStats.setupEvents();
MkbHandler.setupEvents(); EmulatedMkbHandler.setupEvents();
Patcher.initialize(); 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)) {
@ -247,6 +316,12 @@ function main() {
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') { if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
TouchController.setup(); TouchController.setup();
} }
// Start PointerProviderServer
if (getPref(PrefKey.MKB_ENABLED) && AppInterface) {
STATES.pointerServerPort = AppInterface.startPointerServer() || 9269;
BxLogger.info('startPointerServer', 'Port', STATES.pointerServerPort.toString());
}
} }
main(); main();

View File

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

View File

@ -0,0 +1,369 @@
import { Screenshot } from "@utils/screenshot";
import { GamepadKey } from "@enums/mkb";
import { PrompFont } from "@enums/prompt-font";
import { CE } from "@utils/html";
import { t } from "@utils/translation";
import { EmulatedMkbHandler } from "./mkb/mkb-handler";
import { StreamStats } from "./stream/stream-stats";
import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone";
import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui";
import { PrefKey, getPref } from "@utils/preferences";
import { SoundShortcut } from "./shortcuts/shortcut-sound";
import { BxEvent } from "@/utils/bx-event";
import { AppInterface } from "@/utils/global";
enum ShortcutAction {
STREAM_SCREENSHOT_CAPTURE = 'stream-screenshot-capture',
STREAM_MENU_SHOW = 'stream-menu-show',
STREAM_STATS_TOGGLE = 'stream-stats-toggle',
STREAM_SOUND_TOGGLE = 'stream-sound-toggle',
STREAM_MICROPHONE_TOGGLE = 'stream-microphone-toggle',
STREAM_VOLUME_INC = 'stream-volume-inc',
STREAM_VOLUME_DEC = 'stream-volume-dec',
DEVICE_SOUND_TOGGLE = 'device-sound-toggle',
DEVICE_VOLUME_INC = 'device-volume-inc',
DEVICE_VOLUME_DEC = 'device-volume-dec',
DEVICE_BRIGHTNESS_INC = 'device-brightness-inc',
DEVICE_BRIGHTNESS_DEC = 'device-brightness-dec',
}
export class ControllerShortcut {
static readonly #STORAGE_KEY = 'better_xcloud_controller_shortcuts';
static #buttonsCache: {[key: string]: boolean[]} = {};
static #buttonsStatus: {[key: string]: boolean[]} = {};
static #$selectProfile: HTMLSelectElement;
static #$selectActions: Partial<{[key in GamepadKey]: HTMLSelectElement}> = {};
static #$container: HTMLElement;
static #ACTIONS: {[key: string]: (ShortcutAction | null)[]} = {};
static reset(index: number) {
ControllerShortcut.#buttonsCache[index] = [];
ControllerShortcut.#buttonsStatus[index] = [];
}
static handle(gamepad: Gamepad): boolean {
const gamepadIndex = gamepad.index;
const actions = ControllerShortcut.#ACTIONS[gamepad.id];
if (!actions) {
return false;
}
// Move the buttons status from the previous frame to the cache
ControllerShortcut.#buttonsCache[gamepadIndex] = ControllerShortcut.#buttonsStatus[gamepadIndex].slice(0);
// Clear the buttons status
ControllerShortcut.#buttonsStatus[gamepadIndex] = [];
const pressed: boolean[] = [];
let otherButtonPressed = false;
gamepad.buttons.forEach((button, index) => {
// Only add the newly pressed button to the array (holding doesn't count)
if (button.pressed && index !== GamepadKey.HOME) {
otherButtonPressed = true;
pressed[index] = true;
// If this is newly pressed button -> run action
if (actions[index] && !ControllerShortcut.#buttonsCache[gamepadIndex][index]) {
setTimeout(() => ControllerShortcut.#runAction(actions[index]!), 0);
}
}
});
ControllerShortcut.#buttonsStatus[gamepadIndex] = pressed;
return otherButtonPressed;
}
static #runAction(action: ShortcutAction) {
switch (action) {
case ShortcutAction.STREAM_SCREENSHOT_CAPTURE:
Screenshot.takeScreenshot();
break;
case ShortcutAction.STREAM_STATS_TOGGLE:
StreamStats.getInstance().toggle();
break;
case ShortcutAction.STREAM_MICROPHONE_TOGGLE:
MicrophoneShortcut.toggle();
break;
case ShortcutAction.STREAM_MENU_SHOW:
StreamUiShortcut.showHideStreamMenu();
break;
case ShortcutAction.STREAM_SOUND_TOGGLE:
SoundShortcut.muteUnmute();
break;
case ShortcutAction.STREAM_VOLUME_INC:
SoundShortcut.adjustGainNodeVolume(10);
break;
case ShortcutAction.STREAM_VOLUME_DEC:
SoundShortcut.adjustGainNodeVolume(-10);
break;
case ShortcutAction.DEVICE_BRIGHTNESS_INC:
case ShortcutAction.DEVICE_BRIGHTNESS_DEC:
case ShortcutAction.DEVICE_SOUND_TOGGLE:
case ShortcutAction.DEVICE_VOLUME_INC:
case ShortcutAction.DEVICE_VOLUME_DEC:
AppInterface && AppInterface.runShortcut && AppInterface.runShortcut(action);
break;
}
}
static #updateAction(profile: string, button: GamepadKey, action: ShortcutAction | null) {
if (!(profile in ControllerShortcut.#ACTIONS)) {
ControllerShortcut.#ACTIONS[profile] = [];
}
if (!action) {
action = null;
}
ControllerShortcut.#ACTIONS[profile][button] = action;
// Remove empty profiles
for (const key in ControllerShortcut.#ACTIONS) {
let empty = true;
for (const value of ControllerShortcut.#ACTIONS[key]) {
if (!!value) {
empty = false;
break;
}
}
if (empty) {
delete ControllerShortcut.#ACTIONS[key];
}
}
// Save to storage
window.localStorage.setItem(ControllerShortcut.#STORAGE_KEY, JSON.stringify(ControllerShortcut.#ACTIONS));
console.log(ControllerShortcut.#ACTIONS);
}
static #updateProfileList(e?: GamepadEvent) {
const $select = ControllerShortcut.#$selectProfile;
const $container = ControllerShortcut.#$container;
const $fragment = document.createDocumentFragment();
// Remove old profiles
while ($select.firstElementChild) {
$select.firstElementChild.remove();
}
const gamepads = navigator.getGamepads();
let hasGamepad = false;
for (const gamepad of gamepads) {
if (!gamepad || !gamepad.connected) {
continue;
}
// Ignore emulated gamepad
if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
continue;
}
hasGamepad = true;
const $option = CE<HTMLOptionElement>('option', {value: gamepad.id}, gamepad.id);
$fragment.appendChild($option);
}
if (hasGamepad) {
$select.appendChild($fragment);
$select.selectedIndex = 0;
$select.dispatchEvent(new Event('change'));
}
$container.dataset.hasGamepad = hasGamepad.toString();
}
static #switchProfile(profile: string) {
let actions = ControllerShortcut.#ACTIONS[profile];
if (!actions) {
actions = [];
}
// Reset selects' values
let button: any;
for (button in ControllerShortcut.#$selectActions) {
const $select = ControllerShortcut.#$selectActions[button as GamepadKey]!;
$select.value = actions[button] || '';
BxEvent.dispatch($select, 'change', {
ignoreOnChange: true,
});
}
}
static renderSettings() {
// Read actions from localStorage
ControllerShortcut.#ACTIONS = JSON.parse(window.localStorage.getItem(ControllerShortcut.#STORAGE_KEY) || '{}');
const buttons: Map<GamepadKey, PrompFont> = new Map();
buttons.set(GamepadKey.Y, PrompFont.Y);
buttons.set(GamepadKey.A, PrompFont.A);
buttons.set(GamepadKey.B, PrompFont.B);
buttons.set(GamepadKey.X, PrompFont.X);
buttons.set(GamepadKey.UP, PrompFont.UP);
buttons.set(GamepadKey.DOWN, PrompFont.DOWN);
buttons.set(GamepadKey.LEFT, PrompFont.LEFT);
buttons.set(GamepadKey.RIGHT, PrompFont.RIGHT);
buttons.set(GamepadKey.SELECT, PrompFont.SELECT);
buttons.set(GamepadKey.START, PrompFont.START);
buttons.set(GamepadKey.LB, PrompFont.LB);
buttons.set(GamepadKey.RB, PrompFont.RB);
buttons.set(GamepadKey.LT, PrompFont.LT);
buttons.set(GamepadKey.RT, PrompFont.RT);
buttons.set(GamepadKey.L3, PrompFont.L3);
buttons.set(GamepadKey.R3, PrompFont.R3);
const actions: {[key: string]: Partial<{[key in ShortcutAction]: string | string[]}>} = {
[t('device')]: AppInterface && {
[ShortcutAction.DEVICE_SOUND_TOGGLE]: [t('sound'), t('toggle')],
[ShortcutAction.DEVICE_VOLUME_INC]: [t('volume'), t('increase')],
[ShortcutAction.DEVICE_VOLUME_DEC]: [t('volume'), t('decrease')],
[ShortcutAction.DEVICE_BRIGHTNESS_INC]: [t('brightness'), t('increase')],
[ShortcutAction.DEVICE_BRIGHTNESS_DEC]: [t('brightness'), t('decrease')],
},
[t('stream')]: {
[ShortcutAction.STREAM_SCREENSHOT_CAPTURE]: t('take-screenshot'),
[ShortcutAction.STREAM_SOUND_TOGGLE]: [t('sound'), t('toggle')],
[ShortcutAction.STREAM_VOLUME_INC]: getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && [t('volume'), t('increase')],
[ShortcutAction.STREAM_VOLUME_DEC]: getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && [t('volume'), t('decrease')],
[ShortcutAction.STREAM_MENU_SHOW]: [t('menu'), t('show')],
[ShortcutAction.STREAM_STATS_TOGGLE]: [t('stats'), t('show-hide')],
[ShortcutAction.STREAM_MICROPHONE_TOGGLE]: [t('microphone'), t('toggle')],
}
};
const $baseSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'}, CE('option', {value: ''}, '---'));
for (const groupLabel in actions) {
const items = actions[groupLabel];
if (!items) {
continue;
}
const $optGroup = CE<HTMLOptGroupElement>('optgroup', {'label': groupLabel});
for (const action in items) {
let label = items[action as keyof typeof items];
if (!label) {
continue;
}
if (Array.isArray(label)) {
label = label.join(' ');
}
const $option = CE<HTMLOptionElement>('option', {value: action}, label);
$optGroup.appendChild($option);
}
$baseSelect.appendChild($optGroup);
}
let $remap: HTMLElement;
let $selectProfile: HTMLSelectElement;
const $container = CE('div', {'data-has-gamepad': 'false'},
CE('div', {},
CE('p', {'class': 'bx-shortcut-note'}, t('controller-shortcuts-connect-note')),
),
$remap = CE('div', {},
$selectProfile = CE('select', {'class': 'bx-shortcut-profile', autocomplete: 'off'}),
CE('p', {'class': 'bx-shortcut-note'},
CE('span', {'class': 'bx-prompt'}, PrompFont.HOME),
': ' + t('controller-shortcuts-xbox-note'),
),
),
);
$selectProfile.addEventListener('change', e => {
ControllerShortcut.#switchProfile($selectProfile.value);
});
const onActionChanged = (e: Event) => {
const $target = e.target as HTMLSelectElement;
const profile = $selectProfile.value;
const button: unknown = $target.dataset.button;
const action = $target.value as ShortcutAction;
const $fakeSelect = $target.previousElementSibling! as HTMLSelectElement;
let fakeText = '---';
if (action) {
const $selectedOption = $target.options[$target.selectedIndex];
const $optGroup = $selectedOption.parentElement as HTMLOptGroupElement;
fakeText = $optGroup.label + ' ' + $selectedOption.text;
}
($fakeSelect.firstElementChild as HTMLOptionElement).text = fakeText;
!(e as any).ignoreOnChange && ControllerShortcut.#updateAction(profile, button as GamepadKey, action);
};
// @ts-ignore
for (const [button, prompt] of buttons) {
const $row = CE('div', {'class': 'bx-shortcut-row'});
const $label = CE('label', {'class': 'bx-prompt'}, `${PrompFont.HOME} + ${prompt}`);
const $div = CE('div', {'class': 'bx-shortcut-actions'});
const $fakeSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'},
CE('option', {}, '---'),
);
$div.appendChild($fakeSelect);
const $select = $baseSelect.cloneNode(true) as HTMLSelectElement;
$select.dataset.button = button.toString();
$select.addEventListener('change', onActionChanged);
ControllerShortcut.#$selectActions[button] = $select;
$div.appendChild($select);
$row.appendChild($label);
$row.appendChild($div);
$remap.appendChild($row);
}
$container.appendChild($remap);
ControllerShortcut.#$selectProfile = $selectProfile;
ControllerShortcut.#$container = $container;
// Detect when gamepad connected/disconnect
window.addEventListener('gamepadconnected', ControllerShortcut.#updateProfileList);
window.addEventListener('gamepaddisconnected', ControllerShortcut.#updateProfileList);
ControllerShortcut.#updateProfileList();
return $container;
}
}

View File

@ -1,20 +1,21 @@
import { t } from "../utils/translation"; import { t } from "@utils/translation";
import { CE, createButton, ButtonStyle, Icon } from "../utils/html"; import { CE, createButton, ButtonStyle } from "@utils/html";
import { BxIcon } from "@utils/bx-icon";
type DialogOptions = { type DialogOptions = Partial<{
title?: string; title: string;
className?: string; className: string;
content?: string | HTMLElement; content: string | HTMLElement;
hideCloseButton?: boolean; hideCloseButton: boolean;
onClose?: string; onClose: string;
helpUrl?: string; helpUrl: string;
} }>;
export class Dialog { export class Dialog {
$dialog?: HTMLElement; $dialog: HTMLElement;
$title?: HTMLElement; $title: HTMLElement;
$content?: HTMLElement; $content: HTMLElement;
$overlay?: Element | null; $overlay: HTMLElement;
onClose: any; onClose: any;
@ -29,14 +30,17 @@ export class Dialog {
} = options; } = options;
// Create dialog overlay // Create dialog overlay
this.$overlay = document.querySelector('.bx-dialog-overlay'); const $overlay = document.querySelector('.bx-dialog-overlay') as HTMLElement;
if (!this.$overlay) {
if (!$overlay) {
this.$overlay = CE('div', {'class': 'bx-dialog-overlay bx-gone'}); this.$overlay = CE('div', {'class': 'bx-dialog-overlay bx-gone'});
// Disable right click // Disable right click
this.$overlay!.addEventListener('contextmenu', e => e.preventDefault()); this.$overlay.addEventListener('contextmenu', e => e.preventDefault());
document.documentElement.appendChild(this.$overlay!); document.documentElement.appendChild(this.$overlay);
} else {
this.$overlay = $overlay;
} }
let $close; let $close;
@ -44,14 +48,14 @@ export class Dialog {
this.$dialog = CE('div', {'class': `bx-dialog ${className || ''} bx-gone`}, this.$dialog = CE('div', {'class': `bx-dialog ${className || ''} bx-gone`},
this.$title = CE('h2', {}, CE('b', {}, title), this.$title = CE('h2', {}, CE('b', {}, title),
helpUrl && createButton({ helpUrl && createButton({
icon: Icon.QUESTION, icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST, style: ButtonStyle.GHOST,
title: t('help'), title: t('help'),
url: helpUrl, url: helpUrl,
}), }),
), ),
this.$content = CE('div', {'class': 'bx-dialog-content'}, content), this.$content = CE('div', {'class': 'bx-dialog-content'}, content),
!hideCloseButton && ($close = CE('button', {}, t('close'))), !hideCloseButton && ($close = CE('button', {type: 'button'}, t('close'))),
); );
$close && $close.addEventListener('click', e => { $close && $close.addEventListener('click', e => {
@ -72,19 +76,19 @@ export class Dialog {
document.activeElement && (document.activeElement as HTMLElement).blur(); document.activeElement && (document.activeElement as HTMLElement).blur();
if (newOptions && newOptions.title) { if (newOptions && newOptions.title) {
this.$title!.querySelector('b')!.textContent = newOptions.title; this.$title.querySelector('b')!.textContent = newOptions.title;
this.$title!.classList.remove('bx-gone'); this.$title.classList.remove('bx-gone');
} }
this.$dialog!.classList.remove('bx-gone'); this.$dialog.classList.remove('bx-gone');
this.$overlay!.classList.remove('bx-gone'); this.$overlay.classList.remove('bx-gone');
document.body.classList.add('bx-no-scroll'); document.body.classList.add('bx-no-scroll');
} }
hide(e?: any) { hide(e?: any) {
this.$dialog!.classList.add('bx-gone'); this.$dialog.classList.add('bx-gone');
this.$overlay!.classList.add('bx-gone'); this.$overlay.classList.add('bx-gone');
document.body.classList.remove('bx-no-scroll'); document.body.classList.remove('bx-no-scroll');
@ -92,7 +96,7 @@ export class Dialog {
} }
toggle() { toggle() {
this.$dialog!.classList.toggle('bx-gone'); this.$dialog.classList.toggle('bx-gone');
this.$overlay!.classList.toggle('bx-gone'); this.$overlay.classList.toggle('bx-gone');
} }
} }

View File

@ -0,0 +1,6 @@
export abstract class BaseGameBarAction {
constructor() {}
reset() {}
abstract render(): HTMLElement;
}

View File

@ -0,0 +1,66 @@
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { t } from "@utils/translation";
import { BaseGameBarAction } from "./action-base";
import { MicrophoneShortcut, MicrophoneState } from "../shortcuts/shortcut-microphone";
export class MicrophoneAction extends BaseGameBarAction {
$content: HTMLElement;
visible: boolean = false;
constructor() {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
const enabled = MicrophoneShortcut.toggle(false);
this.$content.setAttribute('data-enabled', enabled.toString());
};
const $btnDefault = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.MICROPHONE,
title: t('show-touch-controller'),
onClick: onClick,
classes: ['bx-activated'],
});
const $btnMuted = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.MICROPHONE_MUTED,
title: t('hide-touch-controller'),
onClick: onClick,
});
this.$content = CE('div', {},
$btnDefault,
$btnMuted,
);
this.reset();
window.addEventListener(BxEvent.MICROPHONE_STATE_CHANGED, e => {
const microphoneState = (e as any).microphoneState;
const enabled = microphoneState === MicrophoneState.ENABLED;
this.$content.setAttribute('data-enabled', enabled.toString());
// Show the button in Game Bar if the mic is enabled
this.$content.classList.remove('bx-gone');
});
}
render(): HTMLElement {
return this.$content;
}
reset(): void {
this.visible = false;
this.$content.classList.add('bx-gone');
this.$content.setAttribute('data-enabled', 'false');
}
}

View File

@ -0,0 +1,30 @@
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle } from "@utils/html";
import { BaseGameBarAction } from "./action-base";
import { t } from "@utils/translation";
import { Screenshot } from "@/utils/screenshot";
export class ScreenshotAction extends BaseGameBarAction {
$content: HTMLElement;
constructor() {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
Screenshot.takeScreenshot();
};
this.$content = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.SCREENSHOT,
title: t('take-screenshot'),
onClick: onClick,
});
}
render(): HTMLElement {
return this.$content;
}
}

View File

@ -0,0 +1,54 @@
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { TouchController } from "@modules/touch-controller";
import { BaseGameBarAction } from "./action-base";
import { t } from "@utils/translation";
export class TouchControlAction extends BaseGameBarAction {
$content: HTMLElement;
constructor() {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
const $parent = (e as any).target.closest('div[data-enabled]');
let enabled = $parent.getAttribute('data-enabled', 'true') === 'true';
$parent.setAttribute('data-enabled', (!enabled).toString());
TouchController.toggleVisibility(enabled);
};
const $btnEnable = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.TOUCH_CONTROL_ENABLE,
title: t('show-touch-controller'),
onClick: onClick,
classes: ['bx-activated'],
});
const $btnDisable = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.TOUCH_CONTROL_DISABLE,
title: t('hide-touch-controller'),
onClick: onClick,
});
this.$content = CE('div', {},
$btnEnable,
$btnDisable,
);
this.reset();
}
render(): HTMLElement {
return this.$content;
}
reset(): void {
this.$content.setAttribute('data-enabled', 'true');
}
}

View File

@ -0,0 +1,148 @@
import { CE, createSvgIcon } from "@utils/html";
import { ScreenshotAction } from "./action-screenshot";
import { TouchControlAction } from "./action-touch-control";
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import type { BaseGameBarAction } from "./action-base";
import { STATES } from "@utils/global";
import { PrefKey, getPref } from "@utils/preferences";
import { MicrophoneAction } from "./action-microphone";
export class GameBar {
private static instance: GameBar;
public static getInstance(): GameBar {
if (!GameBar.instance) {
GameBar.instance = new GameBar();
}
return GameBar.instance;
}
private static readonly VISIBLE_DURATION = 2000;
private $gameBar: HTMLElement;
private $container: HTMLElement;
private timeout: number | null = null;
private actions: BaseGameBarAction[] = [];
private constructor() {
let $container;
const position = getPref(PrefKey.GAME_BAR_POSITION);
const $gameBar = CE('div', {id: 'bx-game-bar', class: 'bx-gone', 'data-position': position},
$container = CE('div', {class: 'bx-game-bar-container bx-offscreen'}),
createSvgIcon(position === 'bottom-left' ? BxIcon.CARET_RIGHT : BxIcon.CARET_LEFT),
);
this.actions = [
new ScreenshotAction(),
...(STATES.userAgentHasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []),
new MicrophoneAction(),
];
// Reverse the action list if Game Bar's position is on the right side
if (position === 'bottom-right') {
this.actions.reverse();
}
// Render actions
for (const action of this.actions) {
$container.appendChild(action.render());
}
// Toggle game bar when clicking on the game bar box
$gameBar.addEventListener('click', e => {
if (e.target !== $gameBar) {
return;
}
$container.classList.contains('bx-show') ? this.hideBar() : this.showBar();
});
// Hide game bar after clicking on an action
window.addEventListener(BxEvent.GAME_BAR_ACTION_ACTIVATED, this.hideBar.bind(this));
$container.addEventListener('pointerover', this.clearHideTimeout.bind(this));
$container.addEventListener('pointerout', this.beginHideTimeout.bind(this));
// Add animation when hiding game bar
$container.addEventListener('transitionend', e => {
const classList = $container.classList;
if (classList.contains('bx-hide')) {
classList.remove('bx-offscreen', 'bx-hide');
classList.add('bx-offscreen');
}
});
document.documentElement.appendChild($gameBar);
this.$gameBar = $gameBar;
this.$container = $container;
// Enable/disable Game Bar when playing/pausing
getPref(PrefKey.GAME_BAR_POSITION) !== 'off' && window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, ((e: Event) => {
if (!STATES.isPlaying) {
this.disable();
return;
}
// Toggle Game bar
const mode = (e as any).mode;
mode !== 'None' ? this.disable() : this.enable();
}).bind(this));
}
private beginHideTimeout() {
this.clearHideTimeout();
this.timeout = window.setTimeout(() => {
this.timeout = null;
this.hideBar();
}, GameBar.VISIBLE_DURATION);
}
private clearHideTimeout() {
this.timeout && clearTimeout(this.timeout);
this.timeout = null;
}
enable() {
this.$gameBar && this.$gameBar.classList.remove('bx-gone');
}
disable() {
this.hideBar();
this.$gameBar && this.$gameBar.classList.add('bx-gone');
}
showBar() {
if (!this.$container) {
return;
}
this.$container.classList.remove('bx-offscreen', 'bx-hide');
this.$container.classList.add('bx-show');
this.beginHideTimeout();
}
hideBar() {
if (!this.$container) {
return;
}
this.$container.classList.remove('bx-show');
this.$container.classList.add('bx-hide');
}
// Reset all states
reset() {
for (const action of this.actions) {
action.reset();
}
}
}

View File

@ -1,8 +1,8 @@
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 { 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";
export class LoadingScreen { export class LoadingScreen {
static #$bgStyle: HTMLElement; static #$bgStyle: HTMLElement;
@ -46,6 +46,10 @@ export class LoadingScreen {
#game-stream div[class*=RocketAnimation-module__container] > svg { #game-stream div[class*=RocketAnimation-module__container] > svg {
display: none; display: none;
} }
#game-stream video[class*=RocketAnimationVideo-module__video] {
display: none;
}
`; `;
$bgStyle.textContent += css; $bgStyle.textContent += css;
} }
@ -105,7 +109,7 @@ export class LoadingScreen {
let $waitTimeBox = LoadingScreen.#$waitTimeBox; let $waitTimeBox = LoadingScreen.#$waitTimeBox;
if (!$waitTimeBox) { if (!$waitTimeBox) {
$waitTimeBox = CE<HTMLElement>('div', {'class': 'bx-wait-time-box'}, $waitTimeBox = CE('div', {'class': 'bx-wait-time-box'},
CE('label', {}, t('server')), CE('label', {}, t('server')),
CE('span', {}, getPreferredServerRegion()), CE('span', {}, getPreferredServerRegion()),
CE('label', {}, t('wait-time-estimated')), CE('label', {}, t('wait-time-estimated')),
@ -159,13 +163,13 @@ export class LoadingScreen {
`; `;
} }
LoadingScreen.reset(); setTimeout(LoadingScreen.reset, 2000);
} }
static reset() { static reset() {
LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('bx-gone');
LoadingScreen.#$bgStyle && (LoadingScreen.#$bgStyle.textContent = ''); LoadingScreen.#$bgStyle && (LoadingScreen.#$bgStyle.textContent = '');
LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('bx-gone');
LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval); LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval);
LoadingScreen.#waitTimeInterval = null; LoadingScreen.#waitTimeInterval = null;
} }

View File

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

View File

@ -1,100 +0,0 @@
import type { GamepadKeyNameType } from "../../types/mkb";
export const GamepadKey: DualEnum = {};
GamepadKey[GamepadKey.A = 0] = 'A';
GamepadKey[GamepadKey.B = 1] = 'B';
GamepadKey[GamepadKey.X = 2] = 'X';
GamepadKey[GamepadKey.Y = 3] = 'Y';
GamepadKey[GamepadKey.LB = 4] = 'LB';
GamepadKey[GamepadKey.RB = 5] = 'RB';
GamepadKey[GamepadKey.LT = 6] = 'LT';
GamepadKey[GamepadKey.RT = 7] = 'RT';
GamepadKey[GamepadKey.SELECT = 8] = 'SELECT';
GamepadKey[GamepadKey.START = 9] = 'START';
GamepadKey[GamepadKey.L3 = 10] = 'L3';
GamepadKey[GamepadKey.R3 = 11] = 'R3';
GamepadKey[GamepadKey.UP = 12] = 'UP';
GamepadKey[GamepadKey.DOWN = 13] = 'DOWN';
GamepadKey[GamepadKey.LEFT = 14] = 'LEFT';
GamepadKey[GamepadKey.RIGHT = 15] = 'RIGHT';
GamepadKey[GamepadKey.HOME = 16] = 'HOME';
GamepadKey[GamepadKey.LS_UP = 100] = 'LS_UP';
GamepadKey[GamepadKey.LS_DOWN = 101] = 'LS_DOWN';
GamepadKey[GamepadKey.LS_LEFT = 102] = 'LS_LEFT';
GamepadKey[GamepadKey.LS_RIGHT = 103] = 'LS_RIGHT';
GamepadKey[GamepadKey.RS_UP = 200] = 'RS_UP';
GamepadKey[GamepadKey.RS_DOWN = 201] = 'RS_DOWN';
GamepadKey[GamepadKey.RS_LEFT = 202] = 'RS_LEFT';
GamepadKey[GamepadKey.RS_RIGHT = 203] = 'RS_RIGHT';
export const GamepadKeyName: GamepadKeyNameType = {
[GamepadKey.A]: ['A', '⇓'],
[GamepadKey.B]: ['B', '⇒'],
[GamepadKey.X]: ['X', '⇐'],
[GamepadKey.Y]: ['Y', '⇑'],
[GamepadKey.LB]: ['LB', '↘'],
[GamepadKey.RB]: ['RB', '↙'],
[GamepadKey.LT]: ['LT', '↖'],
[GamepadKey.RT]: ['RT', '↗'],
[GamepadKey.SELECT]: ['Select', '⇺'],
[GamepadKey.START]: ['Start', '⇻'],
[GamepadKey.HOME]: ['Home', ''],
[GamepadKey.UP]: ['D-Pad Up', '≻'],
[GamepadKey.DOWN]: ['D-Pad Down', '≽'],
[GamepadKey.LEFT]: ['D-Pad Left', '≺'],
[GamepadKey.RIGHT]: ['D-Pad Right', '≼'],
[GamepadKey.L3]: ['L3', '↺'],
[GamepadKey.LS_UP]: ['Left Stick Up', '↾'],
[GamepadKey.LS_DOWN]: ['Left Stick Down', '⇂'],
[GamepadKey.LS_LEFT]: ['Left Stick Left', '↼'],
[GamepadKey.LS_RIGHT]: ['Left Stick Right', '⇀'],
[GamepadKey.R3]: ['R3', '↻'],
[GamepadKey.RS_UP]: ['Right Stick Up', '↿'],
[GamepadKey.RS_DOWN]: ['Right Stick Down', '⇃'],
[GamepadKey.RS_LEFT]: ['Right Stick Left', '↽'],
[GamepadKey.RS_RIGHT]: ['Right Stick Right', '⇁'],
};
export enum GamepadStick {
LEFT = 0,
RIGHT = 1,
};
export enum MouseButtonCode {
LEFT_CLICK = 'Mouse0',
RIGHT_CLICK = 'Mouse2',
MIDDLE_CLICK = 'Mouse1',
};
export const MouseMapTo: DualEnum = {};
MouseMapTo[MouseMapTo.OFF = 0] = 'OFF';
MouseMapTo[MouseMapTo.LS = 1] = 'LS';
MouseMapTo[MouseMapTo.RS = 2] = 'RS';
export enum WheelCode {
SCROLL_UP = 'ScrollUp',
SCROLL_DOWN = 'ScrollDown',
SCROLL_LEFT = 'ScrollLeft',
SCROLL_RIGHT = 'ScrollRight',
};
export enum MkbPresetKey {
MOUSE_MAP_TO = 'map_to',
MOUSE_SENSITIVITY_X = 'sensitivity_x',
MOUSE_SENSITIVITY_Y = 'sensitivity_y',
MOUSE_DEADZONE_COUNTERWEIGHT = 'deadzone_counterweight',
MOUSE_STICK_DECAY_STRENGTH = 'stick_decay_strength',
MOUSE_STICK_DECAY_MIN = 'stick_decay_min',
}

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 = {
@ -20,7 +20,7 @@ export class KeyHelper {
let name; let name;
if (e instanceof KeyboardEvent) { if (e instanceof KeyboardEvent) {
code = e.code; code = e.code || e.key;
} else if (e instanceof WheelEvent) { } else if (e instanceof WheelEvent) {
if (e.deltaY < 0) { if (e.deltaY < 0) {
code = WheelCode.SCROLL_UP; code = WheelCode.SCROLL_UP;
@ -28,7 +28,7 @@ export class KeyHelper {
code = WheelCode.SCROLL_DOWN; code = WheelCode.SCROLL_DOWN;
} else if (e.deltaX < 0) { } else if (e.deltaX < 0) {
code = WheelCode.SCROLL_LEFT; code = WheelCode.SCROLL_LEFT;
} else { } else if (e.deltaX > 0) {
code = WheelCode.SCROLL_RIGHT; code = WheelCode.SCROLL_RIGHT;
} }
} else if (e instanceof MouseEvent) { } else if (e instanceof MouseEvent) {

View File

@ -1,42 +1,143 @@
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, Icon, ButtonStyle, CE } from "../../utils/html"; import { createButton, ButtonStyle, CE } from "@utils/html";
import { BxEvent } from "../../utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { PrefKey, getPref } from "../../utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
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 "../stream/stream-ui"; import { AppInterface, STATES } from "@utils/global";
import { STATES } from "../../utils/global"; import { UserAgent } from "@utils/user-agent";
import { UserAgent } from "../../utils/user-agent"; import { BxLogger } from "@utils/bx-logger";
import { PointerClient } from "./pointer-client";
import { NativeMkbHandler } from "./native-mkb-handler";
import { MkbHandler, MouseDataProvider } from "./base-mkb-handler";
import { StreamSettings } from "../stream/stream-settings";
const LOG_TAG = 'MkbHandler';
const PointerToMouseButton = {
1: 0,
2: 2,
4: 1,
}
class WebSocketMouseDataProvider extends MouseDataProvider {
#pointerClient: PointerClient | undefined
#connected = false
init(): void {
this.#pointerClient = PointerClient.getInstance();
this.#connected = false;
try {
this.#pointerClient.start(STATES.pointerServerPort, this.mkbHandler);
this.#connected = true;
} catch (e) {
Toast.show('Cannot enable Mouse & Keyboard feature');
}
}
start(): void {
this.#connected && AppInterface.requestPointerCapture();
}
stop(): void {
this.#connected && AppInterface.releasePointerCapture();
}
destroy(): void {
this.#connected && this.#pointerClient?.stop();
}
}
class PointerLockMouseDataProvider extends MouseDataProvider {
init(): void {}
start(): void {
window.addEventListener('mousemove', this.#onMouseMoveEvent);
window.addEventListener('mousedown', this.#onMouseEvent);
window.addEventListener('mouseup', this.#onMouseEvent);
window.addEventListener('wheel', this.#onWheelEvent, {passive: false});
window.addEventListener('contextmenu', this.#disableContextMenu);
}
stop(): void {
document.pointerLockElement && document.exitPointerLock();
window.removeEventListener('mousemove', this.#onMouseMoveEvent);
window.removeEventListener('mousedown', this.#onMouseEvent);
window.removeEventListener('mouseup', this.#onMouseEvent);
window.removeEventListener('wheel', this.#onWheelEvent);
window.removeEventListener('contextmenu', this.#disableContextMenu);
}
destroy(): void {}
#onMouseMoveEvent = (e: MouseEvent) => {
this.mkbHandler.handleMouseMove({
movementX: e.movementX,
movementY: e.movementY,
});
}
#onMouseEvent = (e: MouseEvent) => {
e.preventDefault();
const isMouseDown = e.type === 'mousedown';
const data: MkbMouseClick = {
mouseButton: e.button,
pressed: isMouseDown,
};
this.mkbHandler.handleMouseClick(data);
}
#onWheelEvent = (e: WheelEvent) => {
const key = KeyHelper.getKeyFromEvent(e);
if (!key) {
return;
}
const data: MkbMouseWheel = {
vertical: e.deltaY,
horizontal: e.deltaX,
};
if (this.mkbHandler.handleMouseWheel(data)) {
e.preventDefault();
}
}
#disableContextMenu = (e: Event) => e.preventDefault();
}
/* /*
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);
static readonly DEFAULT_PANNING_SENSITIVITY = 0.0010; static readonly DEFAULT_PANNING_SENSITIVITY = 0.0010;
static readonly DEFAULT_STICK_SENSITIVITY = 0.0006;
static readonly DEFAULT_DEADZONE_COUNTERWEIGHT = 0.01; static readonly DEFAULT_DEADZONE_COUNTERWEIGHT = 0.01;
static readonly MAXIMUM_STICK_RANGE = 1.1; static readonly MAXIMUM_STICK_RANGE = 1.1;
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,
@ -51,23 +152,27 @@ export class MkbHandler {
#nativeGetGamepads = window.navigator.getGamepads.bind(window.navigator); #nativeGetGamepads = window.navigator.getGamepads.bind(window.navigator);
#enabled = false; #enabled = false;
#mouseDataProvider: MouseDataProvider | undefined;
#isPolling = false; #isPolling = false;
#prevWheelCode = null; #prevWheelCode = null;
#wheelStoppedTimeout?: number | null; #wheelStoppedTimeout?: number | null;
#detectMouseStoppedTimeout?: number | null; #detectMouseStoppedTimeout?: number | null;
#allowStickDecaying = false;
#$message?: HTMLElement; #$message?: HTMLElement;
#STICK_MAP: {[index: keyof typeof GamepadKey]: (number | number[])[]}; #escKeyDownTime: number = -1;
#LEFT_STICK_X: number[] = [];
#LEFT_STICK_Y: number[] = []; #STICK_MAP: {[key in GamepadKey]?: [GamepadKey[], number, number]};
#RIGHT_STICK_X: number[] = []; #LEFT_STICK_X: GamepadKey[] = [];
#RIGHT_STICK_Y: number[] = []; #LEFT_STICK_Y: GamepadKey[] = [];
#RIGHT_STICK_X: 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],
@ -81,6 +186,8 @@ export class MkbHandler {
}; };
} }
isEnabled = () => this.#enabled;
#patchedGetGamepads = () => { #patchedGetGamepads = () => {
const gamepads = this.#nativeGetGamepads() || []; const gamepads = this.#nativeGetGamepads() || [];
(gamepads as any)[this.#VIRTUAL_GAMEPAD.index] = this.#VIRTUAL_GAMEPAD; (gamepads as any)[this.#VIRTUAL_GAMEPAD.index] = this.#VIRTUAL_GAMEPAD;
@ -98,6 +205,7 @@ export class MkbHandler {
virtualGamepad.timestamp = performance.now(); virtualGamepad.timestamp = performance.now();
} }
/*
#getStickAxes(stick: GamepadStick) { #getStickAxes(stick: GamepadStick) {
const virtualGamepad = this.#getVirtualGamepad(); const virtualGamepad = this.#getVirtualGamepad();
return { return {
@ -105,11 +213,10 @@ export class MkbHandler {
y: virtualGamepad.axes[stick * 2 + 1], y: virtualGamepad.axes[stick * 2 + 1],
}; };
} }
*/
#vectorLength = (x: number, y: number): number => Math.sqrt(x ** 2 + y ** 2); #vectorLength = (x: number, y: number): number => Math.sqrt(x ** 2 + y ** 2);
#disableContextMenu = (e: Event) => e.preventDefault();
#resetGamepad = () => { #resetGamepad = () => {
const gamepad = this.#getVirtualGamepad(); const gamepad = this.#getVirtualGamepad();
@ -125,11 +232,11 @@ export class MkbHandler {
gamepad.timestamp = performance.now(); gamepad.timestamp = performance.now();
} }
#pressButton = (buttonIndex: number, pressed: boolean) => { #pressButton = (buttonIndex: GamepadKey, pressed: boolean) => {
const virtualGamepad = this.#getVirtualGamepad(); const virtualGamepad = this.#getVirtualGamepad();
if (buttonIndex >= 100) { if (buttonIndex >= 100) {
let [valueArr, axisIndex] = this.#STICK_MAP[buttonIndex]; let [valueArr, axisIndex] = this.#STICK_MAP[buttonIndex]!;
valueArr = valueArr as number[]; valueArr = valueArr as number[];
axisIndex = axisIndex as number; axisIndex = axisIndex as number;
@ -145,7 +252,7 @@ export class MkbHandler {
let value; let value;
if (valueArr.length) { if (valueArr.length) {
// Get value of the last key of the axis // Get value of the last key of the axis
value = this.#STICK_MAP[valueArr[valueArr.length - 1]][2] as number; value = this.#STICK_MAP[valueArr[valueArr.length - 1]]![2] as number;
} else { } else {
value = 0; value = 0;
} }
@ -163,19 +270,37 @@ 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;
} }
if (!this.#isPolling) { return;
return;
}
} }
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[e.code]!; // 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]!;
if (typeof buttonIndex === 'undefined') { if (typeof buttonIndex === 'undefined') {
return; return;
} }
@ -189,25 +314,30 @@ export class MkbHandler {
this.#pressButton(buttonIndex, isKeyDown); this.#pressButton(buttonIndex, isKeyDown);
} }
#onMouseEvent = (e: MouseEvent) => { #onMouseStopped = () => {
const isMouseDown = e.type === 'mousedown'; // Reset stick position
const key = KeyHelper.getKeyFromEvent(e); this.#detectMouseStoppedTimeout = null;
if (!key) {
return;
}
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code]!; const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
if (typeof buttonIndex === 'undefined') { const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
return; this.#updateStick(analog, 0, 0);
}
e.preventDefault();
this.#pressButton(buttonIndex, isMouseDown);
} }
#onWheelEvent = (e: WheelEvent) => { handleMouseClick = (data: MkbMouseClick) => {
const key = KeyHelper.getKeyFromEvent(e); let mouseButton;
if (!key) { 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;
} }
@ -216,7 +346,63 @@ export class MkbHandler {
return; return;
} }
e.preventDefault(); this.#pressButton(buttonIndex, data.pressed);
}
handleMouseMove = (data: MkbMouseMove) => {
// TODO: optimize this
const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
if (mouseMapTo === MouseMapTo.OFF) {
// Ignore mouse movements
return;
}
this.#detectMouseStoppedTimeout && clearTimeout(this.#detectMouseStoppedTimeout);
this.#detectMouseStoppedTimeout = window.setTimeout(this.#onMouseStopped.bind(this), 50);
const deadzoneCounterweight = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT];
let x = data.movementX * this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_SENSITIVITY_X];
let y = data.movementY * this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y];
let length = this.#vectorLength(x, y);
if (length !== 0 && length < deadzoneCounterweight) {
x *= deadzoneCounterweight / length;
y *= deadzoneCounterweight / length;
} else if (length > EmulatedMkbHandler.MAXIMUM_STICK_RANGE) {
x *= EmulatedMkbHandler.MAXIMUM_STICK_RANGE / length;
y *= EmulatedMkbHandler.MAXIMUM_STICK_RANGE / length;
}
const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
this.#updateStick(analog, x, y);
}
handleMouseWheel = (data: MkbMouseWheel): boolean => {
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;
}
const key = {
code: code,
name: KeyHelper.codeToKeyName(code),
};
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code]!;
if (typeof buttonIndex === 'undefined') {
return false;
}
if (this.#prevWheelCode === null || this.#prevWheelCode === key.code) { if (this.#prevWheelCode === null || this.#prevWheelCode === key.code) {
this.#wheelStoppedTimeout && clearTimeout(this.#wheelStoppedTimeout); this.#wheelStoppedTimeout && clearTimeout(this.#wheelStoppedTimeout);
@ -227,93 +413,20 @@ export class MkbHandler {
this.#prevWheelCode = null; this.#prevWheelCode = null;
this.#pressButton(buttonIndex, false); this.#pressButton(buttonIndex, false);
}, 20); }, 20);
return true;
} }
#decayStick = () => { toggle = (force?: boolean) => {
if (!this.#allowStickDecaying) { if (typeof force !== 'undefined') {
return; this.#enabled = force;
} else {
this.#enabled = !this.#enabled;
} }
const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
if (mouseMapTo === MouseMapTo.OFF) {
return;
}
const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
let { x, y } = this.#getStickAxes(analog);
const length = this.#vectorLength(x, y);
const clampedLength = Math.min(1.0, length);
const decayStrength = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH];
const decay = 1 - clampedLength * clampedLength * decayStrength;
const minDecay = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_STICK_DECAY_MIN];
const clampedDecay = Math.min(1 - minDecay, decay);
x *= clampedDecay;
y *= clampedDecay;
const deadzoneCounterweight = 20 * MkbHandler.DEFAULT_DEADZONE_COUNTERWEIGHT;
if (Math.abs(x) <= deadzoneCounterweight && Math.abs(y) <= deadzoneCounterweight) {
x = 0;
y = 0;
}
if (this.#allowStickDecaying) {
this.#updateStick(analog, x, y);
(x !== 0 || y !== 0) && requestAnimationFrame(this.#decayStick);
}
}
#onMouseStopped = () => {
this.#allowStickDecaying = true;
requestAnimationFrame(this.#decayStick);
}
#onMouseMoveEvent = (e: MouseEvent) => {
// TODO: optimize this
const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
if (mouseMapTo === MouseMapTo.OFF) {
// Ignore mouse movements
return;
}
this.#allowStickDecaying = false;
this.#detectMouseStoppedTimeout && clearTimeout(this.#detectMouseStoppedTimeout);
this.#detectMouseStoppedTimeout = window.setTimeout(this.#onMouseStopped.bind(this), 100);
const deltaX = e.movementX;
const deltaY = e.movementY;
const deadzoneCounterweight = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT];
let x = deltaX * this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_SENSITIVITY_X];
let y = deltaY * this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y];
let length = this.#vectorLength(x, y);
if (length !== 0 && length < deadzoneCounterweight) {
x *= deadzoneCounterweight / length;
y *= deadzoneCounterweight / length;
} else if (length > MkbHandler.MAXIMUM_STICK_RANGE) {
x *= MkbHandler.MAXIMUM_STICK_RANGE / length;
y *= MkbHandler.MAXIMUM_STICK_RANGE / length;
}
const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
this.#updateStick(analog, x, y);
}
toggle = () => {
this.#enabled = !this.#enabled;
this.#enabled ? document.pointerLockElement && this.start() : this.stop();
Toast.show(t('mouse-and-keyboard'), t(this.#enabled ? 'enabled' : 'disabled'), {instant: true});
if (this.#enabled) { if (this.#enabled) {
!document.pointerLockElement && this.#waitForPointerLock(true); document.body.requestPointerLock();
} else { } else {
this.#waitForPointerLock(false);
document.pointerLockElement && document.exitPointerLock(); document.pointerLockElement && document.exitPointerLock();
} }
} }
@ -334,10 +447,84 @@ export class MkbHandler {
}); });
} }
waitForMouseData = (wait: boolean) => {
this.#$message && this.#$message.classList.toggle('bx-gone', !wait);
}
#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 bx-gone'},
CE('div', {},
CE('p', {}, t('virtual-controller')),
CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})),
),
CE('div', {'data-type': 'virtual'},
createButton({
style: ButtonStyle.PRIMARY | ButtonStyle.TALL | ButtonStyle.FULL_WIDTH,
label: t('activate'),
onClick: ((e: Event) => {
e.preventDefault();
e.stopPropagation();
this.toggle(true);
}).bind(this),
}),
CE('div', {},
createButton({
label: t('ignore'),
style: ButtonStyle.GHOST,
onClick: e => {
e.preventDefault();
e.stopPropagation();
this.toggle(false);
this.waitForMouseData(false);
},
}),
createButton({
label: t('edit'),
onClick: e => {
e.preventDefault();
e.stopPropagation();
StreamSettings.getInstance().show('mkb');
},
}),
),
),
);
}
if (!this.#$message.isConnected) {
document.documentElement.appendChild(this.#$message);
}
}
#onPointerLockChange = () => { #onPointerLockChange = () => {
if (this.#enabled && !document.pointerLockElement) { if (document.pointerLockElement) {
this.start();
} else {
this.stop(); this.stop();
this.#waitForPointerLock(true);
} }
} }
@ -346,60 +533,60 @@ export class MkbHandler {
this.stop(); this.stop();
} }
#onActivatePointerLock = () => { #onPointerLockRequested = () => {
if (!document.pointerLockElement) {
document.body.requestPointerLock();
}
this.#waitForPointerLock(false);
this.start(); this.start();
} }
#waitForPointerLock = (wait: boolean) => { #onPointerLockExited = () => {
this.#$message && this.#$message.classList.toggle('bx-gone', !wait); this.#mouseDataProvider?.stop();
} }
#onStreamMenuShown = () => { handleEvent(event: Event) {
this.#enabled && this.#waitForPointerLock(false); switch (event.type) {
} case BxEvent.POINTER_LOCK_REQUESTED:
this.#onPointerLockRequested();
#onStreamMenuHidden = () => { break;
this.#enabled && this.#waitForPointerLock(true); case BxEvent.POINTER_LOCK_EXITED:
this.#onPointerLockExited();
break;
}
} }
init = () => { init = () => {
this.refreshPresetData(); this.refreshPresetData();
this.#enabled = true; this.#enabled = false;
if (AppInterface) {
this.#mouseDataProvider = new WebSocketMouseDataProvider(this);
} else {
this.#mouseDataProvider = new PointerLockMouseDataProvider(this);
}
this.#mouseDataProvider.init();
window.addEventListener('keydown', this.#onKeyboardEvent); window.addEventListener('keydown', this.#onKeyboardEvent);
window.addEventListener('keyup', this.#onKeyboardEvent);
document.addEventListener('pointerlockchange', this.#onPointerLockChange); window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged);
document.addEventListener('pointerlockerror', this.#onPointerLockError); window.addEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this.#onDialogShown);
this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg bx-gone'}, if (AppInterface) {
createButton({ // Android app doesn't support PointerLock API so we need to use a different method
icon: Icon.MOUSE_SETTINGS, window.addEventListener(BxEvent.POINTER_LOCK_REQUESTED, this);
style: ButtonStyle.PRIMARY, window.addEventListener(BxEvent.POINTER_LOCK_EXITED, this);
onClick: e => { } else {
e.preventDefault(); document.addEventListener('pointerlockchange', this.#onPointerLockChange);
e.stopPropagation(); document.addEventListener('pointerlockerror', this.#onPointerLockError);
}
showStreamSettings('mkb'); this.#initMessage();
}, this.#$message?.classList.add('bx-gone');
}),
CE('div', {},
CE('p', {}, t('mkb-click-to-activate')),
CE('p', {}, t<any>('press-key-to-toggle-mkb')({key: 'F8'})),
),
);
this.#$message.addEventListener('click', this.#onActivatePointerLock); if (AppInterface) {
document.documentElement.appendChild(this.#$message); Toast.show(t('press-key-to-toggle-mkb', {key: `<b>F8</b>`}), t('virtual-controller'), {html: true});
this.waitForMouseData(false);
window.addEventListener(BxEvent.STREAM_MENU_SHOWN, this.#onStreamMenuShown); } else {
window.addEventListener(BxEvent.STREAM_MENU_HIDDEN, this.#onStreamMenuHidden); this.waitForMouseData(true);
}
this.#waitForPointerLock(true);
} }
destroy = () => { destroy = () => {
@ -407,31 +594,43 @@ export class MkbHandler {
this.#enabled = false; this.#enabled = false;
this.stop(); this.stop();
this.#waitForPointerLock(false); this.waitForMouseData(false);
document.pointerLockElement && document.exitPointerLock(); document.pointerLockElement && document.exitPointerLock();
window.removeEventListener('keydown', this.#onKeyboardEvent); window.removeEventListener('keydown', this.#onKeyboardEvent);
window.removeEventListener('keyup', this.#onKeyboardEvent);
document.removeEventListener('pointerlockchange', this.#onPointerLockChange); if (AppInterface) {
document.removeEventListener('pointerlockerror', this.#onPointerLockError); 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.STREAM_MENU_SHOWN, this.#onStreamMenuShown); window.removeEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged);
window.removeEventListener(BxEvent.STREAM_MENU_HIDDEN, this.#onStreamMenuHidden); window.removeEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this.#onDialogShown);
this.#mouseDataProvider?.destroy();
window.removeEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged);
} }
start = () => { start = () => {
if (!this.#enabled) {
this.#enabled = true;
Toast.show(t('virtual-controller'), t('enabled'), {instant: true});
}
this.#isPolling = true; this.#isPolling = true;
window.navigator.getGamepads = this.#patchedGetGamepads; this.#escKeyDownTime = -1;
this.#resetGamepad(); this.#resetGamepad();
window.navigator.getGamepads = this.#patchedGetGamepads;
window.addEventListener('keyup', this.#onKeyboardEvent); this.waitForMouseData(false);
window.addEventListener('mousemove', this.#onMouseMoveEvent); this.#mouseDataProvider?.start();
window.addEventListener('mousedown', this.#onMouseEvent);
window.addEventListener('mouseup', this.#onMouseEvent);
window.addEventListener('wheel', this.#onWheelEvent);
window.addEventListener('contextmenu', this.#disableContextMenu);
// Dispatch "gamepadconnected" event // Dispatch "gamepadconnected" event
const virtualGamepad = this.#getVirtualGamepad(); const virtualGamepad = this.#getVirtualGamepad();
@ -441,39 +640,48 @@ export class MkbHandler {
BxEvent.dispatch(window, 'gamepadconnected', { BxEvent.dispatch(window, 'gamepadconnected', {
gamepad: virtualGamepad, gamepad: virtualGamepad,
}); });
window.BX_EXPOSED.stopTakRendering = true;
Toast.show(t('virtual-controller'), t('enabled'), {instant: true});
} }
stop = () => { stop = () => {
this.#enabled = false;
this.#isPolling = false; this.#isPolling = false;
this.#escKeyDownTime = -1;
// Dispatch "gamepaddisconnected" event
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,
});
this.#resetGamepad(); window.navigator.getGamepads = this.#nativeGetGamepads;
}
window.removeEventListener('keyup', this.#onKeyboardEvent); this.waitForMouseData(true);
this.#mouseDataProvider?.stop();
window.removeEventListener('mousemove', this.#onMouseMoveEvent); // Toast.show(t('virtual-controller'), t('disabled'), {instant: true});
window.removeEventListener('mousedown', this.#onMouseEvent);
window.removeEventListener('mouseup', this.#onMouseEvent);
window.removeEventListener('wheel', this.#onWheelEvent);
window.removeEventListener('contextmenu', this.#disableContextMenu);
} }
static setupEvents() { static setupEvents() {
getPref(PrefKey.MKB_ENABLED) && !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
console.log('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 { SettingElementType } from "@utils/settings";
import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "./definitions"; import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "@enums/mkb";
import { MkbHandler } from "./mkb-handler"; import { EmulatedMkbHandler } from "./mkb-handler";
import type { MkbPresetData, MkbConvertedPresetData } from "../../types/mkb"; import type { MkbPresetData, MkbConvertedPresetData } from "@/types/mkb";
import type { PreferenceSettings } from "../../types/preferences"; import type { PreferenceSettings } from "@/types/preferences";
export class MkbPreset { export class MkbPreset {
@ -24,11 +24,11 @@ export class MkbPreset {
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 50, default: 50,
min: 1, min: 1,
max: 200, max: 300,
params: { params: {
suffix: '%', suffix: '%',
exactTicks: 20, exactTicks: 50,
}, },
}, },
@ -37,11 +37,11 @@ export class MkbPreset {
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 50, default: 50,
min: 1, min: 1,
max: 200, max: 300,
params: { params: {
suffix: '%', suffix: '%',
exactTicks: 20, exactTicks: 50,
}, },
}, },
@ -50,38 +50,13 @@ export class MkbPreset {
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 20, default: 20,
min: 1, min: 1,
max: 100, max: 50,
params: { params: {
suffix: '%', suffix: '%',
exactTicks: 10, exactTicks: 10,
}, },
}, },
[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH]: {
label: t('stick-decay-strength'),
type: SettingElementType.NUMBER_STEPPER,
default: 100,
min: 10,
max: 100,
params: {
suffix: '%',
exactTicks: 10,
},
},
[MkbPresetKey.MOUSE_STICK_DECAY_MIN]: {
label: t('stick-decay-minimum'),
type: SettingElementType.NUMBER_STEPPER,
default: 10,
min: 1,
max: 10,
params: {
suffix: '%',
},
},
}; };
static DEFAULT_PRESET: MkbPresetData = { static DEFAULT_PRESET: MkbPresetData = {
@ -124,11 +99,9 @@ export class MkbPreset {
'mouse': { 'mouse': {
[MkbPresetKey.MOUSE_MAP_TO]: MouseMapTo[MouseMapTo.RS], [MkbPresetKey.MOUSE_MAP_TO]: MouseMapTo[MouseMapTo.RS],
[MkbPresetKey.MOUSE_SENSITIVITY_X]: 50, [MkbPresetKey.MOUSE_SENSITIVITY_X]: 100,
[MkbPresetKey.MOUSE_SENSITIVITY_Y]: 50, [MkbPresetKey.MOUSE_SENSITIVITY_Y]: 100,
[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT]: 20, [MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT]: 20,
[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH]: 18,
[MkbPresetKey.MOUSE_STICK_DECAY_MIN]: 6,
}, },
}; };
@ -146,11 +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;
mouse[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH] *= 0.01;
mouse[MkbPresetKey.MOUSE_STICK_DECAY_MIN] *= 0.01;
const mouseMapTo = MouseMapTo[mouse[MkbPresetKey.MOUSE_MAP_TO]!]; const mouseMapTo = MouseMapTo[mouse[MkbPresetKey.MOUSE_MAP_TO]!];
if (typeof mouseMapTo !== 'undefined') { if (typeof mouseMapTo !== 'undefined') {

View File

@ -1,16 +1,16 @@
import { GamepadKey } from "./definitions"; import { CE, createButton, ButtonStyle } from "@utils/html";
import { CE, createButton, ButtonStyle } from "../../utils/html"; import { t } from "@utils/translation";
import { t } from "../../utils/translation"; import { Dialog } from "@modules/dialog";
import { Dialog } from "../dialog"; import { getPref, setPref, PrefKey } from "@utils/preferences";
import { getPref, setPref, PrefKey } from "../../utils/preferences";
import { MkbPresetKey, GamepadKeyName } from "./definitions";
import { KeyHelper } from "./key-helper"; import { KeyHelper } from "./key-helper";
import { MkbPreset } from "./mkb-preset"; import { MkbPreset } from "./mkb-preset";
import { MkbHandler } from "./mkb-handler"; import { EmulatedMkbHandler } from "./mkb-handler";
import { LocalDb } from "../../utils/local-db"; import { LocalDb } from "@utils/local-db";
import { Icon } from "../../utils/html"; import { BxIcon } from "@utils/bx-icon";
import { SettingElement } from "../../utils/settings"; import { SettingElement } from "@utils/settings";
import type { MkbPresetData, MkbStoredPresets } from "../../types/mkb"; import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb";
import { MkbPresetKey, GamepadKey, GamepadKeyName } from "@enums/mkb";
import { deepClone } from "@utils/global";
type MkbRemapperElements = { type MkbRemapperElements = {
@ -258,7 +258,7 @@ export class MkbRemapper {
defaultPresetId = this.#STATE.currentPresetId; defaultPresetId = this.#STATE.currentPresetId;
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, defaultPresetId); setPref(PrefKey.MKB_DEFAULT_PRESET_ID, defaultPresetId);
MkbHandler.INSTANCE.refreshPresetData(); EmulatedMkbHandler.getInstance().refreshPresetData();
} else { } else {
defaultPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID); defaultPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
} }
@ -292,7 +292,7 @@ export class MkbRemapper {
this.#$.wrapper!.classList.toggle('bx-editing', this.#STATE.isEditing); this.#$.wrapper!.classList.toggle('bx-editing', this.#STATE.isEditing);
if (this.#STATE.isEditing) { if (this.#STATE.isEditing) {
this.#STATE.editingPresetData = structuredClone(this.#getCurrentPreset().data); this.#STATE.editingPresetData = deepClone(this.#getCurrentPreset().data);
} else { } else {
this.#STATE.editingPresetData = null; this.#STATE.editingPresetData = null;
} }
@ -340,7 +340,7 @@ export class MkbRemapper {
// Rename button // Rename button
createButton({ createButton({
title: t('rename'), title: t('rename'),
icon: Icon.CURSOR_TEXT, icon: BxIcon.CURSOR_TEXT,
onClick: e => { onClick: e => {
const preset = this.#getCurrentPreset(); const preset = this.#getCurrentPreset();
@ -357,7 +357,7 @@ export class MkbRemapper {
// New button // New button
createButton({ createButton({
icon: Icon.NEW, icon: BxIcon.NEW,
title: t('new'), title: t('new'),
onClick: e => { onClick: e => {
let newName = promptNewName(''); let newName = promptNewName('');
@ -375,7 +375,7 @@ export class MkbRemapper {
// Copy button // Copy button
createButton({ createButton({
icon: Icon.COPY, icon: BxIcon.COPY,
title: t('copy'), title: t('copy'),
onClick: e => { onClick: e => {
const preset = this.#getCurrentPreset(); const preset = this.#getCurrentPreset();
@ -395,7 +395,7 @@ export class MkbRemapper {
// Delete button // Delete button
createButton({ createButton({
icon: Icon.TRASH, icon: BxIcon.TRASH,
style: ButtonStyle.DANGER, style: ButtonStyle.DANGER,
title: t('delete'), title: t('delete'),
onClick: e => { onClick: e => {
@ -426,6 +426,7 @@ 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',
'data-prompt': buttonPrompt, 'data-prompt': buttonPrompt,
'data-button-index': buttonIndex, 'data-button-index': buttonIndex,
'data-key-slot': i, 'data-key-slot': i,
@ -459,7 +460,7 @@ 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-quick-settings-row'}, const $row = CE('div', {'class': 'bx-stream-settings-row'},
CE('label', {'for': `bx_setting_${key}`}, setting.label), CE('label', {'for': `bx_setting_${key}`}, setting.label),
$elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params), $elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params),
); );
@ -486,7 +487,7 @@ export class MkbRemapper {
style: ButtonStyle.PRIMARY, style: ButtonStyle.PRIMARY,
onClick: e => { onClick: e => {
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId); setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId);
MkbHandler.INSTANCE.refreshPresetData(); EmulatedMkbHandler.getInstance().refreshPresetData();
this.#refresh(); this.#refresh();
}, },
@ -510,13 +511,13 @@ export class MkbRemapper {
label: t('save'), label: t('save'),
style: ButtonStyle.PRIMARY, style: ButtonStyle.PRIMARY,
onClick: e => { onClick: e => {
const updatedPreset = structuredClone(this.#getCurrentPreset()); const updatedPreset = deepClone(this.#getCurrentPreset());
updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData; updatedPreset.data = this.#STATE.editingPresetData as MkbPresetData;
LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => { LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => {
// If this is the default preset => refresh preset data // If this is the default preset => refresh preset data
if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) { if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) {
MkbHandler.INSTANCE.refreshPresetData(); EmulatedMkbHandler.getInstance().refreshPresetData();
} }
this.#toggleEditing(false); this.#toggleEditing(false);

View File

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

View File

@ -0,0 +1,130 @@
import { BxLogger } from "@/utils/bx-logger";
import { Toast } from "@/utils/toast";
import type { MkbHandler } from "./base-mkb-handler";
const LOG_TAG = 'PointerClient';
enum PointerAction {
MOVE = 1,
BUTTON_PRESS = 2,
BUTTON_RELEASE = 3,
SCROLL = 4,
POINTER_CAPTURE_CHANGED = 5,
}
export class PointerClient {
private static instance: PointerClient;
public static getInstance(): PointerClient {
if (!PointerClient.instance) {
PointerClient.instance = new PointerClient();
}
return PointerClient.instance;
}
#socket: WebSocket | undefined | null;
#mkbHandler: MkbHandler | undefined;
start(port: number, mkbHandler: MkbHandler) {
if (!port) {
throw new Error('PointerServer port is 0');
}
this.#mkbHandler = mkbHandler;
// Create WebSocket connection.
this.#socket = new WebSocket(`ws://localhost:${port}`);
this.#socket.binaryType = 'arraybuffer';
// Connection opened
this.#socket.addEventListener('open', (event) => {
BxLogger.info(LOG_TAG, 'connected')
});
// Error
this.#socket.addEventListener('error', (event) => {
BxLogger.error(LOG_TAG, event);
Toast.show('Cannot setup mouse: ' + event);
});
this.#socket.addEventListener('close', (event) => {
this.#socket = null;
});
// Listen for messages
this.#socket.addEventListener('message', (event) => {
const dataView = new DataView(event.data);
let messageType = dataView.getInt8(0);
let offset = Int8Array.BYTES_PER_ELEMENT;
switch (messageType) {
case PointerAction.MOVE:
this.onMove(dataView, offset);
break;
case PointerAction.BUTTON_PRESS:
case PointerAction.BUTTON_RELEASE:
this.onPress(messageType, dataView, offset);
break;
case PointerAction.SCROLL:
this.onScroll(dataView, offset);
break;
case PointerAction.POINTER_CAPTURE_CHANGED:
this.onPointerCaptureChanged(dataView, offset);
}
});
}
onMove(dataView: DataView, offset: number) {
// [X, Y]
const x = dataView.getInt16(offset);
offset += Int16Array.BYTES_PER_ELEMENT;
const y = dataView.getInt16(offset);
this.#mkbHandler?.handleMouseMove({
movementX: x,
movementY: y,
});
// BxLogger.info(LOG_TAG, 'move', x, y);
}
onPress(messageType: PointerAction, dataView: DataView, offset: number) {
const button = dataView.getUint8(offset);
this.#mkbHandler?.handleMouseClick({
pointerButton: button,
pressed: messageType === PointerAction.BUTTON_PRESS,
});
// BxLogger.info(LOG_TAG, 'press', buttonIndex);
}
onScroll(dataView: DataView, offset: number) {
// [V_SCROLL, H_SCROLL]
const vScroll = dataView.getInt16(offset);
offset += Int16Array.BYTES_PER_ELEMENT;
const hScroll = dataView.getInt16(offset);
this.#mkbHandler?.handleMouseWheel({
vertical: vScroll,
horizontal: hScroll,
});
// BxLogger.info(LOG_TAG, 'scroll', vScroll, hScroll);
}
onPointerCaptureChanged(dataView: DataView, offset: number) {
const hasCapture = dataView.getInt8(offset) === 1;
!hasCapture && this.#mkbHandler?.stop();
}
stop() {
try {
this.#socket?.close();
} catch (e) {}
this.#socket = null;
}
}

View File

@ -1,7 +1,24 @@
import { STATES } from "../utils/global"; import { 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 { getPref, PrefKey } from "@utils/preferences";
import { VibrationManager } from "./vibration-manager"; import { VibrationManager } from "@modules/vibration-manager";
import { BxLogger } from "@utils/bx-logger";
import { hashCode, renderString } from "@utils/utils";
import { BxEvent } from "@/utils/bx-event";
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 codeRemotePlayEnable from "./patches/remote-play-enable.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 { FeatureGates } from "@/utils/feature-gates.js";
type PatchArray = (keyof typeof PATCHES)[];
const ENDING_CHUNKS_PATCH_NAME = 'loadingEndingChunks';
const LOG_TAG = 'Patcher';
const PATCHES = { const PATCHES = {
// Disable ApplicationInsights.track() function // Disable ApplicationInsights.track() function
@ -36,37 +53,40 @@ const PATCHES = {
} }
const newCode = [ const newCode = [
'this.trackEvent', 'this.trackEvent',
'this.trackPageView', 'this.trackPageView',
'this.trackHttpCompleted', 'this.trackHttpCompleted',
'this.trackHttpFailed', 'this.trackHttpFailed',
'this.trackError', 'this.trackError',
'this.trackErrorLike', 'this.trackErrorLike',
'this.onTrackEvent', 'this.onTrackEvent',
'()=>{}', '()=>{}',
].join('='); ].join('=');
return str.replace(text, newCode + ';' + text); return str.replace(text, newCode + ';' + text);
}, },
// Disable IndexDB logging // Disable IndexDB logging
disableIndexDbLogging(str: string) { disableIndexDbLogging(str: string) {
const text = 'async addLog(e,t=1e4){'; const text = ',this.logsDb=new';
if (!str.includes(text)) { if (!str.includes(text)) {
return false; return false;
} }
return str.replace(text, text + 'return;'); // Replace log() with an empty function
let newCode = ',this.log=()=>{}';
return str.replace(text, newCode + text);
}, },
// Set TV layout // Set custom website layout
tvLayout(str: string) { websiteLayout(str: string) {
const text = '?"tv":"default"'; const text = '?"tv":"default"';
if (!str.includes(text)) { if (!str.includes(text)) {
return false; return false;
} }
return str.replace(text, '?"tv":"tv"'); const layout = getPref(PrefKey.UI_LAYOUT) === 'tv' ? 'tv' : 'default';
return str.replace(text, `?"${layout}":"${layout}"`);
}, },
// Replace "/direct-connect" with "/play" // Replace "/direct-connect" with "/play"
@ -80,41 +100,40 @@ const PATCHES = {
}, },
remotePlayKeepAlive(str: string) { remotePlayKeepAlive(str: string) {
if (!str.includes('onServerDisconnectMessage(e){')) { const text = 'onServerDisconnectMessage(e){';
if (!str.includes(text)) {
return false; return false;
} }
str = str.replace('onServerDisconnectMessage(e){', `onServerDisconnectMessage(e) { str = str.replace(text, text + codeRemotePlayKeepAlive);
const msg = JSON.parse(e);
if (msg.reason === 'WarningForBeingIdle' && !window.location.pathname.includes('/launch/')) {
try {
this.sendKeepAlive();
return;
} catch (ex) { console.log(ex); }
}
`);
return str; return str;
}, },
// Enable Remote Play feature // Enable Remote Play feature
remotePlayConnectMode(str: string) { remotePlayConnectMode(str: string) {
const text = 'connectMode:"cloud-connect"'; const text = 'connectMode:"cloud-connect",';
if (!str.includes(text)) { if (!str.includes(text)) {
return false; return false;
} }
return str.replace(text, `connectMode:window.BX_REMOTE_PLAY_CONFIG?"xhome-connect":"cloud-connect",remotePlayServerId:(window.BX_REMOTE_PLAY_CONFIG&&window.BX_REMOTE_PLAY_CONFIG.serverId)||''`); return str.replace(text, codeRemotePlayEnable);
}, },
// Fix the Guide/Nexus button not working in Remote Play // Disable achievement toast in Remote Play
remotePlayGuideWorkaround(str: string) { remotePlayDisableAchievementToast(str: string) {
const text = 'nexusButtonHandler:this.featureGates.EnableClientGuideInStream'; const text = '.AchievementUnlock:{';
if (!str.includes(text)) { if (!str.includes(text)) {
return false; return false;
} }
return str.replace(text, `nexusButtonHandler: !window.BX_REMOTE_PLAY_CONFIG && this.featureGates.EnableClientGuideInStream`); const newCode = `
if (!!window.BX_REMOTE_PLAY_CONFIG) {
return;
}
`;
return str.replace(text, text + newCode);
}, },
// Disable trackEvent() function // Disable trackEvent() function
@ -137,15 +156,36 @@ const PATCHES = {
return str.replace(text, 'this.shouldCollectStats=!1'); return str.replace(text, 'this.shouldCollectStats=!1');
}, },
blockGamepadStatsCollector(str: string) { patchPollGamepads(str: string) {
const text = 'this.inputPollingIntervalStats.addValue'; const index = str.indexOf('},this.pollGamepads=()=>{');
if (!str.includes(text)) { if (index === -1) {
return false; return false;
} }
str = str.replace('this.inputPollingIntervalStats.addValue', ''); const nextIndex = str.indexOf('setTimeout(this.pollGamepads', index);
str = str.replace('this.inputPollingDurationStats.addValue', ''); if (nextIndex === -1) {
return str; return false;
}
let codeBlock = str.substring(index, nextIndex);
// Block gamepad stats collecting
if (getPref(PrefKey.BLOCK_TRACKING)) {
codeBlock = codeBlock.replaceAll('this.inputPollingIntervalStats.addValue', '');
}
// Map the Share button on Xbox Series controller with the capturing screenshot feature
const match = codeBlock.match(/this\.gamepadTimestamps\.set\((\w+)\.index/);
if (match) {
const gamepadVar = match[1];
const newCode = renderString(codeControllerShortcuts, {
gamepadVar,
});
codeBlock = codeBlock.replace('this.gamepadTimestamps.set', newCode + 'this.gamepadTimestamps.set');
}
return str.substring(0, index) + codeBlock + str.substring(nextIndex);
}, },
enableXcloudLogger(str: string) { enableXcloudLogger(str: string) {
@ -175,20 +215,8 @@ const PATCHES = {
return false; return false;
} }
const newCode = `
if (!window.BX_ENABLE_CONTROLLER_VIBRATION) {
return void(0);
}
if (window.BX_VIBRATION_INTENSITY && window.BX_VIBRATION_INTENSITY < 1) {
e.leftMotorPercent = e.leftMotorPercent * window.BX_VIBRATION_INTENSITY;
e.rightMotorPercent = e.rightMotorPercent * window.BX_VIBRATION_INTENSITY;
e.leftTriggerMotorPercent = e.leftTriggerMotorPercent * window.BX_VIBRATION_INTENSITY;
e.rightTriggerMotorPercent = e.rightTriggerMotorPercent * window.BX_VIBRATION_INTENSITY;
}
`;
VibrationManager.updateGlobalVars(); VibrationManager.updateGlobalVars();
str = str.replaceAll(text, text + newCode); str = str.replaceAll(text, text + codeVibrationAdjust);
return str; return str;
}, },
@ -202,12 +230,10 @@ e.rightTriggerMotorPercent = e.rightTriggerMotorPercent * window.BX_VIBRATION_IN
// 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;
@ -238,14 +264,13 @@ e.rightTriggerMotorPercent = e.rightTriggerMotorPercent * window.BX_VIBRATION_IN
// Add patches that are only needed when start playing // Add patches that are only needed when start playing
loadingEndingChunks(str: string) { loadingEndingChunks(str: string) {
const text = 'Symbol("ChatSocketPlugin")'; const text = '"FamilySagaManager"';
if (!str.includes(text)) { if (!str.includes(text)) {
return false; return false;
} }
console.log('[Better xCloud] Remaining patches:', PATCH_ORDERS); BxLogger.info(LOG_TAG, 'Remaining patches:', PATCH_ORDERS);
PATCH_ORDERS = PATCH_ORDERS.concat(PLAYING_PATCH_ORDERS); PATCH_ORDERS = PATCH_ORDERS.concat(PLAYING_PATCH_ORDERS);
Patcher.cleanupPatches();
return str; return str;
}, },
@ -269,7 +294,44 @@ e.rightTriggerMotorPercent = e.rightTriggerMotorPercent * window.BX_VIBRATION_IN
return false; return false;
} }
str = str.replace(text, 'window.BX_EXPOSED["touch_layout_manager"] = this,' + text); const newCode = `
true;
window.BX_EXPOSED["touchLayoutManager"] = this;
window.dispatchEvent(new Event("${BxEvent.TOUCH_LAYOUT_MANAGER_READY}"));
`;
str = str.replace(text, newCode + text);
return str;
},
patchBabylonRendererClass(str: string) {
// ()=>{a.current.render(),h.current=window.requestAnimationFrame(l)
let index = str.indexOf('.current.render(),');
if (index === -1) {
return false;
}
// Move back a character
index -= 1;
// Get variable of the "BabylonRendererClass" object
const rendererVar = str[index];
const newCode = `
if (window.BX_EXPOSED.stopTakRendering) {
try {
document.getElementById('BabylonCanvasContainer-main')?.parentElement.classList.add('bx-offscreen');
${rendererVar}.current.dispose();
} catch (e) {}
window.BX_EXPOSED.stopTakRendering = false;
return;
}
`;
str = str.substring(0, index) + newCode + str.substring(index);
return str; return str;
}, },
@ -279,27 +341,7 @@ e.rightTriggerMotorPercent = e.rightTriggerMotorPercent * window.BX_VIBRATION_IN
return false; return false;
} }
let patchstr = ` const newCode = `true; ${codeLocalCoOpEnable}; true,`;
let match;
let onGamepadChangedStr = this.onGamepadChanged.toString();
onGamepadChangedStr = onGamepadChangedStr.replaceAll('0', 'arguments[1]');
eval(\`this.onGamepadChanged = function \${onGamepadChangedStr}\`);
let onGamepadInputStr = this.onGamepadInput.toString();
match = onGamepadInputStr.match(/(\\w+\\.GamepadIndex)/);
if (match) {
const gamepadIndexVar = match[0];
onGamepadInputStr = onGamepadInputStr.replace('this.gamepadStates.get(', \`this.gamepadStates.get(\${gamepadIndexVar},\`);
eval(\`this.onGamepadInput = function \${onGamepadInputStr}\`);
console.log('[Better xCloud] ✅ Successfully patched local co-op support');
} else {
console.log('[Better xCloud] ❌ Unable to patch local co-op support');
}
`;
const newCode = `true; ${patchstr}; true,`;
str = str.replace(text, text + newCode); str = str.replace(text, text + newCode);
return str; return str;
@ -323,11 +365,34 @@ if (match) {
return false; return false;
} }
const newCode = ` let remotePlayCode = '';
const titleInfo = window.BX_EXPOSED.getTitleInfo(); if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off' && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) {
if (!titleInfo.details.hasTouchSupport && !titleInfo.details.hasFakeTouchSupport) { remotePlayCode = `
const gamepads = window.navigator.getGamepads();
let gamepadFound = false;
for (let gamepad of gamepads) {
if (gamepad && gamepad.connected) {
gamepadFound = true;
break;
}
}
if (gamepadFound) {
return; return;
} }
`;
}
const newCode = `
if (!!window.BX_REMOTE_PLAY_CONFIG) {
${remotePlayCode}
} else {
const titleInfo = window.BX_EXPOSED.getTitleInfo();
if (titleInfo && !titleInfo.details.hasTouchSupport && !titleInfo.details.hasFakeTouchSupport) {
return;
}
}
`; `;
str = str.replace(text, newCode + text); str = str.replace(text, newCode + text);
@ -350,13 +415,19 @@ if (!titleInfo.details.hasTouchSupport && !titleInfo.details.hasFakeTouchSupport
return false; return false;
} }
// Restore the "..." button let newCode = `
str = str.replace(text, 'e.guideUI = null;' + text); // Expose onShowStreamMenu
window.BX_EXPOSED.showStreamMenu = e.onShowStreamMenu;
// Restore the "..." button
e.guideUI = null;
`;
// Remove the TAK Edit button when the touch controller is disabled // Remove the TAK Edit button when the touch controller is disabled
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off') { if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off') {
str = str.replace(text, 'e.canShowTakHUD = false;' + text); newCode += 'e.canShowTakHUD = false;';
} }
str = str.replace(text, newCode + text);
return str; return str;
}, },
@ -367,7 +438,7 @@ if (!titleInfo.details.hasTouchSupport && !titleInfo.details.hasFakeTouchSupport
} }
const newCode = ` const newCode = `
window.BX_EXPOSED.onPollingModeChanged && window.BX_EXPOSED.onPollingModeChanged(e); BxEvent.dispatch(window, BxEvent.XCLOUD_POLLING_MODE_CHANGED, {mode: e});
`; `;
str = str.replace(text, text + newCode); str = str.replace(text, text + newCode);
return str; return str;
@ -389,7 +460,7 @@ window.BX_EXPOSED.onPollingModeChanged && window.BX_EXPOSED.onPollingModeChanged
const newCode = ` const newCode = `
${titleInfoVar} = window.BX_EXPOSED.modifyTitleInfo(${titleInfoVar}); ${titleInfoVar} = window.BX_EXPOSED.modifyTitleInfo(${titleInfoVar});
console.log(${titleInfoVar}); BxLogger.info('patchXcloudTitleInfo', ${titleInfoVar});
`; `;
str = str.substring(0, backetIndex + 1) + newCode + str.substring(backetIndex + 1); str = str.substring(0, backetIndex + 1) + newCode + str.substring(backetIndex + 1);
return str; return str;
@ -415,87 +486,262 @@ Object.assign(${configsVar}.inputConfiguration, {
enableKeyboardInput: false, enableKeyboardInput: false,
enableAbsoluteMouse: false, enableAbsoluteMouse: false,
}); });
console.log(${configsVar}); BxLogger.info('patchRemotePlayMkb', ${configsVar});
`; `;
str = str.substring(0, backetIndex + 1) + newCode + str.substring(backetIndex + 1); str = str.substring(0, backetIndex + 1) + newCode + str.substring(backetIndex + 1);
return str; return str;
}, },
patchAudioMediaStream(str: string) {
const text = '.srcObject=this.audioMediaStream,';
if (!str.includes(text)) {
return false;
}
const newCode = `window.BX_EXPOSED.setupGainNode(arguments[1], this.audioMediaStream),`;
str = str.replace(text, text + newCode);
return str;
},
patchCombinedAudioVideoMediaStream(str: string) {
const text = '.srcObject=this.combinedAudioVideoStream';
if (!str.includes(text)) {
return false;
}
const newCode = `,window.BX_EXPOSED.setupGainNode(arguments[0], this.combinedAudioVideoStream)`;
str = str.replace(text, text + newCode);
return str;
},
patchTouchControlDefaultOpacity(str: string) {
const text = 'opacityMultiplier:1';
if (!str.includes(text)) {
return false;
}
const opacity = (getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) / 100).toFixed(1);
const newCode = `opacityMultiplier: ${opacity}`;
str = str.replace(text, newCode);
return str;
},
patchShowSensorControls(str: string) {
const text = '{shouldShowSensorControls:';
if (!str.includes(text)) {
return false;
}
const newCode = `{shouldShowSensorControls: (window.BX_EXPOSED && window.BX_EXPOSED.shouldShowSensorControls) ||`;
str = str.replace(text, newCode);
return str;
},
/*
exposeEventTarget(str: string) {
const text ='this._eventTarget=new EventTarget';
if (!str.includes(text)) {
return false;
}
const newCode = `
window.BX_EXPOSED.eventTarget = ${text},
window.dispatchEvent(new Event('${BxEvent.STREAM_EVENT_TARGET_READY}'))
`;
str = str.replace(text, newCode);
return str;
},
//*/
// Class with: connectAsync(), doConnectAsync(), setPlayClient()
exposeStreamSession(str: string) {
const text =',this._connectionType=';
if (!str.includes(text)) {
return false;
}
const newCode = `;
${codeExposeStreamSession}
true` + text;
str = str.replace(text, newCode);
return str;
},
skipFeedbackDialog(str: string) {
const text = '&&this.shouldTransitionToFeedback(';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, '&& false ' + text);
return str;
},
enableNativeMkb(str: string) {
const text = 'e.mouseSupported&&e.keyboardSupported&&e.fullscreenSupported;';
if ((!str.includes(text))) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
patchMouseAndKeyboardEnabled(str: string) {
const text = 'get mouseAndKeyboardEnabled(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
exposeInputSink(str: string) {
const text = 'this.controlChannel=null,this.inputChannel=null';
if (!str.includes(text)) {
return false;
}
const newCode = 'window.BX_EXPOSED.inputSink = this;';
str = str.replace(text, newCode + text);
return str;
},
disableNativeRequestPointerLock(str: string) {
const text = 'async requestPointerLock(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return;');
return str;
},
// Fix crashing when RequestInfo.origin is empty
patchRequestInfoCrash(str: string) {
const text = 'if(!e)throw new Error("RequestInfo.origin is falsy");';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, 'if (!e) e = "https://www.xbox.com";');
return str;
},
exposeDialogRoutes(str: string) {
const text = 'return{goBack:function(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, 'return window.BX_EXPOSED.dialogRoutes = {goBack:function(){');
return str;
}
}; };
let PATCH_ORDERS = [ let PATCH_ORDERS: PatchArray = [
getPref(PrefKey.BLOCK_TRACKING) && [ ...(getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' ? [
'enableNativeMkb',
'patchMouseAndKeyboardEnabled',
'disableNativeRequestPointerLock',
'exposeInputSink',
] : []),
'patchRequestInfoCrash',
'disableStreamGate',
'overrideSettings',
'broadcastPollingMode',
'exposeStreamSession',
'exposeDialogRoutes',
getPref(PrefKey.UI_LAYOUT) !== 'default' && 'websiteLayout',
getPref(PrefKey.LOCAL_CO_OP_ENABLED) && 'supportLocalCoOp',
getPref(PrefKey.GAME_FORTNITE_FORCE_CONSOLE) && 'forceFortniteConsole',
...(getPref(PrefKey.BLOCK_TRACKING) ? [
'disableAiTrack', 'disableAiTrack',
'disableTelemetry', 'disableTelemetry',
],
['disableStreamGate'],
['broadcastPollingMode'],
getPref(PrefKey.UI_LAYOUT) === 'tv' && ['tvLayout'],
BX_FLAGS.EnableXcloudLogging && [
'enableConsoleLogging',
'enableXcloudLogger',
],
getPref(PrefKey.LOCAL_CO_OP_ENABLED) && ['supportLocalCoOp'],
getPref(PrefKey.BLOCK_TRACKING) && [
'blockWebRtcStatsCollector', 'blockWebRtcStatsCollector',
'disableIndexDbLogging', 'disableIndexDbLogging',
],
getPref(PrefKey.BLOCK_TRACKING) && [
'disableTelemetryProvider', 'disableTelemetryProvider',
'disableTrackEvent', 'disableTrackEvent',
], ] : []),
getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayKeepAlive'], ...(getPref(PrefKey.REMOTE_PLAY_ENABLED) ? [
getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayDirectConnectUrl'], 'remotePlayKeepAlive',
'remotePlayDirectConnectUrl',
[ 'remotePlayDisableAchievementToast',
'overrideSettings', STATES.userAgentHasTouchSupport && 'patchUpdateInputConfigurationAsync',
], ] : []),
getPref(PrefKey.REMOTE_PLAY_ENABLED) && STATES.hasTouchSupport && ['patchUpdateInputConfigurationAsync'],
getPref(PrefKey.GAME_FORTNITE_FORCE_CONSOLE) && ['forceFortniteConsole'],
];
...(BX_FLAGS.EnableXcloudLogging ? [
'enableConsoleLogging',
'enableXcloudLogger',
] : []),
].filter(item => !!item);
// Only when playing // Only when playing
const PLAYING_PATCH_ORDERS = [ let PLAYING_PATCH_ORDERS: PatchArray = [
['patchXcloudTitleInfo'], 'patchXcloudTitleInfo',
getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['patchRemotePlayMkb'], 'disableGamepadDisconnectedScreen',
'patchStreamHud',
'playVibration',
getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayConnectMode'], // 'exposeEventTarget',
getPref(PrefKey.REMOTE_PLAY_ENABLED) && ['remotePlayGuideWorkaround'],
['patchStreamHud'], // Patch volume control for normal stream
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && !getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchAudioMediaStream',
// Patch volume control for combined audio+video stream
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchCombinedAudioVideoMediaStream',
['playVibration'], // Skip feedback dialog
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && ['exposeTouchLayoutManager'], getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog',
STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && ['disableTakRenderer'],
BX_FLAGS.EnableXcloudLogging && ['enableConsoleLogging'], ...(STATES.userAgentHasTouchSupport ? [
getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls',
getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager',
(getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer',
getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity',
'patchBabylonRendererClass',
] : []),
getPref(PrefKey.BLOCK_TRACKING) && ['blockGamepadStatsCollector'], BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging',
[ 'patchPollGamepads',
'disableGamepadDisconnectedScreen',
],
getPref(PrefKey.STREAM_COMBINE_SOURCES) && ['streamCombineSources'], getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'streamCombineSources',
];
...(getPref(PrefKey.REMOTE_PLAY_ENABLED) ? [
'patchRemotePlayMkb',
'remotePlayConnectMode',
] : []),
].filter(item => !!item);
const ALL_PATCHES = [...PATCH_ORDERS, ...PLAYING_PATCH_ORDERS];
export class Patcher { export class Patcher {
static #patchFunctionBind() { static #patchFunctionBind() {
const nativeBind = Function.prototype.bind; const nativeBind = Function.prototype.bind;
Function.prototype.bind = function() { Function.prototype.bind = function() {
let valid = false; let valid = false;
// Looking for these criteria:
// - Variable name <= 2 characters
// - Has 2 params:
// - The first one is null
// - The second one is either 0 or a function
if (this.name.length <= 2 && arguments.length === 2 && arguments[0] === null) { if (this.name.length <= 2 && arguments.length === 2 && arguments[0] === null) {
if (arguments[1] === 0 || (typeof arguments[1] === 'function')) { if (arguments[1] === 0 || (typeof arguments[1] === 'function')) {
valid = true; valid = true;
@ -507,18 +753,15 @@ export class Patcher {
return nativeBind.apply(this, arguments); return nativeBind.apply(this, arguments);
} }
PatcherCache.init();
if (typeof arguments[1] === 'function') { if (typeof arguments[1] === 'function') {
console.log('[Better xCloud] Restored Function.prototype.bind()'); BxLogger.info(LOG_TAG, 'Restored Function.prototype.bind()');
Function.prototype.bind = nativeBind; Function.prototype.bind = nativeBind;
} }
const orgFunc = this; const orgFunc = this;
const newFunc = (a: any, item: any) => { const newFunc = (a: any, item: any) => {
if (Patcher.length() === 0) {
orgFunc(a, item);
return;
}
Patcher.patch(item); Patcher.patch(item);
orgFunc(a, item); orgFunc(a, item);
} }
@ -528,98 +771,189 @@ export class Patcher {
}; };
} }
static length() { return PATCH_ORDERS.length; }; static patch(item: [[number], { [key: string]: () => {} }]) {
// !!! Use "caches" as variable name will break touch controller???
static patch(item: any) {
// console.log('patch', '-----'); // console.log('patch', '-----');
let appliedPatches; let patchesToCheck: PatchArray;
let appliedPatches: PatchArray;
const patchesMap: Record<string, PatchArray> = {};
for (let id in item[1]) { for (let id in item[1]) {
if (PATCH_ORDERS.length <= 0) {
return;
}
appliedPatches = []; appliedPatches = [];
const func = item[1][id];
let str = func.toString();
for (let groupIndex = 0; groupIndex < PATCH_ORDERS.length; groupIndex++) { const cachedPatches = PatcherCache.getPatches(id);
const group = PATCH_ORDERS[groupIndex]; if (cachedPatches) {
let modified = false; patchesToCheck = cachedPatches.slice(0);
patchesToCheck.push(...PATCH_ORDERS);
for (let patchIndex = 0; patchIndex < group.length; patchIndex++) { } else {
const patchName = group[patchIndex] as keyof typeof PATCHES; patchesToCheck = PATCH_ORDERS.slice(0);
if (appliedPatches.indexOf(patchName) > -1) {
continue;
}
const patchedstr = PATCHES[patchName].call(null, str);
if (!patchedstr) {
// Only stop if the first patch is failed
if (patchIndex === 0) {
break;
} else {
continue;
}
}
modified = true;
str = patchedstr;
console.log(`[Better xCloud] Applied "${patchName}" patch`);
appliedPatches.push(patchName);
// Remove patch from group
group.splice(patchIndex, 1);
patchIndex--;
}
// Apply patched functions
if (modified) {
item[1][id] = eval(str);
}
// Remove empty group
if (!group.length) {
PATCH_ORDERS.splice(groupIndex, 1);
groupIndex--;
}
} }
}
}
// Remove disabled patches // Empty patch list
static cleanupPatches() { if (!patchesToCheck.length) {
for (let groupIndex = PATCH_ORDERS.length - 1; groupIndex >= 0; groupIndex--) {
const group = PATCH_ORDERS[groupIndex];
if (group === false) {
PATCH_ORDERS.splice(groupIndex, 1);
continue; continue;
} }
for (let patchIndex = group.length - 1; patchIndex >= 0; patchIndex--) { const func = item[1][id];
const patchName = group[patchIndex] as keyof typeof PATCHES; let str = func.toString();
if (!PATCHES[patchName]) {
// Remove disabled patch let modified = false;
group.splice(patchIndex, 1);
for (let patchIndex = 0; patchIndex < patchesToCheck.length; patchIndex++) {
const patchName = patchesToCheck[patchIndex];
if (appliedPatches.indexOf(patchName) > -1) {
continue;
} }
if (!PATCHES[patchName]) {
continue;
}
// Check function against patch
const patchedStr = PATCHES[patchName].call(null, str);
// Not patched
if (!patchedStr) {
continue;
}
modified = true;
str = patchedStr;
BxLogger.info(LOG_TAG, `${patchName}`);
appliedPatches.push(patchName);
// Remove patch
patchesToCheck.splice(patchIndex, 1);
patchIndex--;
PATCH_ORDERS = PATCH_ORDERS.filter(item => item != patchName);
} }
// Remove empty group // Apply patched functions
if (!group.length) { if (modified) {
PATCH_ORDERS.splice(groupIndex, 1); item[1][id] = eval(str);
} }
// Save to cache
if (appliedPatches.length) {
patchesMap[id] = appliedPatches;
}
}
if (Object.keys(patchesMap).length) {
PatcherCache.saveToCache(patchesMap);
} }
} }
static initialize() { static init() {
if (window.location.pathname.includes('/play/')) {
PATCH_ORDERS = PATCH_ORDERS.concat(PLAYING_PATCH_ORDERS);
} else {
PATCH_ORDERS.push(['loadingEndingChunks']);
}
Patcher.cleanupPatches();
Patcher.#patchFunctionBind(); Patcher.#patchFunctionBind();
} }
} }
export class PatcherCache {
static #KEY_CACHE = 'better_xcloud_patches_cache';
static #KEY_SIGNATURE = 'better_xcloud_patches_cache_signature';
static #CACHE: any;
static #isInitialized = false;
/**
* Get patch's signature
*/
static #getSignature(): number {
const scriptVersion = SCRIPT_VERSION;
const webVersion = (document.querySelector('meta[name=gamepass-app-version]') as HTMLMetaElement)?.content;
const patches = JSON.stringify(ALL_PATCHES);
// Calculate signature
const sig = hashCode(scriptVersion + webVersion + patches)
return sig;
}
static clear() {
// Clear cache
window.localStorage.removeItem(PatcherCache.#KEY_CACHE);
PatcherCache.#CACHE = {};
}
static checkSignature() {
const storedSig = window.localStorage.getItem(PatcherCache.#KEY_SIGNATURE) || 0;
const currentSig = PatcherCache.#getSignature();
if (currentSig !== parseInt(storedSig as string)) {
// Save new signature
BxLogger.warning(LOG_TAG, 'Signature changed');
window.localStorage.setItem(PatcherCache.#KEY_SIGNATURE, currentSig.toString());
PatcherCache.clear();
} else {
BxLogger.info(LOG_TAG, 'Signature unchanged');
}
}
static #cleanupPatches(patches: PatchArray): PatchArray {
return patches.filter(item => {
for (const id in PatcherCache.#CACHE) {
const cached = PatcherCache.#CACHE[id];
if (cached.includes(item)) {
return false;
}
}
return true;
});
}
static getPatches(id: string): PatchArray {
return PatcherCache.#CACHE[id];
}
static saveToCache(subCache: Record<string, PatchArray>) {
for (const id in subCache) {
const patchNames = subCache[id];
let data = PatcherCache.#CACHE[id];
if (!data) {
PatcherCache.#CACHE[id] = patchNames;
} else {
for (const patchName of patchNames) {
if (!data.includes(patchName)) {
data.push(patchName);
}
}
}
}
// Save to storage
window.localStorage.setItem(PatcherCache.#KEY_CACHE, JSON.stringify(PatcherCache.#CACHE));
}
static init() {
if (PatcherCache.#isInitialized) {
return;
}
PatcherCache.#isInitialized = true;
PatcherCache.checkSignature();
// Read cache from storage
PatcherCache.#CACHE = JSON.parse(window.localStorage.getItem(PatcherCache.#KEY_CACHE) || '{}');
BxLogger.info(LOG_TAG, PatcherCache.#CACHE);
if (window.location.pathname.includes('/play/')) {
PATCH_ORDERS.push(...PLAYING_PATCH_ORDERS);
} else {
PATCH_ORDERS.push(ENDING_CHUNKS_PATCH_NAME);
}
// Remove cached patches from PATCH_ORDERS & PLAYING_PATCH_ORDERS
PATCH_ORDERS = PatcherCache.#cleanupPatches(PATCH_ORDERS);
PLAYING_PATCH_ORDERS = PatcherCache.#cleanupPatches(PLAYING_PATCH_ORDERS);
BxLogger.info(LOG_TAG, PATCH_ORDERS.slice(0));
BxLogger.info(LOG_TAG, PLAYING_PATCH_ORDERS.slice(0));
}
}

View File

@ -0,0 +1,94 @@
const currentGamepad = ${gamepadVar};
// Share button on XS controller
if (currentGamepad.buttons[17] && currentGamepad.buttons[17].pressed) {
window.dispatchEvent(new Event(BxEvent.CAPTURE_SCREENSHOT));
}
const btnHome = currentGamepad.buttons[16];
if (btnHome) {
if (!this.bxHomeStates) {
this.bxHomeStates = {};
}
let intervalMs = 0;
let hijack = false;
if (btnHome.pressed) {
hijack = true;
intervalMs = 16;
this.gamepadIsIdle.set(currentGamepad.index, false);
if (this.bxHomeStates[currentGamepad.index]) {
const lastTimestamp = this.bxHomeStates[currentGamepad.index].timestamp;
if (currentGamepad.timestamp !== lastTimestamp) {
this.bxHomeStates[currentGamepad.index].timestamp = currentGamepad.timestamp;
const handled = window.BX_EXPOSED.handleControllerShortcut(currentGamepad);
if (handled) {
this.bxHomeStates[currentGamepad.index].shortcutPressed += 1;
}
}
} else {
// First time pressing > save current timestamp
window.BX_EXPOSED.resetControllerShortcut(currentGamepad.index);
this.bxHomeStates[currentGamepad.index] = {
shortcutPressed: 0,
timestamp: currentGamepad.timestamp,
};
}
} else if (this.bxHomeStates[currentGamepad.index]) {
hijack = true;
const info = structuredClone(this.bxHomeStates[currentGamepad.index]);
// Home button released
this.bxHomeStates[currentGamepad.index] = null;
if (info.shortcutPressed === 0) {
const fakeGamepadMappings = [{
GamepadIndex: currentGamepad.index,
A: 0,
B: 0,
X: 0,
Y: 0,
LeftShoulder: 0,
RightShoulder: 0,
LeftTrigger: 0,
RightTrigger: 0,
View: 0,
Menu: 0,
LeftThumb: 0,
RightThumb: 0,
DPadUp: 0,
DPadDown: 0,
DPadLeft: 0,
DPadRight: 0,
Nexus: 1,
LeftThumbXAxis: 0,
LeftThumbYAxis: 0,
RightThumbXAxis: 0,
RightThumbYAxis: 0,
PhysicalPhysicality: 0,
VirtualPhysicality: 0,
Dirty: true,
Virtual: false,
}];
const isLongPress = (currentGamepad.timestamp - info.timestamp) >= 500;
intervalMs = isLongPress ? 500 : 100;
this.inputSink.onGamepadInput(performance.now() - intervalMs, fakeGamepadMappings);
} else {
intervalMs = 4;
}
}
if (hijack && intervalMs) {
// Listen to next button press
this.inputConfiguration.useIntervalWorkerThreadForInput && this.intervalWorker ? this.intervalWorker.scheduleTimer(intervalMs) : this.pollGamepadssetTimeoutTimerID = setTimeout(this.pollGamepads, intervalMs);
// Hijack this button
return;
}
}

View File

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

View File

@ -0,0 +1,17 @@
let match;
let onGamepadChangedStr = this.onGamepadChanged.toString();
onGamepadChangedStr = onGamepadChangedStr.replaceAll('0', 'arguments[1]');
eval(`this.onGamepadChanged = function ${onGamepadChangedStr}`);
let onGamepadInputStr = this.onGamepadInput.toString();
match = onGamepadInputStr.match(/(\w+\.GamepadIndex)/);
if (match) {
const gamepadIndexVar = match[0];
onGamepadInputStr = onGamepadInputStr.replace('this.gamepadStates.get(', `this.gamepadStates.get(${gamepadIndexVar},`);
eval(`this.onGamepadInput = function ${onGamepadInputStr}`);
BxLogger.info('supportLocalCoOp', '✅ Successfully patched local co-op support');
} else {
BxLogger.error('supportLocalCoOp', '❌ Unable to patch local co-op support');
}

View File

@ -0,0 +1,2 @@
connectMode: window.BX_REMOTE_PLAY_CONFIG ? "xhome-connect" : "cloud-connect",
remotePlayServerId: (window.BX_REMOTE_PLAY_CONFIG && window.BX_REMOTE_PLAY_CONFIG.serverId) || '',

View File

@ -0,0 +1,7 @@
const msg = JSON.parse(e);
if (msg.reason === 'WarningForBeingIdle' && !window.location.pathname.includes('/launch/')) {
try {
this.sendKeepAlive();
return;
} catch (ex) { console.log(ex); }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,14 @@
import { STATES, AppInterface } from "../utils/global"; import { STATES, AppInterface } from "@utils/global";
import { CE, createButton, ButtonStyle, Icon } from "../utils/html"; import { CE, createButton, ButtonStyle } from "@utils/html";
import { Toast } from "../utils/toast"; import { BxIcon } from "@utils/bx-icon";
import { BxEvent } from "../utils/bx-event"; import { Toast } from "@utils/toast";
import { getPref, PrefKey, setPref } from "../utils/preferences"; import { BxEvent } from "@utils/bx-event";
import { t } from "../utils/translation"; import { getPref, PrefKey, setPref } from "@utils/preferences";
import { localRedirect } from "./ui/ui"; import { t } from "@utils/translation";
import { localRedirect } from "@modules/ui/ui";
import { BxLogger } from "@utils/bx-logger";
const LOG_TAG = 'RemotePlay';
enum RemotePlayConsoleState { enum RemotePlayConsoleState {
ON = 'On', ON = 'On',
@ -92,7 +96,7 @@ export class RemotePlay {
RemotePlay.#$content = CE('div', {}, t('getting-consoles-list')); RemotePlay.#$content = CE('div', {}, t('getting-consoles-list'));
RemotePlay.#getXhomeToken(() => { RemotePlay.#getXhomeToken(() => {
RemotePlay.#getConsolesList(() => { RemotePlay.#getConsolesList(() => {
console.log(RemotePlay.#CONSOLES); BxLogger.info(LOG_TAG, 'Consoles', RemotePlay.#CONSOLES);
RemotePlay.#renderConsoles(); RemotePlay.#renderConsoles();
BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY); BxEvent.dispatch(window, BxEvent.REMOTE_PLAY_READY);
}); });
@ -180,7 +184,7 @@ export class RemotePlay {
// Add Help button // Add Help button
$fragment.appendChild(createButton({ $fragment.appendChild(createButton({
icon: Icon.QUESTION, icon: BxIcon.QUESTION,
style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE, style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE,
url: 'https://better-xcloud.github.io/remote-play', url: 'https://better-xcloud.github.io/remote-play',
label: t('help'), label: t('help'),

View File

@ -1,97 +0,0 @@
import { STATES, AppInterface } from "../utils/global";
import { CE } from "../utils/html";
export function takeScreenshot(callback: any) {
const currentStream = STATES.currentStream!;
const $video = currentStream.$video;
const $canvas = currentStream.$screenshotCanvas;
if (!$video || !$canvas) {
return;
}
const $canvasContext = $canvas.getContext('2d')!;
$canvasContext.drawImage($video, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app
if (AppInterface) {
const data = $canvas.toDataURL('image/png').split(';base64,')[1];
AppInterface.saveScreenshot(currentStream.titleId, data);
// Free screenshot from memory
$canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
return;
}
$canvas && $canvas.toBlob(blob => {
// Download screenshot
const now = +new Date;
const $anchor = CE<HTMLAnchorElement>('a', {
'download': `${currentStream.titleId}-${now}.png`,
'href': URL.createObjectURL(blob!),
});
$anchor.click();
// Free screenshot from memory
URL.revokeObjectURL($anchor.href);
$canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
}, 'image/png');
}
export function setupScreenshotButton() {
const currentStream = STATES.currentStream!
currentStream.$screenshotCanvas = CE('canvas', {'class': 'bx-screenshot-canvas'});
document.documentElement.appendChild(currentStream.$screenshotCanvas!);
const delay = 2000;
const $btn = CE('div', {'class': 'bx-screenshot-button', 'data-showing': false});
let timeout: number | null;
const detectDbClick = (e: MouseEvent) => {
if (!currentStream.$video) {
timeout = null;
$btn.style.display = 'none';
return;
}
if (timeout) {
clearTimeout(timeout);
timeout = null;
$btn.setAttribute('data-capturing', 'true');
takeScreenshot(() => {
// Hide button
$btn.setAttribute('data-showing', 'false');
window.setTimeout(() => {
if (!timeout) {
$btn.setAttribute('data-capturing', 'false');
}
}, 100);
});
return;
}
const isShowing = $btn.getAttribute('data-showing') === 'true';
if (!isShowing) {
// Show button
$btn.setAttribute('data-showing', 'true');
$btn.setAttribute('data-capturing', 'false');
timeout && clearTimeout(timeout);
timeout = window.setTimeout(() => {
timeout = null;
$btn.setAttribute('data-showing', 'false');
$btn.setAttribute('data-capturing', 'false');
}, delay);
}
}
$btn.addEventListener('mousedown', detectDbClick);
document.documentElement.appendChild($btn);
}

View File

@ -0,0 +1,33 @@
import { t } from "@utils/translation";
import { Toast } from "@utils/toast";
export enum MicrophoneState {
REQUESTED = 'Requested',
ENABLED = 'Enabled',
MUTED = 'Muted',
NOT_ALLOWED = 'NotAllowed',
NOT_FOUND = 'NotFound',
}
export class MicrophoneShortcut {
static toggle(showToast: boolean = true): boolean {
if (!window.BX_EXPOSED.streamSession) {
return false;
}
const state = window.BX_EXPOSED.streamSession._microphoneState;
const enableMic = state === MicrophoneState.ENABLED ? false : true;
try {
window.BX_EXPOSED.streamSession.tryEnableChatAsync(enableMic);
showToast && Toast.show(t('microphone'), t(enableMic ? 'unmuted': 'muted'), {instant: true});
return enableMic;
} catch (e) {
console.log(e);
}
return false;
}
}

View File

@ -0,0 +1,90 @@
import { t } from "@utils/translation";
import { STATES } from "@utils/global";
import { PrefKey, getPref, setPref } from "@utils/preferences";
import { Toast } from "@utils/toast";
import { BxEvent } from "@/utils/bx-event";
import { ceilToNearest, floorToNearest } from "@/utils/utils";
export class SoundShortcut {
static adjustGainNodeVolume(amount: number): number {
if (!getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL)) {
return 0;
}
const currentValue = getPref(PrefKey.AUDIO_VOLUME);
let nearestValue: number;
if (amount > 0) { // Increase
nearestValue = ceilToNearest(currentValue, amount);
} else { // Decrease
nearestValue = floorToNearest(currentValue, -1 * amount);
}
let newValue: number;
if (currentValue !== nearestValue) {
newValue = nearestValue;
} else {
newValue = currentValue + amount;
}
newValue = setPref(PrefKey.AUDIO_VOLUME, newValue);
SoundShortcut.setGainNodeVolume(newValue);
// Show toast
Toast.show(`${t('stream')} ${t('volume')}`, newValue + '%', {instant: true});
BxEvent.dispatch(window, BxEvent.GAINNODE_VOLUME_CHANGED, {
volume: newValue,
});
return newValue;
}
static setGainNodeVolume(value: number) {
STATES.currentStream.audioGainNode && (STATES.currentStream.audioGainNode.gain.value = value / 100);
}
static muteUnmute() {
if (getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && STATES.currentStream.audioGainNode) {
const gainValue = STATES.currentStream.audioGainNode.gain.value;
const settingValue = getPref(PrefKey.AUDIO_VOLUME);
let targetValue: number;
if (settingValue === 0) { // settingValue is 0 => set to 100
targetValue = 100;
setPref(PrefKey.AUDIO_VOLUME, targetValue);
BxEvent.dispatch(window, BxEvent.GAINNODE_VOLUME_CHANGED, {
volume: targetValue,
});
} else if (gainValue === 0) { // is being muted => set to settingValue
targetValue = settingValue;
} else { // not being muted => mute
targetValue = 0;
}
let status: string;
if (targetValue === 0) {
status = t('muted');
} else {
status = targetValue + '%';
}
SoundShortcut.setGainNodeVolume(targetValue);
Toast.show(`${t('stream')} ${t('volume')}`, status, {instant: true});
return;
}
let $media: HTMLMediaElement;
$media = document.querySelector('div[data-testid=media-container] audio') as HTMLAudioElement;
if (!$media) {
$media = document.querySelector('div[data-testid=media-container] video') as HTMLAudioElement;
}
if ($media) {
$media.muted = !$media.muted;
const status = $media.muted ? t('muted') : t('unmuted');
Toast.show(`${t('stream')} ${t('volume')}`, status, {instant: true});
}
}
}

View File

@ -0,0 +1,6 @@
export class StreamUiShortcut {
static showHideStreamMenu() {
// Show menu
window.BX_EXPOSED.showStreamMenu && window.BX_EXPOSED.showStreamMenu();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,77 +1,19 @@
import { STATES } from "../../utils/global"; import { STATES } from "@utils/global.ts";
import { Icon } from "../../utils/html"; import { createSvgIcon } from "@utils/html.ts";
import { BxEvent } from "../../utils/bx-event"; import { BxIcon } from "@utils/bx-icon";
import { PrefKey, getPref } from "../../utils/preferences"; import { BxEvent } from "@utils/bx-event.ts";
import { t } from "../../utils/translation"; import { t } from "@utils/translation.ts";
import { StreamBadges } from "./stream-badges"; import { StreamBadges } from "./stream-badges.ts";
import { StreamStats } from "./stream-stats"; import { StreamStats } from "./stream-stats.ts";
import { StreamSettings } from "./stream-settings.ts";
class MouseHoldEvent { function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) {
#isHolding = false;
#timeout?: number | null;
#$elm;
#callback;
#duration;
#onMouseDown(e: MouseEvent | TouchEvent) {
const _this = this;
this.#isHolding = false;
this.#timeout && clearTimeout(this.#timeout);
this.#timeout = window.setTimeout(() => {
_this.#isHolding = true;
_this.#callback();
}, this.#duration);
};
#onMouseUp(e: MouseEvent | TouchEvent) {
this.#timeout && clearTimeout(this.#timeout);
this.#timeout = null;
if (this.#isHolding) {
e.preventDefault();
e.stopPropagation();
}
this.#isHolding = false;
};
#addEventListeners = () => {
this.#$elm.addEventListener('mousedown', this.#onMouseDown.bind(this));
this.#$elm.addEventListener('click', this.#onMouseUp.bind(this));
this.#$elm.addEventListener('touchstart', this.#onMouseDown.bind(this));
this.#$elm.addEventListener('touchend', this.#onMouseUp.bind(this));
}
/*
#clearEventLiseners = () => {
this.#$elm.removeEventListener('mousedown', this.#onMouseDown);
this.#$elm.removeEventListener('click', this.#onMouseUp);
this.#$elm.removeEventListener('touchstart', this.#onMouseDown);
this.#$elm.removeEventListener('touchend', this.#onMouseUp);
}
*/
constructor($elm: HTMLElement, callback: any, duration=1000) {
this.#$elm = $elm;
this.#callback = callback;
this.#duration = duration;
this.#addEventListeners();
// $elm.clearMouseHoldEventListeners = this.#clearEventLiseners;
}
}
function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: Icon) {
const $container = $orgButton.cloneNode(true) as HTMLElement; const $container = $orgButton.cloneNode(true) as HTMLElement;
let timeout: number | null; let timeout: number | null;
const onTransitionStart = (e: TransitionEvent) => { const onTransitionStart = (e: TransitionEvent) => {
if ( e.propertyName !== 'opacity') { if (e.propertyName !== 'opacity') {
return; return;
} }
@ -80,7 +22,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: I
}; };
const onTransitionEnd = (e: TransitionEvent) => { const onTransitionEnd = (e: TransitionEvent) => {
if ( e.propertyName !== 'opacity') { if (e.propertyName !== 'opacity') {
return; return;
} }
@ -93,7 +35,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: I
} }
}; };
if (STATES.hasTouchSupport) { if (STATES.browserHasTouchSupport) {
$container.addEventListener('transitionstart', onTransitionStart); $container.addEventListener('transitionstart', onTransitionStart);
$container.addEventListener('transitionend', onTransitionEnd); $container.addEventListener('transitionend', onTransitionEnd);
} }
@ -101,26 +43,36 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: I
const $button = $container.querySelector('button')!; const $button = $container.querySelector('button')!;
$button.setAttribute('title', label); $button.setAttribute('title', label);
const $svg = $button.querySelector('svg')!; const $orgSvg = $button.querySelector('svg')!;
$svg.innerHTML = svgIcon; const $svg = createSvgIcon(svgIcon);
$svg.style.fill = 'none';
$svg.setAttribute('class', $orgSvg.getAttribute('class') || '');
$svg.ariaHidden = 'true';
$orgSvg.replaceWith($svg);
return $container;
}
function cloneCloseButton($$btnOrg: HTMLElement, icon: typeof BxIcon, className: string, onChange: any) {
// Create button from the Close button
const $btn = $$btnOrg.cloneNode(true) as HTMLElement;
// Refresh SVG
const $svg = createSvgIcon(icon);
// Copy classes
$svg.setAttribute('class', $btn.firstElementChild!.getAttribute('class') || '');
$svg.style.fill = 'none'; $svg.style.fill = 'none';
const attrs = { $btn.classList.add(className);
'fill': 'none', // Remove icon
'stroke': '#fff', $btn.removeChild($btn.firstElementChild!);
'fill-rule': 'evenodd', // Add icon
'stroke-linecap': 'round', $btn.appendChild($svg);
'stroke-linejoin': 'round', // Add "click" event listener
'stroke-width': '2', $btn.addEventListener('click', onChange);
'viewBox': '0 0 32 32'
};
let attr: keyof typeof attrs; return $btn;
for (attr in attrs) {
$svg.setAttribute(attr, attrs[attr]);
}
return $container;
} }
@ -136,53 +88,16 @@ export function injectStreamMenuButtons() {
($screen as any).xObserving = true; ($screen as any).xObserving = true;
const $quickBar = document.querySelector('.bx-quick-settings-bar')!;
const $parent = $screen.parentElement;
const hideQuickBarFunc = (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', hideQuickBarFunc);
}
}
// Hide Quick settings bar
$quickBar.classList.add('bx-gone');
$parent?.removeEventListener('click', hideQuickBarFunc);
// $parent.removeEventListener('touchstart', hideQuickBarFunc);
}
let $btnStreamSettings: HTMLElement; let $btnStreamSettings: HTMLElement;
let $btnStreamStats: HTMLElement; let $btnStreamStats: HTMLElement;
const streamStats = StreamStats.getInstance();
const PREF_DISABLE_FEEDBACK_DIALOG = getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG);
const observer = new MutationObserver(mutationList => { const observer = new MutationObserver(mutationList => {
mutationList.forEach(item => { mutationList.forEach(item => {
if (item.type !== 'childList') { if (item.type !== 'childList') {
return; return;
} }
item.removedNodes.forEach($node => {
if (!$node || $node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (!($node as HTMLElement).className || !($node as HTMLElement).className.startsWith) {
return;
}
if (($node as HTMLElement).className.startsWith('StreamMenu')) {
if (!document.querySelector('div[class^=PureInStreamConfirmationModal]')) {
BxEvent.dispatch(window, BxEvent.STREAM_MENU_HIDDEN);
}
}
});
item.addedNodes.forEach(async $node => { item.addedNodes.forEach(async $node => {
if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { if (!$node || $node.nodeType !== Node.ELEMENT_NODE) {
return; return;
@ -190,48 +105,50 @@ export function injectStreamMenuButtons() {
let $elm: HTMLElement | null = $node as HTMLElement; let $elm: HTMLElement | null = $node as HTMLElement;
// Ignore SVG elements
if ($elm instanceof SVGSVGElement) {
return;
}
// Error Page: .PureErrorPage.ErrorScreen // Error Page: .PureErrorPage.ErrorScreen
if ($elm.className.includes('PureErrorPage')) { if ($elm.className?.includes('PureErrorPage')) {
BxEvent.dispatch(window, BxEvent.STREAM_ERROR_PAGE); BxEvent.dispatch(window, BxEvent.STREAM_ERROR_PAGE);
return; return;
} }
if (PREF_DISABLE_FEEDBACK_DIALOG && $elm.className.startsWith('PostStreamFeedbackScreen')) {
const $btnClose = $elm.querySelector('button');
$btnClose && $btnClose.click();
return;
}
// Render badges // Render badges
if ($elm.className.startsWith('StreamMenu')) { if ($elm.className?.startsWith('StreamMenu-module__container')) {
BxEvent.dispatch(window, BxEvent.STREAM_MENU_SHOWN); const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]') as HTMLElement;
// Hide Quick bar when closing HUD
const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]');
if (!$btnCloseHud) { if (!$btnCloseHud) {
return; return;
} }
$btnCloseHud && $btnCloseHud.addEventListener('click', e => { // Hide Stream Settings dialog when closing HUD
$quickBar.classList.add('bx-gone'); $btnCloseHud.addEventListener('click', e => {
StreamSettings.getInstance().hide();
}); });
// Get "Quit game" button // Create Refresh button from the Close button
const $btnQuit = $elm.querySelector('div[class^=StreamMenu] > div > button:last-child') as HTMLElement; const $btnRefresh = cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => {
// Hold "Quit game" button to refresh the stream
new MouseHoldEvent($btnQuit, () => {
confirm(t('confirm-reload-stream')) && window.location.reload(); confirm(t('confirm-reload-stream')) && window.location.reload();
}, 1000); });
const $btnHome = cloneCloseButton($btnCloseHud, BxIcon.HOME, 'bx-stream-home-button', () => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
});
// Add to website
$btnCloseHud.insertAdjacentElement('afterend', $btnRefresh);
$btnRefresh.insertAdjacentElement('afterend', $btnHome);
// Render stream badges // Render stream badges
const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]');
$menu?.appendChild(await StreamBadges.render()); $menu?.appendChild(await StreamBadges.getInstance().render());
hideQuickBarFunc();
return; return;
} }
if ($elm.className.startsWith('Overlay-module_') || $elm.className.startsWith('InProgressScreen')) { if ($elm.className?.startsWith('Overlay-module_') || $elm.className?.startsWith('InProgressScreen')) {
$elm = $elm.querySelector('#StreamHud'); $elm = $elm.querySelector('#StreamHud');
} }
@ -261,38 +178,32 @@ export function injectStreamMenuButtons() {
// Create Stream Settings button // Create Stream Settings button
if (!$btnStreamSettings) { if (!$btnStreamSettings) {
$btnStreamSettings = cloneStreamHudButton($orgButton, t('menu-stream-settings'), Icon.STREAM_SETTINGS); $btnStreamSettings = cloneStreamHudButton($orgButton, t('stream-settings'), BxIcon.STREAM_SETTINGS);
$btnStreamSettings.addEventListener('click', e => { $btnStreamSettings.addEventListener('click', e => {
hideGripHandle(); hideGripHandle();
e.preventDefault(); e.preventDefault();
// Show Quick settings bar // Show Stream Settings dialog
$quickBar.classList.remove('bx-gone'); StreamSettings.getInstance().show();
$parent?.addEventListener('click', hideQuickBarFunc);
//$parent.addEventListener('touchstart', hideQuickBarFunc);
const $touchSurface = document.getElementById('MultiTouchSurface');
$touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideQuickBarFunc);
}); });
} }
// Create Stream Stats button // Create Stream Stats button
if (!$btnStreamStats) { if (!$btnStreamStats) {
$btnStreamStats = cloneStreamHudButton($orgButton, t('menu-stream-stats'), Icon.STREAM_STATS); $btnStreamStats = cloneStreamHudButton($orgButton, t('stream-stats'), BxIcon.STREAM_STATS);
$btnStreamStats.addEventListener('click', e => { $btnStreamStats.addEventListener('click', e => {
hideGripHandle(); hideGripHandle();
e.preventDefault(); e.preventDefault();
// Toggle Stream Stats // Toggle Stream Stats
StreamStats.toggle(); streamStats.toggle();
const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing());
$btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn);
}); });
} }
const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing());
$btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn);
if ($orgButton) { if ($orgButton) {
@ -311,37 +222,3 @@ export function injectStreamMenuButtons() {
}); });
observer.observe($screen, {subtree: true, childList: true}); observer.observe($screen, {subtree: true, childList: true});
} }
export function showStreamSettings(tabId: string) {
const $wrapper = document.querySelector('.bx-quick-settings-bar');
if (!$wrapper) {
return;
}
// Select tab
if (tabId) {
const $tab = $wrapper.querySelector(`.bx-quick-settings-tabs svg[data-group=${tabId}]`);
$tab && $tab.dispatchEvent(new Event('click'));
}
$wrapper.classList.remove('bx-gone');
const $screen = document.querySelector('#PageContent section[class*=PureScreens]');
if ($screen && $screen.parentElement) {
const $parent = $screen.parentElement;
if (!$parent || ($parent as any).bxClick) {
return;
}
($parent as any).bxClick = true;
const onClick = (e: Event) => {
$wrapper.classList.add('bx-gone');
($parent as any).bxClick = false;
$parent.removeEventListener('click', onClick);
};
$parent.addEventListener('click', onClick);
}
}

View File

@ -1,15 +1,21 @@
import { STATES } from "../utils/global"; import { STATES } from "@utils/global";
import { CE } 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 } from "../utils/bx-flags"; import { BX_FLAGS, NATIVE_FETCH } from "@utils/bx-flags";
import { getPref, PrefKey } from "../utils/preferences"; import { getPref, PrefKey } from "@utils/preferences";
import { t } from "../utils/translation"; import { t } from "@utils/translation";
import { NATIVE_FETCH } from "../utils/network"; import { BxLogger } from "@utils/bx-logger";
const LOG_TAG = 'TouchController';
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: '{"content":"{\\"layoutId\\":\\"\\"}","target":"/streaming/touchcontrols/showlayoutv2","type":"Message"}', data: JSON.stringify({
content: '{"layoutId":""}',
target: '/streaming/touchcontrols/showlayoutv2',
type: 'Message',
}),
origin: 'better-xcloud', origin: 'better-xcloud',
}); });
@ -20,17 +26,17 @@ export class TouchController {
}); });
*/ */
static #$bar: HTMLElement;
static #$style: HTMLStyleElement; static #$style: HTMLStyleElement;
static #enable = false; static #enable = false;
static #showing = false;
static #dataChannel: RTCDataChannel | null; static #dataChannel: RTCDataChannel | null;
static #customLayouts: {[index: string]: any} = {}; static #customLayouts: {[index: string]: any} = {};
static #baseCustomLayouts: {[index: string]: any} = {}; static #baseCustomLayouts: {[index: string]: any} = {};
static #currentLayoutId: string; static #currentLayoutId: string;
static #customList: string[];
static enable() { static enable() {
TouchController.#enable = true; TouchController.#enable = true;
} }
@ -45,37 +51,28 @@ export class TouchController {
static #showDefault() { static #showDefault() {
TouchController.#dispatchMessage(TouchController.#EVENT_SHOW_DEFAULT_CONTROLLER); TouchController.#dispatchMessage(TouchController.#EVENT_SHOW_DEFAULT_CONTROLLER);
TouchController.#showing = true;
} }
static #show() { static #show() {
document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.remove('bx-offscreen'); document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.remove('bx-offscreen');
TouchController.#showing = true;
} }
static #hide() { static #hide() {
document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.add('bx-offscreen'); document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.add('bx-offscreen');
TouchController.#showing = false;
} }
static #toggleVisibility() { static toggleVisibility(status: boolean) {
if (!TouchController.#dataChannel) { if (!TouchController.#dataChannel) {
return; return;
} }
TouchController.#showing ? TouchController.#hide() : TouchController.#show(); status ? TouchController.#hide() : TouchController.#show();
}
static #toggleBar(value: boolean) {
TouchController.#$bar && TouchController.#$bar.setAttribute('data-showing', value.toString());
} }
static reset() { static reset() {
TouchController.#enable = false; TouchController.#enable = false;
TouchController.#showing = false;
TouchController.#dataChannel = null; TouchController.#dataChannel = null;
TouchController.#$bar && TouchController.#$bar.removeAttribute('data-showing');
TouchController.#$style && (TouchController.#$style.textContent = ''); TouchController.#$style && (TouchController.#$style.textContent = '');
} }
@ -100,7 +97,7 @@ export class TouchController {
retries = retries || 1; retries = retries || 1;
if (retries > 2) { if (retries > 2) {
TouchController.#customLayouts[xboxTitleId] = null; TouchController.#customLayouts[xboxTitleId] = null;
// Wait for BX_EXPOSED.touch_layout_manager // Wait for BX_EXPOSED.touchLayoutManager
window.setTimeout(() => TouchController.#dispatchLayouts(null), 1000); window.setTimeout(() => TouchController.#dispatchLayouts(null), 1000);
return; return;
} }
@ -136,7 +133,7 @@ export class TouchController {
json.layouts = layouts; json.layouts = layouts;
TouchController.#customLayouts[xboxTitleId] = json; TouchController.#customLayouts[xboxTitleId] = json;
// Wait for BX_EXPOSED.touch_layout_manager // Wait for BX_EXPOSED.touchLayoutManager
window.setTimeout(() => TouchController.#dispatchLayouts(json), 1000); window.setTimeout(() => TouchController.#dispatchLayouts(json), 1000);
} catch (e) { } catch (e) {
// Retry // Retry
@ -145,7 +142,16 @@ export class TouchController {
} }
static loadCustomLayout(xboxTitleId: string, layoutId: string, delay: number=0) { static loadCustomLayout(xboxTitleId: string, layoutId: string, delay: number=0) {
if (!window.BX_EXPOSED.touch_layout_manager) { // TODO: fix this
if (!window.BX_EXPOSED.touchLayoutManager) {
const listener = (e: Event) => {
window.removeEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener);
if (TouchController.#enable) {
TouchController.loadCustomLayout(xboxTitleId, layoutId, 0);
}
};
window.addEventListener(BxEvent.TOUCH_LAYOUT_MANAGER_READY, listener);
return; return;
} }
@ -165,69 +171,71 @@ export class TouchController {
} }
// Show a toast with layout's name // Show a toast with layout's name
layoutChanged && Toast.show(t('touch-control-layout'), layout.name); let msg: string;
let html = false;
if (layout.author) {
const author = `<b>${escapeHtml(layout.author)}</b>`;
msg = t('touch-control-layout-by', {name: author});
html = true;
} else {
msg = t('touch-control-layout');
}
layoutChanged && Toast.show(msg, layout.name, {html: html});
window.setTimeout(() => { window.setTimeout(() => {
window.BX_EXPOSED.touch_layout_manager.changeLayoutForScope({ // Show gyroscope control in the "More options" dialog if this layout has gyroscope
window.BX_EXPOSED.shouldShowSensorControls = JSON.stringify(layout).includes('gyroscope');
window.BX_EXPOSED.touchLayoutManager.changeLayoutForScope({
type: 'showLayout', type: 'showLayout',
scope: xboxTitleId, scope: xboxTitleId,
subscope: 'base', subscope: 'base',
layout: { layout: {
id: 'System.Standard', id: 'System.Standard',
displayName: 'System', displayName: 'System',
layoutFile: { layoutFile: layout,
content: layout.content,
},
} }
}); });
}, delay); }, delay);
} }
static updateCustomList() {
const key = 'better_xcloud_custom_touch_layouts';
TouchController.#customList = JSON.parse(window.localStorage.getItem(key) || '[]');
NATIVE_FETCH('https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts/ids.json')
.then(response => response.json())
.then(json => {
TouchController.#customList = json;
window.localStorage.setItem(key, JSON.stringify(json));
});
}
static getCustomList(): string[] {
return TouchController.#customList;
}
static setup() { static setup() {
// Function for testing touch control // Function for testing touch control
window.BX_EXPOSED.test_touch_control = (content: any) => { (window as any).testTouchLayout = (layout: any) => {
const { touch_layout_manager } = window.BX_EXPOSED; const { touchLayoutManager } = window.BX_EXPOSED;
touch_layout_manager && touch_layout_manager.changeLayoutForScope({ touchLayoutManager && touchLayoutManager.changeLayoutForScope({
type: 'showLayout', type: 'showLayout',
scope: '' + STATES.currentStream?.xboxTitleId, scope: '' + STATES.currentStream?.xboxTitleId,
subscope: 'base', subscope: 'base',
layout: { layout: {
id: 'System.Standard', id: 'System.Standard',
displayName: 'Custom', displayName: 'Custom',
layoutFile: { layoutFile: layout,
content: content,
},
}, },
}); });
}; };
const $fragment = document.createDocumentFragment();
const $style = document.createElement('style'); const $style = document.createElement('style');
$fragment.appendChild($style); document.documentElement.appendChild($style);
const $bar = CE('div', {'id': 'bx-touch-controller-bar'});
$fragment.appendChild($bar);
document.documentElement.appendChild($fragment);
// Setup double-tap event
let clickTimeout: number | null;
$bar.addEventListener('mousedown', (e: MouseEvent) => {
clickTimeout && clearTimeout(clickTimeout);
if (clickTimeout) {
// Double-clicked
clickTimeout = null;
TouchController.#toggleVisibility();
return;
}
clickTimeout = window.setTimeout(() => {
clickTimeout = null;
}, 400);
});
TouchController.#$bar = $bar;
TouchController.#$style = $style; TouchController.#$style = $style;
const PREF_STYLE_STANDARD = getPref(PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD); const PREF_STYLE_STANDARD = getPref(PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD);
@ -286,7 +294,6 @@ export class TouchController {
try { try {
if (msg.data.includes('/titleinfo')) { if (msg.data.includes('/titleinfo')) {
const json = JSON.parse(JSON.parse(msg.data).content); const json = JSON.parse(JSON.parse(msg.data).content);
TouchController.#toggleBar(json.focused);
focused = json.focused; focused = json.focused;
if (!json.focused) { if (!json.focused) {
@ -296,7 +303,7 @@ export class TouchController {
STATES.currentStream.xboxTitleId = parseInt(json.titleid, 16).toString(); STATES.currentStream.xboxTitleId = parseInt(json.titleid, 16).toString();
} }
} catch (e) { } catch (e) {
console.log(e); BxLogger.error(LOG_TAG, 'Load custom layout', e);
} }
}); });
}); });

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