Compare commits

...

133 Commits

Author SHA1 Message Date
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
98 changed files with 8869 additions and 10112 deletions

View File

@ -4,13 +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
> - Search first before making a report. - label: I have used the search function for [**open and closed issues**](https://github.com/redphx/better-xcloud/issues?q=is%3Aissue) to see if someone else has already submitted the same bug report.
required: true
- 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:
@ -25,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
@ -69,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
@ -82,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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
.bx-settings-reload-button { .bx-settings-reload-button {
margin-top: 10px; margin-top: 10px;
height: calc(var(--bx-button-height) * 1.5);
} }
.bx-settings-container { .bx-settings-container {

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

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

View File

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

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,7 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
.bx-quick-settings-tabs { .bx-stream-settings-tabs {
position: fixed; position: fixed;
top: 0; top: 0;
right: 420px; right: 420px;
@ -39,7 +39,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;
@ -89,7 +89,7 @@
} }
.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;
@ -113,14 +113,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

@ -20,6 +20,18 @@ body[data-media-type=tv] .bx-stream-refresh-button {
top: calc(var(--gds-focus-borderSize) + 80px) !important; top: calc(var(--gds-focus-borderSize) + 80px) !important;
} }
.bx-stream-home-button {
top: calc(env(safe-area-inset-top, 0px) + 10px + 50px * 2) !important;
}
body[data-media-type=default] .bx-stream-home-button {
left: calc(env(safe-area-inset-left, 0px) + 12px) !important;
}
body[data-media-type=tv] .bx-stream-home-button {
top: calc(var(--gds-focus-borderSize) + 80px * 2) !important;
}
@keyframes bx-anim-taking-screenshot { @keyframes bx-anim-taking-screenshot {
0% { 0% {
border: 0px solid #ffffff80; border: 0px solid #ffffff80;
@ -53,3 +65,18 @@ div[data-testid=media-container] {
align-self: center; align-self: center;
background: #000; background: #000;
} }
#game-stream canvas {
position: absolute;
align-self: center;
margin: auto;
left: 0;
right: 0;
}
#gamepass-dialog-root div[class^=Guide-module__guide] {
.bx-button {
overflow: visible;
margin-bottom: 12px;
}
}

View File

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

After

Width:  |  Height:  |  Size: 821 B

View File

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

Before

Width:  |  Height:  |  Size: 494 B

After

Width:  |  Height:  |  Size: 494 B

View File

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

Before

Width:  |  Height:  |  Size: 386 B

After

Width:  |  Height:  |  Size: 386 B

View File

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

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

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

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

After

Width:  |  Height:  |  Size: 374 B

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

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

After

Width:  |  Height:  |  Size: 427 B

View File

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

After

Width:  |  Height:  |  Size: 355 B

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

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

After

Width:  |  Height:  |  Size: 358 B

View File

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

Before

Width:  |  Height:  |  Size: 551 B

After

Width:  |  Height:  |  Size: 551 B

View File

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

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 411 B

View File

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

After

Width:  |  Height:  |  Size: 619 B

View File

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

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

View File

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

After

Width:  |  Height:  |  Size: 410 B

View File

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

Before

Width:  |  Height:  |  Size: 915 B

After

Width:  |  Height:  |  Size: 915 B

View File

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

Before

Width:  |  Height:  |  Size: 796 B

After

Width:  |  Height:  |  Size: 796 B

View File

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

After

Width:  |  Height:  |  Size: 349 B

View File

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

After

Width:  |  Height:  |  Size: 680 B

View File

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

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

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

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

View File

@ -6,12 +6,12 @@ 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 } from "@utils/css";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { setupStreamUi, updateVideoPlayerCss } from "@modules/ui/ui"; import { setupStreamUi, updateVideoPlayer } from "@modules/ui/ui";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { LoadingScreen } from "@modules/loading-screen"; import { LoadingScreen } from "@modules/loading-screen";
import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider"; import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider";
@ -23,12 +23,14 @@ import { RemotePlay } from "@modules/remote-play";
import { onHistoryChanged, patchHistoryMethod } from "@utils/history"; import { onHistoryChanged, patchHistoryMethod } from "@utils/history";
import { VibrationManager } from "@modules/vibration-manager"; import { VibrationManager } from "@modules/vibration-manager";
import { overridePreloadState } from "@utils/preload-state"; import { overridePreloadState } from "@utils/preload-state";
import { patchAudioContext, patchCanvasContext, patchMeControl, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches"; import { patchAudioContext, patchCanvasContext, patchMeControl, patchPointerLockApi, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches";
import { STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { injectStreamMenuButtons } from "@modules/stream/stream-ui"; import { injectStreamMenuButtons } from "@modules/stream/stream-ui";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { GameBar } from "./modules/game-bar/game-bar"; import { GameBar } from "./modules/game-bar/game-bar";
import { Screenshot } from "./utils/screenshot"; import { Screenshot } from "./utils/screenshot";
import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler";
import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu";
// Handle login page // Handle login page
@ -144,9 +146,6 @@ window.addEventListener(BxEvent.STREAM_STARTING, e => {
}); });
window.addEventListener(BxEvent.STREAM_PLAYING, e => { window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const $video = (e as any).$video as HTMLVideoElement;
STATES.currentStream.$video = $video;
STATES.isPlaying = true; STATES.isPlaying = true;
injectStreamMenuButtons(); injectStreamMenuButtons();
@ -157,54 +156,130 @@ window.addEventListener(BxEvent.STREAM_PLAYING, e => {
gameBar.showBar(); gameBar.showBar();
} }
const $video = (e as any).$video as HTMLVideoElement;
Screenshot.updateCanvasSize($video.videoWidth, $video.videoHeight); Screenshot.updateCanvasSize($video.videoWidth, $video.videoHeight);
updateVideoPlayerCss(); updateVideoPlayer();
}); });
window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => { window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => {
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
}); });
window.addEventListener(BxEvent.STREAM_STOPPED, e => { function unload() {
if (!STATES.isPlaying) { if (!STATES.isPlaying) {
return; return;
} }
// Stop MKB listeners
EmulatedMkbHandler.getInstance().destroy();
NativeMkbHandler.getInstance().destroy();
// Destroy StreamPlayer
STATES.currentStream.streamPlayer?.destroy();
STATES.isPlaying = false; STATES.isPlaying = false;
STATES.currentStream = {}; STATES.currentStream = {};
window.BX_EXPOSED.shouldShowSensorControls = false; window.BX_EXPOSED.shouldShowSensorControls = false;
window.BX_EXPOSED.stopTakRendering = false;
// Stop MKB listeners const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog');
getPref(PrefKey.MKB_ENABLED) && MkbHandler.INSTANCE.destroy(); if ($streamSettingsDialog) {
$streamSettingsDialog.classList.add('bx-gone');
const $quickBar = document.querySelector('.bx-quick-settings-bar');
if ($quickBar) {
$quickBar.classList.add('bx-gone');
} }
STATES.currentStream.audioGainNode = null; StreamStats.getInstance().onStoppedPlaying();
STATES.currentStream.$video = null;
StreamStats.onStoppedPlaying();
MouseCursorHider.stop(); MouseCursorHider.stop();
TouchController.reset(); TouchController.reset();
GameBar.getInstance().disable(); GameBar.getInstance().disable();
}
window.addEventListener(BxEvent.STREAM_STOPPED, unload);
window.addEventListener('pagehide', e => {
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
});
window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => {
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(); patchCanvasContext();
AppInterface && patchPointerLockApi();
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext(); getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext();
getPref(PrefKey.BLOCK_TRACKING) && patchMeControl(); getPref(PrefKey.BLOCK_TRACKING) && patchMeControl();
STATES.hasTouchSupport && TouchController.updateCustomList(); STATES.userAgentHasTouchSupport && TouchController.updateCustomList();
overridePreloadState(); overridePreloadState();
VibrationManager.initialSetup(); VibrationManager.initialSetup();
@ -218,9 +293,10 @@ function main() {
(getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance(); (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance();
BX_FLAGS.PreloadUi && setupStreamUi(); BX_FLAGS.PreloadUi && setupStreamUi();
GuideMenu.observe();
StreamBadges.setupEvents(); StreamBadges.setupEvents();
StreamStats.setupEvents(); StreamStats.setupEvents();
MkbHandler.setupEvents(); EmulatedMkbHandler.setupEvents();
Patcher.init(); Patcher.init();
@ -238,6 +314,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

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

@ -3,14 +3,8 @@ import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle, CE } from "@utils/html"; import { createButton, ButtonStyle, CE } from "@utils/html";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { BaseGameBarAction } from "./action-base"; import { BaseGameBarAction } from "./action-base";
import { MicrophoneShortcut, MicrophoneState } from "../shortcuts/shortcut-microphone";
enum MicrophoneState {
REQUESTED = 'Requested',
ENABLED = 'Enabled',
MUTED = 'Muted',
NOT_ALLOWED = 'NotAllowed',
NOT_FOUND = 'NotFound',
}
export class MicrophoneAction extends BaseGameBarAction { export class MicrophoneAction extends BaseGameBarAction {
$content: HTMLElement; $content: HTMLElement;
@ -22,15 +16,9 @@ export class MicrophoneAction extends BaseGameBarAction {
const onClick = (e: Event) => { const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED); BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
const state = this.$content.getAttribute('data-enabled');
const enableMic = state === 'true' ? false : true;
try { const enabled = MicrophoneShortcut.toggle(false);
window.BX_EXPOSED.streamSession.tryEnableChatAsync(enableMic); this.$content.setAttribute('data-enabled', enabled.toString());
this.$content.setAttribute('data-enabled', enableMic.toString());
} catch (e) {
console.log(e);
}
}; };
const $btnDefault = createButton({ const $btnDefault = createButton({

View File

@ -41,7 +41,7 @@ export class GameBar {
this.actions = [ this.actions = [
new ScreenshotAction(), new ScreenshotAction(),
...(STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []), ...(STATES.userAgentHasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []),
new MicrophoneAction(), new MicrophoneAction(),
]; ];
@ -82,6 +82,18 @@ export class GameBar {
document.documentElement.appendChild($gameBar); document.documentElement.appendChild($gameBar);
this.$gameBar = $gameBar; this.$gameBar = $gameBar;
this.$container = $container; 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() { private beginHideTimeout() {

View File

@ -46,6 +46,10 @@ export class LoadingScreen {
#game-stream div[class*=RocketAnimation-module__container] > svg { #game-stream div[class*=RocketAnimation-module__container] > svg {
display: none; display: none;
} }
#game-stream video[class*=RocketAnimationVideo-module__video] {
display: none;
}
`; `;
$bgStyle.textContent += css; $bgStyle.textContent += css;
} }
@ -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,103 +0,0 @@
import type { GamepadKeyNameType } from "@/types/mkb";
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,
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', '⇓'],
[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 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',
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,5 +1,5 @@
import { MkbPreset } from "./mkb-preset"; import { MkbPreset } from "./mkb-preset";
import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo } from "./definitions"; import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo, WheelCode } from "@enums/mkb";
import { createButton, ButtonStyle, CE } from "@utils/html"; import { createButton, ButtonStyle, CE } from "@utils/html";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
@ -9,38 +9,135 @@ import { LocalDb } from "@utils/local-db";
import { KeyHelper } from "./key-helper"; import { KeyHelper } from "./key-helper";
import type { MkbStoredPreset } from "@/types/mkb"; import type { MkbStoredPreset } from "@/types/mkb";
import { showStreamSettings } from "@modules/stream/stream-ui"; import { showStreamSettings } from "@modules/stream/stream-ui";
import { STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { UserAgent } from "@utils/user-agent"; import { UserAgent } from "@utils/user-agent";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { BxIcon } from "@utils/bx-icon"; import { PointerClient } from "./pointer-client";
import { NativeMkbHandler } from "./native-mkb-handler";
import { MkbHandler, MouseDataProvider } from "./base-mkb-handler";
const LOG_TAG = 'MkbHandler'; 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,
@ -55,16 +152,18 @@ 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;
#escKeyDownTime: number = -1;
#STICK_MAP: {[key in GamepadKey]?: [GamepadKey[], number, number]}; #STICK_MAP: {[key in GamepadKey]?: [GamepadKey[], number, number]};
#LEFT_STICK_X: GamepadKey[] = []; #LEFT_STICK_X: GamepadKey[] = [];
#LEFT_STICK_Y: GamepadKey[] = []; #LEFT_STICK_Y: GamepadKey[] = [];
@ -72,6 +171,8 @@ export class MkbHandler {
#RIGHT_STICK_Y: GamepadKey[] = []; #RIGHT_STICK_Y: GamepadKey[] = [];
constructor() { constructor() {
super();
this.#STICK_MAP = { this.#STICK_MAP = {
[GamepadKey.LS_LEFT]: [this.#LEFT_STICK_X, 0, -1], [GamepadKey.LS_LEFT]: [this.#LEFT_STICK_X, 0, -1],
[GamepadKey.LS_RIGHT]: [this.#LEFT_STICK_X, 0, 1], [GamepadKey.LS_RIGHT]: [this.#LEFT_STICK_X, 0, 1],
@ -85,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;
@ -102,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 {
@ -109,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();
@ -167,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;
}
// 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; return;
} }
if (!this.#isPolling) { if (!this.#isPolling) {
return; return;
} }
}
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[e.code]!; const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[e.code || e.key]!;
if (typeof buttonIndex === 'undefined') { if (typeof buttonIndex === 'undefined') {
return; return;
} }
@ -193,10 +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) {
const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
this.#updateStick(analog, 0, 0);
}
handleMouseClick = (data: MkbMouseClick) => {
let mouseButton;
if (typeof data.mouseButton !== 'undefined') {
mouseButton = data.mouseButton;
} else if (typeof data.pointerButton !== 'undefined') {
mouseButton = PointerToMouseButton[data.pointerButton as keyof typeof PointerToMouseButton];
}
const keyCode = 'Mouse' + mouseButton;
const key = {
code: keyCode,
name: KeyHelper.codeToKeyName(keyCode),
};
if (!key.name) {
return; return;
} }
@ -205,23 +346,64 @@ export class MkbHandler {
return; return;
} }
e.preventDefault(); this.#pressButton(buttonIndex, data.pressed);
this.#pressButton(buttonIndex, isMouseDown);
} }
#onWheelEvent = (e: WheelEvent) => { handleMouseMove = (data: MkbMouseMove) => {
const key = KeyHelper.getKeyFromEvent(e); // TODO: optimize this
if (!key) { const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
if (mouseMapTo === MouseMapTo.OFF) {
// Ignore mouse movements
return; 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]!; const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code]!;
if (typeof buttonIndex === 'undefined') { if (typeof buttonIndex === 'undefined') {
return; return false;
} }
e.preventDefault();
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);
this.#pressButton(buttonIndex, true); this.#pressButton(buttonIndex, true);
@ -231,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 {
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), 10);
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 = !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();
} }
} }
@ -338,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();
showStreamSettings('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);
} }
} }
@ -350,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();
break;
case BxEvent.POINTER_LOCK_EXITED:
this.#onPointerLockExited();
break;
} }
#onStreamMenuHidden = () => {
this.#enabled && this.#waitForPointerLock(true);
} }
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);
window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged);
window.addEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this.#onDialogShown);
if (AppInterface) {
// Android app doesn't support PointerLock API so we need to use a different method
window.addEventListener(BxEvent.POINTER_LOCK_REQUESTED, this);
window.addEventListener(BxEvent.POINTER_LOCK_EXITED, this);
} else {
document.addEventListener('pointerlockchange', this.#onPointerLockChange); document.addEventListener('pointerlockchange', this.#onPointerLockChange);
document.addEventListener('pointerlockerror', this.#onPointerLockError); document.addEventListener('pointerlockerror', this.#onPointerLockError);
}
this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg bx-gone'}, this.#initMessage();
createButton({ this.#$message?.classList.add('bx-gone');
icon: BxIcon.MOUSE_SETTINGS,
style: ButtonStyle.PRIMARY,
onClick: e => {
e.preventDefault();
e.stopPropagation();
showStreamSettings('mkb'); if (AppInterface) {
}, Toast.show(t('press-key-to-toggle-mkb', {key: `<b>F8</b>`}), t('virtual-controller'), {html: true});
}), this.waitForMouseData(false);
CE('div', {}, } else {
CE('p', {}, t('mkb-click-to-activate')), this.waitForMouseData(true);
CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})), }
),
);
this.#$message.addEventListener('click', this.#onActivatePointerLock);
document.documentElement.appendChild(this.#$message);
window.addEventListener(BxEvent.STREAM_MENU_SHOWN, this.#onStreamMenuShown);
window.addEventListener(BxEvent.STREAM_MENU_HIDDEN, this.#onStreamMenuHidden);
this.#waitForPointerLock(true);
} }
destroy = () => { destroy = () => {
@ -411,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);
if (AppInterface) {
window.removeEventListener(BxEvent.POINTER_LOCK_REQUESTED, this);
window.removeEventListener(BxEvent.POINTER_LOCK_EXITED, this);
} else {
document.removeEventListener('pointerlockchange', this.#onPointerLockChange); document.removeEventListener('pointerlockchange', this.#onPointerLockChange);
document.removeEventListener('pointerlockerror', this.#onPointerLockError); 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();
@ -445,13 +640,22 @@ 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();
if (virtualGamepad.connected) {
// Dispatch "gamepaddisconnected" event
this.#resetGamepad();
virtualGamepad.connected = false; virtualGamepad.connected = false;
virtualGamepad.timestamp = performance.now(); virtualGamepad.timestamp = performance.now();
@ -460,24 +664,24 @@ export class MkbHandler {
}); });
window.navigator.getGamepads = this.#nativeGetGamepads; window.navigator.getGamepads = this.#nativeGetGamepads;
}
this.#resetGamepad(); this.waitForMouseData(true);
this.#mouseDataProvider?.stop();
window.removeEventListener('keyup', this.#onKeyboardEvent); // Toast.show(t('virtual-controller'), t('disabled'), {instant: true});
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);
} }
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
if (AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on') {
AppInterface && NativeMkbHandler.getInstance().init();
}
} else if (getPref(PrefKey.MKB_ENABLED) && (AppInterface || !UserAgent.isMobile())) {
BxLogger.info(LOG_TAG, 'Emulate MKB'); BxLogger.info(LOG_TAG, 'Emulate MKB');
MkbHandler.INSTANCE.init(); EmulatedMkbHandler.getInstance().init();
} }
}); });
} }

View File

@ -1,7 +1,7 @@
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { SettingElementType } from "@utils/settings"; import { SettingElementType } from "@utils/settings";
import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "./definitions"; import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "@enums/mkb";
import { MkbHandler } from "./mkb-handler"; import { EmulatedMkbHandler } from "./mkb-handler";
import type { MkbPresetData, MkbConvertedPresetData } from "@/types/mkb"; import type { MkbPresetData, MkbConvertedPresetData } from "@/types/mkb";
import type { PreferenceSettings } from "@/types/preferences"; import type { PreferenceSettings } from "@/types/preferences";
@ -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]: 100,
[MkbPresetKey.MOUSE_STICK_DECAY_MIN]: 10,
}, },
}; };
@ -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,15 @@
import { GamepadKey } from "./definitions";
import { CE, createButton, ButtonStyle } from "@utils/html"; import { CE, createButton, ButtonStyle } from "@utils/html";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { Dialog } from "@modules/dialog"; import { Dialog } from "@modules/dialog";
import { getPref, setPref, PrefKey } from "@utils/preferences"; import { getPref, setPref, PrefKey } from "@utils/preferences";
import { MkbPresetKey, GamepadKeyName } from "./definitions";
import { KeyHelper } from "./key-helper"; import { KeyHelper } from "./key-helper";
import { MkbPreset } from "./mkb-preset"; import { MkbPreset } from "./mkb-preset";
import { MkbHandler } from "./mkb-handler"; import { EmulatedMkbHandler } from "./mkb-handler";
import { LocalDb } from "@utils/local-db"; import { LocalDb } from "@utils/local-db";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { SettingElement } from "@utils/settings"; import { SettingElement } from "@utils/settings";
import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb"; import type { MkbPresetData, MkbStoredPresets } from "@/types/mkb";
import { MkbPresetKey, GamepadKey, GamepadKeyName } from "@enums/mkb";
type MkbRemapperElements = { type MkbRemapperElements = {
@ -258,7 +257,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);
} }
@ -460,7 +459,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),
); );
@ -487,7 +486,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();
}, },
@ -517,7 +516,7 @@ export class MkbRemapper {
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

@ -3,9 +3,17 @@ import { BX_FLAGS } from "@utils/bx-flags";
import { getPref, PrefKey } from "@utils/preferences"; import { getPref, PrefKey } from "@utils/preferences";
import { VibrationManager } from "@modules/vibration-manager"; import { VibrationManager } from "@modules/vibration-manager";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { hashCode } from "@utils/utils"; import { hashCode, renderString } from "@utils/utils";
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
import codeControllerShortcuts from "./patches/controller-shortcuts.js" with { type: "text" };
import 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)[]; type PatchArray = (keyof typeof PATCHES)[];
const ENDING_CHUNKS_PATCH_NAME = 'loadingEndingChunks'; const ENDING_CHUNKS_PATCH_NAME = 'loadingEndingChunks';
@ -92,31 +100,24 @@ 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);
}, },
// Disable achievement toast in Remote Play // Disable achievement toast in Remote Play
@ -155,15 +156,36 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
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) {
@ -193,20 +215,8 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
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;
}, },
@ -220,12 +230,10 @@ if (window.BX_VIBRATION_INTENSITY && window.BX_VIBRATION_INTENSITY < 1) {
// 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;
@ -296,33 +304,44 @@ window.dispatchEvent(new Event("${BxEvent.TOUCH_LAYOUT_MANAGER_READY}"));
return str; return str;
}, },
patchBabylonRendererClass(str: string) {
// ()=>{a.current.render(),h.current=window.requestAnimationFrame(l)
let index = str.indexOf('.current.render(),');
if (index === -1) {
return false;
}
// Move back a character
index -= 1;
// Get variable of the "BabylonRendererClass" object
const rendererVar = str[index];
const newCode = `
if (window.BX_EXPOSED.stopTakRendering) {
try {
document.getElementById('BabylonCanvasContainer-main')?.parentElement.classList.add('bx-offscreen');
${rendererVar}.current.dispose();
} catch (e) {}
window.BX_EXPOSED.stopTakRendering = false;
return;
}
`;
str = str.substring(0, index) + newCode + str.substring(index);
return str;
},
supportLocalCoOp(str: string) { supportLocalCoOp(str: string) {
const text = 'this.gamepadMappingsToSend=[],'; const text = 'this.gamepadMappingsToSend=[],';
if (!str.includes(text)) { if (!str.includes(text)) {
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}\`);
BxLogger.info('supportLocalCoOp', '✅ Successfully patched local co-op support');
} else {
BxLogger.error('supportLocalCoOp', '❌ 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;
@ -396,13 +415,19 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
return false; return false;
} }
let newCode = `
// Expose onShowStreamMenu
window.BX_EXPOSED.showStreamMenu = e.onShowStreamMenu;
// Restore the "..." button // Restore the "..." button
str = str.replace(text, 'e.guideUI = null;' + text); 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;
}, },
@ -413,7 +438,7 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
} }
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;
@ -541,28 +566,74 @@ window.dispatchEvent(new Event('${BxEvent.STREAM_EVENT_TARGET_READY}'))
} }
const newCode = `; const newCode = `;
window.BX_EXPOSED.streamSession = this; ${codeExposeStreamSession}
const orgSetMicrophoneState = this.setMicrophoneState.bind(this);
this.setMicrophoneState = state => {
orgSetMicrophoneState(state);
const evt = new Event('${BxEvent.MICROPHONE_STATE_CHANGED}');
evt.microphoneState = state;
window.dispatchEvent(evt);
};
window.dispatchEvent(new Event('${BxEvent.STREAM_SESSION_READY}'))
true` + text; true` + text;
str = str.replace(text, newCode); str = str.replace(text, newCode);
return str; return str;
}, },
skipFeedbackDialog(str: string) {
const text = '&&this.shouldTransitionToFeedback(';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, '&& false ' + text);
return str;
},
enableNativeMkb(str: string) {
const text = 'e.mouseSupported&&e.keyboardSupported&&e.fullscreenSupported;';
if ((!str.includes(text))) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
patchMouseAndKeyboardEnabled(str: string) {
const text = 'get mouseAndKeyboardEnabled(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
exposeInputSink(str: string) {
const text = 'this.controlChannel=null,this.inputChannel=null';
if (!str.includes(text)) {
return false;
}
const newCode = 'window.BX_EXPOSED.inputSink = this;';
str = str.replace(text, newCode + text);
return str;
},
disableNativeRequestPointerLock(str: string) {
const text = 'async requestPointerLock(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return;');
return str;
}
}; };
let PATCH_ORDERS: PatchArray = [ let PATCH_ORDERS: PatchArray = [
...(getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' ? [
'enableNativeMkb',
'patchMouseAndKeyboardEnabled',
'disableNativeRequestPointerLock',
'exposeInputSink',
] : []),
'disableStreamGate', 'disableStreamGate',
'overrideSettings', 'overrideSettings',
'broadcastPollingMode', 'broadcastPollingMode',
@ -588,7 +659,7 @@ let PATCH_ORDERS: PatchArray = [
'remotePlayKeepAlive', 'remotePlayKeepAlive',
'remotePlayDirectConnectUrl', 'remotePlayDirectConnectUrl',
'remotePlayDisableAchievementToast', 'remotePlayDisableAchievementToast',
STATES.hasTouchSupport && 'patchUpdateInputConfigurationAsync', STATES.userAgentHasTouchSupport && 'patchUpdateInputConfigurationAsync',
] : []), ] : []),
...(BX_FLAGS.EnableXcloudLogging ? [ ...(BX_FLAGS.EnableXcloudLogging ? [
@ -611,15 +682,20 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
// Patch volume control for combined audio+video stream // Patch volume control for combined audio+video stream
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchCombinedAudioVideoMediaStream', getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'patchCombinedAudioVideoMediaStream',
// Skip feedback dialog
getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', ...(STATES.userAgentHasTouchSupport ? [
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls',
STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity', (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer',
getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity',
'patchBabylonRendererClass',
] : []),
BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging', BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging',
getPref(PrefKey.BLOCK_TRACKING) && 'blockGamepadStatsCollector', 'patchPollGamepads',
getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'streamCombineSources', getPref(PrefKey.STREAM_COMBINE_SOURCES) && 'streamCombineSources',

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

@ -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,28 +130,34 @@ 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() {
this.#interval && clearInterval(this.#interval);
this.#interval = null;
}
#secondsToHm(seconds: number) {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor(seconds % 3600 / 60) + 1; const m = Math.floor(seconds % 3600 / 60) + 1;
@ -141,25 +167,32 @@ export class StreamBadges {
} }
// 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)); let 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 +209,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 +222,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

@ -1,11 +1,9 @@
import { PrefKey } from "@utils/preferences" import { PrefKey } from "@utils/preferences"
import { BxEvent } from "@utils/bx-event" import { BxEvent } from "@utils/bx-event"
import { getPref } from "@utils/preferences" import { getPref } from "@utils/preferences"
import { StreamBadges } from "./stream-badges"
import { CE } from "@utils/html" import { CE } from "@utils/html"
import { t } from "@utils/translation" import { t } from "@utils/translation"
import { STATES } from "@utils/global" import { STATES } from "@utils/global"
import { BxLogger } from "@utils/bx-logger"
export enum StreamStat { export enum StreamStat {
PING = 'ping', PING = 'ping',
@ -17,286 +15,259 @@ 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;
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);
} }
static stop(glancing=false) { this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval);
if (glancing && !StreamStats.isGlancing()) { }
stop(glancing=false) {
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 (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) => { 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 => { const stats = await STATES.currentStream.peerConnection.getStats();
let grade = ''; let grade = '';
stats.forEach(stat => {
if (stat.type === 'inbound-rtp' && stat.kind === 'video') { if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
// FPS // FPS
StreamStats.#$fps.textContent = stat.framesPerSecond || 0; this.#$fps!.textContent = stat.framesPerSecond || 0;
// Packets Lost // Packets Lost
const packetsLost = stat.packetsLost; const packetsLost = stat.packetsLost;
const packetsReceived = stat.packetsReceived; const packetsReceived = stat.packetsReceived;
const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2); const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2);
StreamStats.#$pl.textContent = packetsLostPercentage === '0.00' ? packetsLost : `${packetsLost} (${packetsLostPercentage}%)`; this.#$pl!.textContent = packetsLostPercentage === '0.00' ? packetsLost : `${packetsLost} (${packetsLostPercentage}%)`;
// Frames Dropped // Frames dropped
const framesDropped = stat.framesDropped; const framesDropped = stat.framesDropped;
const framesReceived = stat.framesReceived; const framesReceived = stat.framesReceived;
const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2); const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2);
StreamStats.#$fl.textContent = framesDroppedPercentage === '0.00' ? framesDropped : `${framesDropped} (${framesDroppedPercentage}%)`; this.#$fl!.textContent = framesDroppedPercentage === '0.00' ? framesDropped : `${framesDropped} (${framesDroppedPercentage}%)`;
if (StreamStats.#lastStat) { if (!this.#lastVideoStat) {
const lastStat = StreamStats.#lastStat; this.#lastVideoStat = stat;
return;
}
const lastStat = this.#lastVideoStat;
// Bitrate // Bitrate
const timeDiff = stat.timestamp - lastStat.timestamp; const timeDiff = stat.timestamp - lastStat.timestamp;
const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000; const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000;
StreamStats.#$br.textContent = `${bitrate.toFixed(2)} Mbps`; this.#$br!.textContent = `${bitrate.toFixed(2)} Mbps`;
// Decode time // Decode time
const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime; const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime;
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded;
const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000; const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000;
StreamStats.#$dt.textContent = `${currentDecodeTime.toFixed(2)}ms`;
if (isNaN(currentDecodeTime)) {
this.#$dt!.textContent = '??ms';
} else {
this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`;
}
if (PREF_STATS_CONDITIONAL_FORMATTING) { if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : ''; grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : '';
} this.#$dt!.dataset.grade = grade;
StreamStats.#$dt.setAttribute('data-grade', grade);
} }
StreamStats.#lastStat = stat; this.#lastVideoStat = stat;
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') { } else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
// Round Trip Time // Round Trip Time
const roundTripTime = typeof stat.currentRoundTripTime !== 'undefined' ? stat.currentRoundTripTime * 1000 : -1; const roundTripTime = !!stat.currentRoundTripTime ? stat.currentRoundTripTime * 1000 : -1;
StreamStats.#$ping.textContent = roundTripTime === -1 ? '???' : roundTripTime.toString(); this.#$ping!.textContent = roundTripTime === -1 ? '???' : roundTripTime.toString();
if (PREF_STATS_CONDITIONAL_FORMATTING) { if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : ''; grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : '';
this.#$ping!.dataset.grade = grade;
} }
StreamStats.#$ping.setAttribute('data-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) { if (this.#$container) {
return; return;
} }
const STATS = { const stats = {
[StreamStat.PING]: [t('stat-ping'), StreamStats.#$ping = CE('span', {}, '0')], [StreamStat.PING]: [t('stat-ping'), this.#$ping = CE('span', {}, '0')],
[StreamStat.FPS]: [t('stat-fps'), StreamStats.#$fps = CE('span', {}, '0')], [StreamStat.FPS]: [t('stat-fps'), this.#$fps = CE('span', {}, '0')],
[StreamStat.BITRATE]: [t('stat-bitrate'), StreamStats.#$br = CE('span', {}, '0 Mbps')], [StreamStat.BITRATE]: [t('stat-bitrate'), this.#$br = CE('span', {}, '0 Mbps')],
[StreamStat.DECODE_TIME]: [t('stat-decode-time'), StreamStats.#$dt = CE('span', {}, '0ms')], [StreamStat.DECODE_TIME]: [t('stat-decode-time'), this.#$dt = CE('span', {}, '0ms')],
[StreamStat.PACKETS_LOST]: [t('stat-packets-lost'), StreamStats.#$pl = CE('span', {}, '0')], [StreamStat.PACKETS_LOST]: [t('stat-packets-lost'), this.#$pl = CE('span', {}, '0')],
[StreamStat.FRAMES_LOST]: [t('stat-frames-lost'), StreamStats.#$fl = CE('span', {}, '0')], [StreamStat.FRAMES_LOST]: [t('stat-frames-lost'), this.#$fl = CE('span', {}, '0')],
}; };
const $barFragment = document.createDocumentFragment(); const $barFragment = document.createDocumentFragment();
let statKey: keyof typeof STATS let statKey: keyof typeof stats;
for (statKey in STATS) { for (statKey in stats) {
const $div = CE('div', {'class': `bx-stat-${statKey}`, title: STATS[statKey][0]}, CE('label', {}, statKey.toUpperCase()), STATS[statKey][1]); const $div = CE('div', {
'class': `bx-stat-${statKey}`,
title: stats[statKey][0]
},
CE('label', {}, statKey.toUpperCase()),
stats[statKey][1],
);
$barFragment.appendChild($div); $barFragment.appendChild($div);
} }
StreamStats.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment); this.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment);
document.documentElement.appendChild(StreamStats.#$container); this.refreshStyles();
StreamStats.refreshStyles(); document.documentElement.appendChild(this.#$container!);
}
static getServerStats() {
STATES.currentStream.peerConnection && STATES.currentStream.peerConnection.getStats().then(stats => {
const allVideoCodecs: {[index: string]: RTCBasicStat} = {};
let videoCodecId;
const allAudioCodecs: {[index: string]: RTCBasicStat} = {};
let audioCodecId;
const allCandidates: {[index: string]: string} = {};
let candidateId;
stats.forEach((stat: RTCBasicStat) => {
if (stat.type === 'codec') {
const mimeType = stat.mimeType.split('/');
if (mimeType[0] === 'video') {
// Store all video stats
allVideoCodecs[stat.id] = stat;
} else if (mimeType[0] === 'audio') {
// Store all audio stats
allAudioCodecs[stat.id] = stat;
}
} else if (stat.type === 'inbound-rtp' && stat.packetsReceived > 0) {
// Get the codecId of the video/audio track currently being used
if (stat.kind === 'video') {
videoCodecId = stat.codecId;
} else if (stat.kind === 'audio') {
audioCodecId = stat.codecId;
}
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
candidateId = stat.remoteCandidateId;
} else if (stat.type === 'remote-candidate') {
allCandidates[stat.id] = stat.address;
}
});
// Get video codec from codecId
if (videoCodecId) {
const videoStat = allVideoCodecs[videoCodecId];
const video: typeof StreamBadges.video = {
codec: videoStat.mimeType.substring(6),
};
if (video.codec === 'H264') {
const match = /profile-level-id=([0-9a-f]{6})/.exec(videoStat.sdpFmtpLine);
video.profile = match ? match[1] : null;
}
StreamBadges.video = video;
}
// Get audio codec from codecId
if (audioCodecId) {
const audioStat = allAudioCodecs[audioCodecId];
StreamBadges.audio = {
codec: audioStat.mimeType.substring(6),
bitrate: audioStat.clockRate,
}
}
// Get server type
if (candidateId) {
BxLogger.info('candidate', candidateId, allCandidates);
StreamBadges.ipv6 = allCandidates[candidateId].includes(':');
}
if (getPref(PrefKey.STATS_SHOW_WHEN_PLAYING)) {
StreamStats.start();
}
});
} }
static setupEvents() { static setupEvents() {
window.addEventListener(BxEvent.STREAM_LOADING, e => {
StreamStats.getInstance().#render();
});
window.addEventListener(BxEvent.STREAM_PLAYING, e => { window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE); const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE);
const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING); const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING);
StreamStats.getServerStats(); const streamStats = StreamStats.getInstance();
// Setup Stat's Quick Glance mode // Setup Stat's Quick Glance mode
if (PREF_STATS_QUICK_GLANCE) {
StreamStats.quickGlanceSetup(); if (PREF_STATS_SHOW_WHEN_PLAYING) {
streamStats.start();
} else if (PREF_STATS_QUICK_GLANCE) {
streamStats.quickGlanceSetup();
// Show stats bar // Show stats bar
!PREF_STATS_SHOW_WHEN_PLAYING && StreamStats.start(true); !PREF_STATS_SHOW_WHEN_PLAYING && streamStats.start(true);
} }
}); });
} }
static refreshStyles() {
StreamStats.getInstance().refreshStyles();
}
} }

View File

@ -2,7 +2,6 @@ import { STATES } from "@utils/global.ts";
import { createSvgIcon } from "@utils/html.ts"; import { createSvgIcon } from "@utils/html.ts";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { BxEvent } from "@utils/bx-event.ts"; import { BxEvent } from "@utils/bx-event.ts";
import { PrefKey, getPref } from "@utils/preferences.ts";
import { t } from "@utils/translation.ts"; import { t } from "@utils/translation.ts";
import { StreamBadges } from "./stream-badges.ts"; import { StreamBadges } from "./stream-badges.ts";
import { StreamStats } from "./stream-stats.ts"; import { StreamStats } from "./stream-stats.ts";
@ -54,6 +53,28 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t
} }
function cloneCloseButton($$btnOrg: HTMLElement, icon: typeof BxIcon, className: string, onChange: any) {
// Create button from the Close button
const $btn = $$btnOrg.cloneNode(true) as HTMLElement;
// Refresh SVG
const $svg = createSvgIcon(icon);
// Copy classes
$svg.setAttribute('class', $btn.firstElementChild!.getAttribute('class') || '');
$svg.style.fill = 'none';
$btn.classList.add(className);
// Remove icon
$btn.removeChild($btn.firstElementChild!);
// Add icon
$btn.appendChild($svg);
// Add "click" event listener
$btn.addEventListener('click', onChange);
return $btn;
}
export function injectStreamMenuButtons() { export function injectStreamMenuButtons() {
const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); const $screen = document.querySelector('#PageContent section[class*=PureScreens]');
if (!$screen) { if (!$screen) {
@ -66,9 +87,9 @@ export function injectStreamMenuButtons() {
($screen as any).xObserving = true; ($screen as any).xObserving = true;
const $quickBar = document.querySelector('.bx-quick-settings-bar')!; const $settingsDialog = document.querySelector('.bx-stream-settings-dialog')!;
const $parent = $screen.parentElement; const $parent = $screen.parentElement;
const hideQuickBarFunc = (e?: MouseEvent | TouchEvent) => { const hideSettingsFunc = (e?: MouseEvent | TouchEvent) => {
if (e) { if (e) {
const $target = e.target as HTMLElement; const $target = e.target as HTMLElement;
e.stopPropagation(); e.stopPropagation();
@ -76,43 +97,27 @@ export function injectStreamMenuButtons() {
return; return;
} }
if ($target.id === 'MultiTouchSurface') { if ($target.id === 'MultiTouchSurface') {
$target.removeEventListener('touchstart', hideQuickBarFunc); $target.removeEventListener('touchstart', hideSettingsFunc);
} }
} }
// Hide Quick settings bar // Hide Stream settings dialog
$quickBar.classList.add('bx-gone'); $settingsDialog.classList.add('bx-gone');
$parent?.removeEventListener('click', hideQuickBarFunc); $parent?.removeEventListener('click', hideSettingsFunc);
// $parent.removeEventListener('touchstart', hideQuickBarFunc); // $parent.removeEventListener('touchstart', hideSettingsFunc);
} }
let $btnStreamSettings: HTMLElement; let $btnStreamSettings: HTMLElement;
let $btnStreamStats: HTMLElement; let $btnStreamStats: HTMLElement;
const streamStats = StreamStats.getInstance();
const PREF_DISABLE_FEEDBACK_DIALOG = getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG);
const observer = new MutationObserver(mutationList => { const observer = new MutationObserver(mutationList => {
mutationList.forEach(item => { mutationList.forEach(item => {
if (item.type !== 'childList') { if (item.type !== 'childList') {
return; return;
} }
item.removedNodes.forEach($node => {
if (!$node || $node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (!($node as HTMLElement).className || !($node as HTMLElement).className.startsWith) {
return;
}
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;
@ -131,52 +136,36 @@ export function injectStreamMenuButtons() {
return; return;
} }
if (PREF_DISABLE_FEEDBACK_DIALOG && $elm.className.startsWith('PostStreamFeedbackScreen')) {
const $btnClose = $elm.querySelector('button');
$btnClose && $btnClose.click();
return;
}
// Render badges // Render badges
if ($elm.className?.startsWith('StreamMenu-module__container')) { if ($elm.className?.startsWith('StreamMenu-module__container')) {
BxEvent.dispatch(window, BxEvent.STREAM_MENU_SHOWN); const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]') as HTMLElement;
const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]');
if (!$btnCloseHud) { if (!$btnCloseHud) {
return; return;
} }
// Hide Quick bar when closing HUD // Hide Stream Settings dialog when closing HUD
$btnCloseHud && $btnCloseHud.addEventListener('click', e => { $btnCloseHud.addEventListener('click', e => {
$quickBar.classList.add('bx-gone'); $settingsDialog.classList.add('bx-gone');
}); });
// Create Refresh button from the Close button // Create Refresh button from the Close button
const $btnRefresh = $btnCloseHud.cloneNode(true) as HTMLElement; const $btnRefresh = cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => {
// Refresh SVG
const $svgRefresh = createSvgIcon(BxIcon.REFRESH);
// Copy classes
$svgRefresh.setAttribute('class', $btnRefresh.firstElementChild!.getAttribute('class') || '');
$svgRefresh.style.fill = 'none';
$btnRefresh.classList.add('bx-stream-refresh-button');
// Remove icon
$btnRefresh.removeChild($btnRefresh.firstElementChild!);
// Add Refresh icon
$btnRefresh.appendChild($svgRefresh);
// Add "click" event listener
$btnRefresh.addEventListener('click', e => {
confirm(t('confirm-reload-stream')) && window.location.reload(); confirm(t('confirm-reload-stream')) && window.location.reload();
}); });
const $btnHome = cloneCloseButton($btnCloseHud, BxIcon.HOME, 'bx-stream-home-button', () => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
});
// Add to website // Add to website
$btnCloseHud.insertAdjacentElement('afterend', $btnRefresh); $btnCloseHud.insertAdjacentElement('afterend', $btnRefresh);
$btnRefresh.insertAdjacentElement('afterend', $btnHome);
// Render stream badges // Render stream badges
const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]');
$menu?.appendChild(await StreamBadges.render()); $menu?.appendChild(await StreamBadges.getInstance().render());
hideQuickBarFunc(); hideSettingsFunc();
return; return;
} }
@ -210,38 +199,38 @@ export function injectStreamMenuButtons() {
// Create Stream Settings button // Create Stream Settings button
if (!$btnStreamSettings) { if (!$btnStreamSettings) {
$btnStreamSettings = cloneStreamHudButton($orgButton, t('menu-stream-settings'), BxIcon.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'); $settingsDialog.classList.remove('bx-gone');
$parent?.addEventListener('click', hideQuickBarFunc); $parent?.addEventListener('click', hideSettingsFunc);
//$parent.addEventListener('touchstart', hideQuickBarFunc); //$parent.addEventListener('touchstart', hideSettingsFunc);
const $touchSurface = document.getElementById('MultiTouchSurface'); const $touchSurface = document.getElementById('MultiTouchSurface');
$touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideQuickBarFunc); $touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideSettingsFunc);
}); });
} }
// Create Stream Stats button // Create Stream Stats button
if (!$btnStreamStats) { if (!$btnStreamStats) {
$btnStreamStats = cloneStreamHudButton($orgButton, t('menu-stream-stats'), BxIcon.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) {
@ -263,14 +252,14 @@ export function injectStreamMenuButtons() {
export function showStreamSettings(tabId: string) { export function showStreamSettings(tabId: string) {
const $wrapper = document.querySelector('.bx-quick-settings-bar'); const $wrapper = document.querySelector('.bx-stream-settings-dialog');
if (!$wrapper) { if (!$wrapper) {
return; return;
} }
// Select tab // Select tab
if (tabId) { if (tabId) {
const $tab = $wrapper.querySelector(`.bx-quick-settings-tabs svg[data-group=${tabId}]`); const $tab = $wrapper.querySelector(`.bx-stream-settings-tabs svg[data-group=${tabId}]`);
$tab && $tab.dispatchEvent(new Event('click')); $tab && $tab.dispatchEvent(new Event('click'));
} }

View File

@ -2,10 +2,9 @@ import { STATES } from "@utils/global";
import { escapeHtml } from "@utils/html"; import { escapeHtml } from "@utils/html";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS } 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"; import { BxLogger } from "@utils/bx-logger";
const LOG_TAG = 'TouchController'; const LOG_TAG = 'TouchController';

View File

@ -1,11 +1,12 @@
import { STATES, AppInterface, SCRIPT_HOME, SCRIPT_VERSION } from "@utils/global"; import { STATES, AppInterface, SCRIPT_VERSION } from "@utils/global";
import { CE, createButton, ButtonStyle } from "@utils/html"; import { CE, createButton, ButtonStyle } from "@utils/html";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { getPreferredServerRegion } from "@utils/region"; import { getPreferredServerRegion } from "@utils/region";
import { UserAgent, UserAgentProfile } from "@utils/user-agent"; import { UserAgent } from "@utils/user-agent";
import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences"; import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences";
import { t, refreshCurrentLocale } from "@utils/translation"; import { t, Translations } from "@utils/translation";
import { PatcherCache } from "../patcher"; import { PatcherCache } from "../patcher";
import { UserAgentProfile } from "@enums/user-agent";
const SETTINGS_UI = { const SETTINGS_UI = {
'Better xCloud': { 'Better xCloud': {
@ -31,11 +32,11 @@ const SETTINGS_UI = {
PrefKey.BITRATE_VIDEO_MAX, PrefKey.BITRATE_VIDEO_MAX,
PrefKey.AUDIO_ENABLE_VOLUME_CONTROL, PrefKey.AUDIO_ENABLE_VOLUME_CONTROL,
PrefKey.AUDIO_MIC_ON_PLAYING,
PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG, PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG,
PrefKey.SCREENSHOT_APPLY_FILTERS, PrefKey.SCREENSHOT_APPLY_FILTERS,
PrefKey.AUDIO_MIC_ON_PLAYING,
PrefKey.GAME_FORTNITE_FORCE_CONSOLE, PrefKey.GAME_FORTNITE_FORCE_CONSOLE,
PrefKey.STREAM_COMBINE_SOURCES, PrefKey.STREAM_COMBINE_SOURCES,
], ],
@ -55,15 +56,15 @@ const SETTINGS_UI = {
[t('mouse-and-keyboard')]: { [t('mouse-and-keyboard')]: {
items: [ items: [
PrefKey.NATIVE_MKB_DISABLED, PrefKey.NATIVE_MKB_ENABLED,
PrefKey.MKB_ENABLED, PrefKey.MKB_ENABLED,
PrefKey.MKB_HIDE_IDLE_CURSOR, PrefKey.MKB_HIDE_IDLE_CURSOR,
], ],
}, },
[t('touch-controller')]: { [t('touch-controller')]: {
note: !STATES.hasTouchSupport ? '⚠️ ' + t('device-unsupported-touch') : null, note: !STATES.userAgentHasTouchSupport ? '⚠️ ' + t('device-unsupported-touch') : null,
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
items: [ items: [
PrefKey.STREAM_TOUCH_CONTROLLER, PrefKey.STREAM_TOUCH_CONTROLLER,
PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF, PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF,
@ -130,7 +131,7 @@ export function setupSettingsUi() {
CE<HTMLElement>('div', {'class': 'bx-settings-title-wrapper'}, CE<HTMLElement>('div', {'class': 'bx-settings-title-wrapper'},
CE('a', { CE('a', {
'class': 'bx-settings-title', 'class': 'bx-settings-title',
'href': SCRIPT_HOME, 'href': 'https://github.com/redphx/better-xcloud/releases',
'target': '_blank', 'target': '_blank',
}, 'Better xCloud ' + SCRIPT_VERSION), }, 'Better xCloud ' + SCRIPT_VERSION),
createButton({ createButton({
@ -143,14 +144,14 @@ export function setupSettingsUi() {
); );
$updateAvailable = CE('a', { $updateAvailable = CE('a', {
'class': 'bx-settings-update bx-gone', 'class': 'bx-settings-update bx-gone',
'href': 'https://github.com/redphx/better-xcloud/releases', 'href': 'https://github.com/redphx/better-xcloud/releases/latest',
'target': '_blank', 'target': '_blank',
}); });
$wrapper.appendChild($updateAvailable); $wrapper.appendChild($updateAvailable);
// Show new version indicator // Show new version indicator
if (PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) { if (!SCRIPT_VERSION.includes('beta') && PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) {
$updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`; $updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`;
$updateAvailable.classList.remove('bx-gone'); $updateAvailable.classList.remove('bx-gone');
} }
@ -181,7 +182,7 @@ export function setupSettingsUi() {
} }
} }
const onChange = (e: Event) => { const onChange = async (e: Event) => {
// Clear PatcherCache; // Clear PatcherCache;
PatcherCache.clear(); PatcherCache.clear();
@ -193,7 +194,8 @@ export function setupSettingsUi() {
if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) { if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) {
// Update locale // Update locale
refreshCurrentLocale(); Translations.refreshCurrentLocale();
await Translations.updateTranslations();
$btnReload.textContent = t('settings-reloading'); $btnReload.textContent = t('settings-reloading');
$btnReload.click(); $btnReload.click();
@ -374,7 +376,7 @@ export function setupSettingsUi() {
$btnReload = createButton({ $btnReload = createButton({
label: t('settings-reload'), label: t('settings-reload'),
classes: ['bx-settings-reload-button'], classes: ['bx-settings-reload-button'],
style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH, style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH | ButtonStyle.TALL,
onClick: e => { onClick: e => {
window.location.reload(); window.location.reload();
$btnReload.disabled = true; $btnReload.disabled = true;

View File

@ -0,0 +1,80 @@
import { BxEvent } from "@/utils/bx-event";
import { AppInterface, STATES } from "@/utils/global";
import { createButton, ButtonStyle } from "@/utils/html";
import { t } from "@/utils/translation";
export enum GuideMenuTab {
HOME,
}
export class GuideMenu {
static #injectHome($root: HTMLElement) {
// Find the last divider
const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]');
if (!$dividers) {
return;
}
const $lastDivider = $dividers[$dividers.length - 1];
// Add "Close app" button
if (AppInterface) {
const $btnQuit = createButton({
label: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
});
$lastDivider.insertAdjacentElement('afterend', $btnQuit);
}
}
static #injectHomePlaying($root: HTMLElement) {
const $btnQuit = $root.querySelector('a[class*=QuitGameButton]');
if (!$btnQuit) {
return;
}
// Add buttons
const $btnReload = createButton({
label: t('reload-stream'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('confirm-reload-stream')) && window.location.reload();
},
});
const $btnHome = createButton({
label: t('back-to-home'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
},
});
$btnQuit.insertAdjacentElement('afterend', $btnReload);
$btnReload.insertAdjacentElement('afterend', $btnHome);
// Hide xCloud's Home button
const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement;
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
}
static async #onShown(e: Event) {
const where = (e as any).where as GuideMenuTab;
if (where === GuideMenuTab.HOME) {
const $root = document.querySelector('#gamepass-dialog-root div[role=dialog]') as HTMLElement;
if (STATES.isPlaying) {
GuideMenu.#injectHomePlaying($root);
} else {
GuideMenu.#injectHome($root);
}
}
}
static observe() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown);
}
}

View File

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

View File

@ -1,15 +1,20 @@
import { STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html"; import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { UserAgent } from "@utils/user-agent";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { MkbRemapper } from "@modules/mkb/mkb-remapper"; import { MkbRemapper } from "@modules/mkb/mkb-remapper";
import { getPref, PrefKey, toPrefElement } from "@utils/preferences"; import { getPref, Preferences, PrefKey, setPref, toPrefElement } from "@utils/preferences";
import { StreamStats } from "@modules/stream/stream-stats"; import { StreamStats } from "@modules/stream/stream-stats";
import { TouchController } from "@modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { VibrationManager } from "@modules/vibration-manager"; import { VibrationManager } from "@modules/vibration-manager";
import { Screenshot } from "@/utils/screenshot"; import { Screenshot } from "@/utils/screenshot";
import { ControllerShortcut } from "../controller-shortcut";
import { SoundShortcut } from "../shortcuts/shortcut-sound";
import { NativeMkbHandler } from "../mkb/native-mkb-handler";
import { UserAgent } from "@/utils/user-agent";
import type { StreamPlayerOptions } from "../stream-player";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
export function localRedirect(path: string) { export function localRedirect(path: string) {
@ -35,54 +40,8 @@ export function localRedirect(path: string) {
$anchor.click(); $anchor.click();
} }
function setupStreamSettingsDialog() {
function getVideoPlayerFilterStyle() {
const filters = [];
const clarity = getPref(PrefKey.VIDEO_CLARITY);
if (clarity != 0) {
const level = (7 - (clarity - 1) * 0.5).toFixed(1); // 5, 5.5, 6, 6.5, 7
const matrix = `0 -1 0 -1 ${level} -1 0 -1 0`;
document.getElementById('bx-filter-clarity-matrix')!.setAttributeNS(null, 'kernelMatrix', matrix);
filters.push(`url(#bx-filter-clarity)`);
}
const saturation = getPref(PrefKey.VIDEO_SATURATION);
if (saturation != 100) {
filters.push(`saturate(${saturation}%)`);
}
const contrast = getPref(PrefKey.VIDEO_CONTRAST);
if (contrast != 100) {
filters.push(`contrast(${contrast}%)`);
}
const brightness = getPref(PrefKey.VIDEO_BRIGHTNESS);
if (brightness != 100) {
filters.push(`brightness(${brightness}%)`);
}
return filters.join(' ');
}
function setupQuickSettingsBar() {
const isSafari = UserAgent.isSafari();
const SETTINGS_UI = [ const SETTINGS_UI = [
getPref(PrefKey.MKB_ENABLED) && {
icon: BxIcon.MOUSE,
group: 'mkb',
items: [
{
group: 'mkb',
label: t('mouse-and-keyboard'),
help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/',
content: MkbRemapper.INSTANCE.render(),
},
],
},
{ {
icon: BxIcon.DISPLAY, icon: BxIcon.DISPLAY,
group: 'stream', group: 'stream',
@ -94,13 +53,21 @@ function setupQuickSettingsBar() {
items: [ items: [
{ {
pref: PrefKey.AUDIO_VOLUME, pref: PrefKey.AUDIO_VOLUME,
label: t('volume'),
onChange: (e: any, value: number) => { onChange: (e: any, value: number) => {
STATES.currentStream.audioGainNode && (STATES.currentStream.audioGainNode.gain.value = value / 100); SoundShortcut.setGainNodeVolume(value);
}, },
params: { params: {
disabled: !getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL), 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,
});
});
},
}, },
], ],
}, },
@ -111,34 +78,38 @@ function setupQuickSettingsBar() {
help_url: 'https://better-xcloud.github.io/ingame-features/#video', help_url: 'https://better-xcloud.github.io/ingame-features/#video',
items: [ items: [
{ {
pref: PrefKey.VIDEO_RATIO, pref: PrefKey.VIDEO_PLAYER_TYPE,
label: t('ratio'), onChange: onChangeVideoPlayerType,
onChange: updateVideoPlayerCss,
}, },
{ {
pref: PrefKey.VIDEO_CLARITY, pref: PrefKey.VIDEO_RATIO,
label: t('clarity'), onChange: updateVideoPlayer,
onChange: updateVideoPlayerCss, },
unsupported: isSafari,
{
pref: PrefKey.VIDEO_PROCESSING,
onChange: updateVideoPlayer,
},
{
pref: PrefKey.VIDEO_SHARPNESS,
onChange: updateVideoPlayer,
}, },
{ {
pref: PrefKey.VIDEO_SATURATION, pref: PrefKey.VIDEO_SATURATION,
label: t('saturation'), onChange: updateVideoPlayer,
onChange: updateVideoPlayerCss,
}, },
{ {
pref: PrefKey.VIDEO_CONTRAST, pref: PrefKey.VIDEO_CONTRAST,
label: t('contrast'), onChange: updateVideoPlayer,
onChange: updateVideoPlayerCss,
}, },
{ {
pref: PrefKey.VIDEO_BRIGHTNESS, pref: PrefKey.VIDEO_BRIGHTNESS,
label: t('brightness'), onChange: updateVideoPlayer,
onChange: updateVideoPlayerCss,
}, },
], ],
}, },
@ -156,28 +127,25 @@ function setupQuickSettingsBar() {
items: [ items: [
{ {
pref: PrefKey.CONTROLLER_ENABLE_VIBRATION, pref: PrefKey.CONTROLLER_ENABLE_VIBRATION,
label: t('controller-vibration'),
unsupported: !VibrationManager.supportControllerVibration(), unsupported: !VibrationManager.supportControllerVibration(),
onChange: VibrationManager.updateGlobalVars, onChange: VibrationManager.updateGlobalVars,
}, },
{ {
pref: PrefKey.CONTROLLER_DEVICE_VIBRATION, pref: PrefKey.CONTROLLER_DEVICE_VIBRATION,
label: t('device-vibration'),
unsupported: !VibrationManager.supportDeviceVibration(), unsupported: !VibrationManager.supportDeviceVibration(),
onChange: VibrationManager.updateGlobalVars, onChange: VibrationManager.updateGlobalVars,
}, },
(VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && { (VibrationManager.supportControllerVibration() || VibrationManager.supportDeviceVibration()) && {
pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY, pref: PrefKey.CONTROLLER_VIBRATION_INTENSITY,
label: t('vibration-intensity'),
unsupported: !VibrationManager.supportDeviceVibration(), unsupported: !VibrationManager.supportDeviceVibration(),
onChange: VibrationManager.updateGlobalVars, onChange: VibrationManager.updateGlobalVars,
}, },
], ],
}, },
STATES.hasTouchSupport && { STATES.userAgentHasTouchSupport && {
group: 'touch-controller', group: 'touch-controller',
label: t('touch-controller'), label: t('touch-controller'),
items: [ items: [
@ -239,54 +207,97 @@ function setupQuickSettingsBar() {
], ],
}, },
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, icon: BxIcon.STREAM_STATS,
group: 'stats', group: 'stats',
items: [ items: [
{ {
group: 'stats', group: 'stats',
label: t('menu-stream-stats'), label: t('stream-stats'),
help_url: 'https://better-xcloud.github.io/stream-stats/', help_url: 'https://better-xcloud.github.io/stream-stats/',
items: [ items: [
{ {
pref: PrefKey.STATS_SHOW_WHEN_PLAYING, pref: PrefKey.STATS_SHOW_WHEN_PLAYING,
label: t('show-stats-on-startup'),
}, },
{ {
pref: PrefKey.STATS_QUICK_GLANCE, pref: PrefKey.STATS_QUICK_GLANCE,
label: '👀 ' + t('enable-quick-glance-mode'),
onChange: (e: InputEvent) => { onChange: (e: InputEvent) => {
(e.target! as HTMLInputElement).checked ? StreamStats.quickGlanceSetup() : StreamStats.quickGlanceStop(); const streamStats = StreamStats.getInstance();
(e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop();
}, },
}, },
{ {
pref: PrefKey.STATS_ITEMS, pref: PrefKey.STATS_ITEMS,
label: t('stats'),
onChange: StreamStats.refreshStyles, onChange: StreamStats.refreshStyles,
}, },
{ {
pref: PrefKey.STATS_POSITION, pref: PrefKey.STATS_POSITION,
label: t('position'),
onChange: StreamStats.refreshStyles, onChange: StreamStats.refreshStyles,
}, },
{ {
pref: PrefKey.STATS_TEXT_SIZE, pref: PrefKey.STATS_TEXT_SIZE,
label: t('text-size'),
onChange: StreamStats.refreshStyles, onChange: StreamStats.refreshStyles,
}, },
{ {
pref: PrefKey.STATS_OPACITY, pref: PrefKey.STATS_OPACITY,
label: t('opacity'),
onChange: StreamStats.refreshStyles, onChange: StreamStats.refreshStyles,
}, },
{ {
pref: PrefKey.STATS_TRANSPARENT, pref: PrefKey.STATS_TRANSPARENT,
label: t('transparent-background'),
onChange: StreamStats.refreshStyles, onChange: StreamStats.refreshStyles,
}, },
{ {
pref: PrefKey.STATS_CONDITIONAL_FORMATTING, pref: PrefKey.STATS_CONDITIONAL_FORMATTING,
label: t('conditional-formatting'),
onChange: StreamStats.refreshStyles, onChange: StreamStats.refreshStyles,
}, },
], ],
@ -298,9 +309,9 @@ function setupQuickSettingsBar() {
let $tabs: HTMLElement; let $tabs: HTMLElement;
let $settings: HTMLElement; let $settings: HTMLElement;
const $wrapper = CE<HTMLElement>('div', {'class': 'bx-quick-settings-bar bx-gone'}, const $wrapper = CE<HTMLElement>('div', {'class': 'bx-stream-settings-dialog bx-gone'},
$tabs = CE<HTMLElement>('div', {'class': 'bx-quick-settings-tabs'}), $tabs = CE<HTMLElement>('div', {'class': 'bx-stream-settings-tabs'}),
$settings = CE<HTMLElement>('div', {'class': 'bx-quick-settings-tab-contents'}), $settings = CE<HTMLElement>('div', {'class': 'bx-stream-settings-tab-contents'}),
); );
for (const settingTab of SETTINGS_UI) { for (const settingTab of SETTINGS_UI) {
@ -375,10 +386,14 @@ function setupQuickSettingsBar() {
$control = toPrefElement(pref, setting.onChange, setting.params); $control = toPrefElement(pref, setting.onChange, setting.params);
} }
const $content = CE<HTMLElement>('div', {'class': 'bx-quick-settings-row', 'data-type': settingGroup.group}, 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}`}, CE('label', {for: `bx_setting_${pref}`},
setting.label, label,
setting.unsupported && CE<HTMLElement>('div', {'class': 'bx-quick-settings-bar-note'}, t('browser-unsupported-feature')), 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, !setting.unsupported && $control,
); );
@ -399,106 +414,77 @@ function setupQuickSettingsBar() {
} }
export function updateVideoPlayerCss() { function onChangeVideoPlayerType() {
let $elm = document.getElementById('bx-video-css'); const playerType = getPref(PrefKey.VIDEO_PLAYER_TYPE);
if (!$elm) { const $videoProcessing = document.getElementById('bx_setting_video_processing') as HTMLSelectElement;
const $fragment = document.createDocumentFragment(); const $videoSharpness = document.getElementById('bx_setting_video_sharpness') as HTMLElement;
$elm = CE<HTMLStyleElement>('style', {id: 'bx-video-css'});
$fragment.appendChild($elm);
// Setup SVG filters let isDisabled = false;
const $svg = CE('svg', {
'id': 'bx-video-filters', if (playerType === StreamPlayerType.WEBGL2) {
'xmlns': 'http://www.w3.org/2000/svg', ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = false;
'class': 'bx-gone', } else {
}, CE('defs', {'xmlns': 'http://www.w3.org/2000/svg'}, // Only allow USM when player type is Video
CE('filter', {'id': 'bx-filter-clarity', 'xmlns': 'http://www.w3.org/2000/svg'}, $videoProcessing.value = StreamVideoProcessing.USM;
CE('feConvolveMatrix', {'id': 'bx-filter-clarity-matrix', 'order': '3', 'xmlns': 'http://www.w3.org/2000/svg'})) setPref(PrefKey.VIDEO_PROCESSING, StreamVideoProcessing.USM);
)
); ($videoProcessing.querySelector(`option[value=${StreamVideoProcessing.CAS}]`) as HTMLOptionElement).disabled = true;
$fragment.appendChild($svg);
document.documentElement.appendChild($fragment); if (UserAgent.isSafari()) {
isDisabled = true;
}
} }
let filters = getVideoPlayerFilterStyle(); $videoProcessing.disabled = isDisabled;
let videoCss = ''; $videoSharpness.dataset.disabled = isDisabled.toString();
if (filters) {
videoCss += `filter: ${filters} !important;`; updateVideoPlayer();
} }
// Apply video filters to screenshots
if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
Screenshot.updateCanvasFilters(filters);
}
let css = ''; export function updateVideoPlayer() {
if (videoCss) { const streamPlayer = STATES.currentStream.streamPlayer;
css = ` if (!streamPlayer) {
#game-stream video {
${videoCss}
}
`;
}
$elm.textContent = css;
resizeVideoPlayer();
}
function resizeVideoPlayer() {
const $video = STATES.currentStream.$video;
if (!$video || !$video.parentElement) {
return; return;
} }
const PREF_RATIO = getPref(PrefKey.VIDEO_RATIO); const options = {
if (PREF_RATIO.includes(':')) { processing: getPref(PrefKey.VIDEO_PROCESSING),
const tmp = PREF_RATIO.split(':'); sharpness: getPref(PrefKey.VIDEO_SHARPNESS),
saturation: getPref(PrefKey.VIDEO_SATURATION),
contrast: getPref(PrefKey.VIDEO_CONTRAST),
brightness: getPref(PrefKey.VIDEO_BRIGHTNESS),
} satisfies StreamPlayerOptions;
// Get preferred ratio streamPlayer.setPlayerType(getPref(PrefKey.VIDEO_PLAYER_TYPE));
const videoRatio = parseFloat(tmp[0]) / parseFloat(tmp[1]); streamPlayer.updateOptions(options);
streamPlayer.refreshPlayer();
let width = 0;
let height = 0;
// Get parent's ratio
const parentRect = $video.parentElement.getBoundingClientRect();
const parentRatio = parentRect.width / parentRect.height;
// Get target width & height
if (parentRatio > videoRatio) {
height = parentRect.height;
width = height * videoRatio;
} else {
width = parentRect.width;
height = width / videoRatio;
} }
// Prevent floating points
width = Math.floor(width);
height = Math.floor(height);
// Update size function preloadFonts() {
$video.style.width = `${width}px`; const $link = CE<HTMLLinkElement>('link', {
$video.style.height = `${height}px`; rel: 'preload',
$video.style.objectFit = 'fill'; href: 'https://redphx.github.io/better-xcloud/fonts/promptfont.otf',
} else { as: 'font',
$video.style.width = '100%'; type: 'font/otf',
$video.style.height = '100%'; crossorigin: '',
$video.style.objectFit = PREF_RATIO; });
}
document.querySelector('head')?.appendChild($link);
} }
export function setupStreamUi() { export function setupStreamUi() {
// Prevent initializing multiple times // Prevent initializing multiple times
if (!document.querySelector('.bx-quick-settings-bar')) { if (!document.querySelector('.bx-stream-settings-dialog')) {
window.addEventListener('resize', updateVideoPlayerCss); preloadFonts();
setupQuickSettingsBar();
StreamStats.render(); window.addEventListener('resize', updateVideoPlayer);
setupStreamSettingsDialog();
Screenshot.setup(); Screenshot.setup();
} }
updateVideoPlayerCss(); onChangeVideoPlayerType();
} }

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

@ -28,7 +28,7 @@ type BxStates = {
appContext: any | null; appContext: any | null;
serverRegions: any; serverRegions: any;
hasTouchSupport: boolean; userAgentHasTouchSupport: boolean;
browserHasTouchSupport: boolean; browserHasTouchSupport: boolean;
currentStream: Partial<{ currentStream: Partial<{
@ -37,9 +37,7 @@ type BxStates = {
productId: string; productId: string;
titleInfo: XcloudTitleInfo; titleInfo: XcloudTitleInfo;
$video: HTMLVideoElement | null; streamPlayer: StreamPlayer | null;
$screenshotCanvas: HTMLCanvasElement | null;
screenshotCanvasContext: CanvasRenderingContext2D | null;
peerConnection: RTCPeerConnection; peerConnection: RTCPeerConnection;
audioContext: AudioContext | null; audioContext: AudioContext | null;
@ -53,6 +51,8 @@ type BxStates = {
serverId: string; serverId: string;
}; };
}>; }>;
pointerServerPort: number;
} }
type DualEnum = {[index: string]: number} & {[index: number]: string}; type DualEnum = {[index: string]: number} & {[index: number]: string};
@ -61,6 +61,8 @@ type XcloudTitleInfo = {
details: { details: {
productId: string; productId: string;
supportedInputTypes: InputType[]; supportedInputTypes: InputType[];
supportedTabs: any[];
hasNativeTouchSupport: boolean;
hasTouchSupport: boolean; hasTouchSupport: boolean;
hasFakeTouchSupport: boolean; hasFakeTouchSupport: boolean;
hasMkbSupport: boolean; hasMkbSupport: boolean;
@ -73,5 +75,25 @@ type XcloudTitleInfo = {
}; };
}; };
declare module "*.svg"; declare module '*.js';
declare module "*.styl"; declare module '*.svg';
declare module '*.styl';
declare module '*.fs';
declare module '*.vert';
type MkbMouseMove = {
movementX: number;
movementY: number;
}
type MkbMouseClick = {
pointerButton?: number,
mouseButton?: number,
pressed: boolean,
}
type MkbMouseWheel = {
vertical: number;
horizontal: number;
}

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

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

View File

@ -6,7 +6,7 @@ export type PreferenceSetting = {
note?: string | HTMLElement; note?: string | HTMLElement;
type?: SettingElementType; type?: SettingElementType;
ready?: (setting: PreferenceSetting) => void; ready?: (setting: PreferenceSetting) => void;
migrate?: (savedPrefs: any, value: any) => {}; migrate?: (this: Preferences, savedPrefs: any, value: any) => void;
min?: number; min?: number;
max?: number; max?: number;
steps?: number; steps?: number;

View File

@ -13,9 +13,6 @@ export enum BxEvent {
STREAM_STOPPED = 'bx-stream-stopped', STREAM_STOPPED = 'bx-stream-stopped',
STREAM_ERROR_PAGE = 'bx-stream-error-page', STREAM_ERROR_PAGE = 'bx-stream-error-page',
STREAM_MENU_SHOWN = 'bx-stream-menu-shown',
STREAM_MENU_HIDDEN = 'bx-stream-menu-hidden',
STREAM_WEBRTC_CONNECTED = 'bx-stream-webrtc-connected', STREAM_WEBRTC_CONNECTED = 'bx-stream-webrtc-connected',
STREAM_WEBRTC_DISCONNECTED = 'bx-stream-webrtc-disconnected', STREAM_WEBRTC_DISCONNECTED = 'bx-stream-webrtc-disconnected',
@ -34,6 +31,20 @@ export enum BxEvent {
GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated', GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated',
MICROPHONE_STATE_CHANGED = 'bx-microphone-state-changed', MICROPHONE_STATE_CHANGED = 'bx-microphone-state-changed',
CAPTURE_SCREENSHOT = 'bx-capture-screenshot',
GAINNODE_VOLUME_CHANGED = 'bx-gainnode-volume-changed',
POINTER_LOCK_REQUESTED = 'bx-pointer-lock-requested',
POINTER_LOCK_EXITED = 'bx-pointer-lock-exited',
// xCloud Dialog events
XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown',
XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed',
XCLOUD_GUIDE_MENU_SHOWN = 'bx-xcloud-guide-menu-shown',
XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed',
} }
export enum XcloudEvent { export enum XcloudEvent {
@ -55,7 +66,9 @@ export namespace BxEvent {
} }
} }
AppInterface && AppInterface.onEvent(eventName);
target.dispatchEvent(event); target.dispatchEvent(event);
AppInterface && AppInterface.onEvent(eventName);
} }
} }
(window as any).BxEvent = BxEvent;

View File

@ -1,8 +1,9 @@
import { GameBar } from "@modules/game-bar/game-bar"; import { ControllerShortcut } from "@/modules/controller-shortcut";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { getPref, PrefKey } from "@utils/preferences"; import { getPref, PrefKey } from "@utils/preferences";
import { UserAgent } from "@utils/user-agent"; import { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags";
export enum InputType { export enum InputType {
CONTROLLER = 'Controller', CONTROLLER = 'Controller',
@ -14,23 +15,6 @@ export enum InputType {
} }
export const BxExposed = { export const BxExposed = {
// Enable/disable Game Bar when playing/pausing
onPollingModeChanged: (mode: 'All' | 'None') => {
if (getPref(PrefKey.GAME_BAR_POSITION) === 'off') {
return;
}
const gameBar = GameBar.getInstance();
if (!STATES.isPlaying) {
gameBar.disable();
return;
}
// Toggle Game bar
mode !== 'None' ? gameBar.disable() : gameBar.enable();
},
getTitleInfo: () => STATES.currentStream.titleInfo, getTitleInfo: () => STATES.currentStream.titleInfo,
modifyTitleInfo: (titleInfo: XcloudTitleInfo): XcloudTitleInfo => { modifyTitleInfo: (titleInfo: XcloudTitleInfo): XcloudTitleInfo => {
@ -39,14 +23,18 @@ export const BxExposed = {
let supportedInputTypes = titleInfo.details.supportedInputTypes; let supportedInputTypes = titleInfo.details.supportedInputTypes;
if (BX_FLAGS.ForceNativeMkbTitles?.includes(titleInfo.details.productId)) {
supportedInputTypes.push(InputType.MKB);
}
// Remove native MKB support on mobile browsers or by user's choice // Remove native MKB support on mobile browsers or by user's choice
if (getPref(PrefKey.NATIVE_MKB_DISABLED) || UserAgent.isMobile()) { if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.MKB); supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.MKB);
} }
titleInfo.details.hasMkbSupport = supportedInputTypes.includes(InputType.MKB); titleInfo.details.hasMkbSupport = supportedInputTypes.includes(InputType.MKB);
if (STATES.hasTouchSupport) { if (STATES.userAgentHasTouchSupport) {
let touchControllerAvailability = getPref(PrefKey.STREAM_TOUCH_CONTROLLER); let touchControllerAvailability = getPref(PrefKey.STREAM_TOUCH_CONTROLLER);
// Disable touch control when gamepad found // Disable touch control when gamepad found
@ -64,15 +52,16 @@ export const BxExposed = {
gamepadFound && (touchControllerAvailability = 'off'); gamepadFound && (touchControllerAvailability = 'off');
} }
if (touchControllerAvailability === 'off') { if (touchControllerAvailability === 'off') {
// Disable touch on all games (not native touch) // Disable touch on all games (not native touch)
supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.CUSTOM_TOUCH_OVERLAY && i !== InputType.GENERIC_TOUCH); supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.CUSTOM_TOUCH_OVERLAY && i !== InputType.GENERIC_TOUCH);
// Empty TABs
titleInfo.details.supportedTabs = [];
} }
// Pre-check supported input types // Pre-check supported input types
titleInfo.details.hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) || titleInfo.details.hasNativeTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH);
titleInfo.details.hasTouchSupport = titleInfo.details.hasNativeTouchSupport ||
supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) || supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) ||
supportedInputTypes.includes(InputType.GENERIC_TOUCH); supportedInputTypes.includes(InputType.GENERIC_TOUCH);
@ -106,10 +95,18 @@ export const BxExposed = {
}); });
} }
try {
const audioCtx = STATES.currentStream.audioContext!; const audioCtx = STATES.currentStream.audioContext!;
const source = audioCtx.createMediaStreamSource(audioStream); const source = audioCtx.createMediaStreamSource(audioStream);
const gainNode = audioCtx.createGain(); // call monkey-patched createGain() in BxAudioContext const gainNode = audioCtx.createGain(); // call monkey-patched createGain() in BxAudioContext
source.connect(gainNode).connect(audioCtx.destination); source.connect(gainNode).connect(audioCtx.destination);
} catch (e) {
BxLogger.error('setupGainNode', e);
STATES.currentStream.audioGainNode = null;
} }
},
handleControllerShortcut: ControllerShortcut.handle,
resetControllerShortcut: ControllerShortcut.reset,
}; };

View File

@ -1,12 +1,15 @@
type BxFlags = { type BxFlags = Partial<{
CheckForUpdate?: boolean; CheckForUpdate: boolean;
PreloadRemotePlay?: boolean; PreloadRemotePlay: boolean;
PreloadUi?: boolean; PreloadUi: boolean;
EnableXcloudLogging?: boolean; EnableXcloudLogging: boolean;
SafariWorkaround?: boolean; SafariWorkaround: boolean;
UseDevTouchLayout?: boolean; UseDevTouchLayout: boolean;
}
ForceNativeMkbTitles: string[];
FeatureGates: {[key: string]: boolean} | null,
}>
// Setup flags // Setup flags
const DEFAULT_FLAGS: BxFlags = { const DEFAULT_FLAGS: BxFlags = {
@ -17,9 +20,14 @@ const DEFAULT_FLAGS: BxFlags = {
SafariWorkaround: true, SafariWorkaround: true,
UseDevTouchLayout: false, UseDevTouchLayout: false,
ForceNativeMkbTitles: [],
FeatureGates: null,
} }
export const BX_FLAGS = Object.assign(DEFAULT_FLAGS, window.BX_FLAGS || {}); export const BX_FLAGS: BxFlags = Object.assign(DEFAULT_FLAGS, window.BX_FLAGS || {});
try { try {
delete window.BX_FLAGS; delete window.BX_FLAGS;
} catch (e) {} } catch (e) {}
export const NATIVE_FETCH = window.fetch;

View File

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

View File

@ -6,11 +6,14 @@ import { renderStylus } from "@macros/build" with {type: "macro"};
export function addCss() { export function addCss() {
let css = renderStylus(); let css = renderStylus();
// Hide "Play with friends" section
if (getPref(PrefKey.BLOCK_SOCIAL_FEATURES)) { if (getPref(PrefKey.BLOCK_SOCIAL_FEATURES)) {
css += ` css += `
/* Hide "Play with friends" section */
div[class^=HomePage-module__bottomSpacing]:has(button[class*=SocialEmptyCard]), div[class^=HomePage-module__bottomSpacing]:has(button[class*=SocialEmptyCard]),
button[class*=SocialEmptyCard] { button[class*=SocialEmptyCard],
/* Hide "Start a party" button in the Guide menu */
#gamepass-dialog-root div[class^=AchievementsPreview-module__container] + button[class*=HomeLandingPage-module__button]
{
display: none; display: none;
} }
`; `;
@ -54,12 +57,13 @@ div[class*=StreamHUD-module__buttonsContainer] {
`; `;
} }
// Simplify Stream's menu
css += ` css += `
div[class*=StreamMenu-module__menu] { div[class*=StreamMenu-module__menu] {
min-width: 100vw !important; min-width: 100vw !important;
} }
`; `;
// Simplify Stream's menu
if (getPref(PrefKey.STREAM_SIMPLIFY_MENU)) { if (getPref(PrefKey.STREAM_SIMPLIFY_MENU)) {
css += ` css += `
div[class*=Menu-module__scrollable] { div[class*=Menu-module__scrollable] {

View File

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

View File

@ -1,4 +1,4 @@
import { MkbHandler } from "@modules/mkb/mkb-handler"; import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { Toast } from "@utils/toast"; import { Toast } from "@utils/toast";
@ -7,7 +7,7 @@ import { BxLogger } from "@utils/bx-logger";
// Show a toast when connecting/disconecting controller // Show a toast when connecting/disconecting controller
export function showGamepadToast(gamepad: Gamepad) { export function showGamepadToast(gamepad: Gamepad) {
// Don't show Toast for virtual controller // Don't show Toast for virtual controller
if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) { if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
return; return;
} }

View File

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

View File

@ -1,7 +1,7 @@
import type { BxIcon } from "@utils/bx-icon"; import type { BxIcon } from "@utils/bx-icon";
type BxButton = { type BxButton = {
style?: number | string; style?: number | string | ButtonStyle;
url?: string; url?: string;
classes?: string[]; classes?: string[];
icon?: typeof BxIcon; icon?: typeof BxIcon;
@ -67,6 +67,7 @@ ButtonStyle[ButtonStyle.GHOST = 4] = 'bx-ghost';
ButtonStyle[ButtonStyle.FOCUSABLE = 8] = 'bx-focusable'; ButtonStyle[ButtonStyle.FOCUSABLE = 8] = 'bx-focusable';
ButtonStyle[ButtonStyle.FULL_WIDTH = 16] = 'bx-full-width'; ButtonStyle[ButtonStyle.FULL_WIDTH = 16] = 'bx-full-width';
ButtonStyle[ButtonStyle.FULL_HEIGHT = 32] = 'bx-full-height'; ButtonStyle[ButtonStyle.FULL_HEIGHT = 32] = 'bx-full-height';
ButtonStyle[ButtonStyle.TALL = 64] = 'bx-tall';
const ButtonStyleIndices = Object.keys(ButtonStyle).splice(0, Object.keys(ButtonStyle).length / 2).map(i => parseInt(i)); const ButtonStyleIndices = Object.keys(ButtonStyle).splice(0, Object.keys(ButtonStyle).length / 2).map(i => parseInt(i));

View File

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

View File

@ -1,5 +1,5 @@
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 { LoadingScreen } from "@modules/loading-screen"; import { LoadingScreen } from "@modules/loading-screen";
import { PrefKey, getPref } from "@utils/preferences"; import { PrefKey, getPref } from "@utils/preferences";
import { RemotePlay } from "@modules/remote-play"; import { RemotePlay } from "@modules/remote-play";
@ -7,11 +7,9 @@ import { StreamBadges } from "@modules/stream/stream-badges";
import { TouchController } from "@modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { getPreferredServerRegion } from "@utils/region"; import { getPreferredServerRegion } from "@utils/region";
import { GamePassCloudGallery } from "./gamepass-gallery"; import { GamePassCloudGallery } from "../enums/game-pass-gallery";
import { InputType } from "./bx-exposed"; import { InputType } from "./bx-exposed";
import { UserAgent } from "./user-agent"; import { FeatureGates } from "./feature-gates";
export const NATIVE_FETCH = window.fetch;
enum RequestType { enum RequestType {
XCLOUD = 'xcloud', XCLOUD = 'xcloud',
@ -368,14 +366,15 @@ class XcloudInterceptor {
const url = (typeof request === 'string') ? request : (request as Request).url; const url = (typeof request === 'string') ? request : (request as Request).url;
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
StreamBadges.region = parsedUrl.host.split('.', 1)[0]; let badgeRegion: string = parsedUrl.host.split('.', 1)[0];
for (let regionName in STATES.serverRegions) { for (let regionName in STATES.serverRegions) {
const region = STATES.serverRegions[regionName]; const region = STATES.serverRegions[regionName];
if (parsedUrl.origin == region.baseUri) { if (parsedUrl.origin == region.baseUri) {
StreamBadges.region = regionName; badgeRegion = regionName;
break; break;
} }
} }
StreamBadges.getInstance().setRegion(badgeRegion);
const clone = (request as Request).clone(); const clone = (request as Request).clone();
const body = await clone.json(); const body = await clone.json();
@ -440,11 +439,20 @@ class XcloudInterceptor {
overrides.inputConfiguration = overrides.inputConfiguration || {}; overrides.inputConfiguration = overrides.inputConfiguration || {};
overrides.inputConfiguration.enableVibration = true; overrides.inputConfiguration.enableVibration = true;
if (getPref(PrefKey.NATIVE_MKB_DISABLED) || UserAgent.isMobile()) { let overrideMkb: boolean | null = null;
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || BX_FLAGS.ForceNativeMkbTitles?.includes(STATES.currentStream.titleInfo!.details.productId)) {
overrideMkb = true;
}
if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') {
overrideMkb = false;
}
if (overrideMkb !== null) {
overrides.inputConfiguration = Object.assign(overrides.inputConfiguration, { overrides.inputConfiguration = Object.assign(overrides.inputConfiguration, {
enableMouseInput: false, enableMouseInput: overrideMkb,
enableAbsoluteMouse: false, enableKeyboardInput: overrideMkb,
enableKeyboardInput: false,
}); });
} }
@ -571,14 +579,8 @@ export function interceptHttpRequests() {
const response = await NATIVE_FETCH(request, init); const response = await NATIVE_FETCH(request, init);
const json = await response.json(); const json = await response.json();
const overrideTreatments: {[key: string]: boolean} = {}; for (const key in FeatureGates) {
json.exp.treatments[key] = FeatureGates[key]
if (getPref(PrefKey.UI_HOME_CONTEXT_MENU_DISABLED)) {
overrideTreatments['EnableHomeContextMenu'] = false;
}
for (const key in overrideTreatments) {
json.exp.treatments[key] = overrideTreatments[key]
} }
response.json = () => Promise.resolve(json); response.json = () => Promise.resolve(json);
@ -589,7 +591,7 @@ export function interceptHttpRequests() {
} }
// Add list of games with custom layouts to the official list // Add list of games with custom layouts to the official list
if (STATES.hasTouchSupport && url.includes('catalog.gamepass.com/sigls/')) { if (STATES.userAgentHasTouchSupport && url.includes('catalog.gamepass.com/sigls/')) {
const response = await NATIVE_FETCH(request, init); const response = await NATIVE_FETCH(request, init);
const obj = await response.clone().json(); const obj = await response.clone().json();
@ -615,6 +617,21 @@ export function interceptHttpRequests() {
return response; return response;
} }
if (BX_FLAGS.ForceNativeMkbTitles && url.includes('catalog.gamepass.com/sigls/') && url.includes(GamePassCloudGallery.NATIVE_MKB)) {
const response = await NATIVE_FETCH(request, init);
const obj = await response.clone().json();
try {
const newCustomList = BX_FLAGS.ForceNativeMkbTitles.map((item: string) => ({ id: item }));
obj.push(...newCustomList);
} catch (e) {
console.log(e);
}
response.json = () => Promise.resolve(obj);
return response;
}
let requestType: RequestType; let requestType: RequestType;
if (url.includes('/sessions/home') || url.includes('xhome.') || (STATES.remotePlay.isPlaying && url.endsWith('/inputconfigs'))) { if (url.includes('/sessions/home') || url.includes('xhome.') || (STATES.remotePlay.isPlaying && url.endsWith('/inputconfigs'))) {
requestType = RequestType.XHOME; requestType = RequestType.XHOME;

View File

@ -1,10 +1,12 @@
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { SUPPORTED_LANGUAGES, t } from "@utils/translation"; import { SUPPORTED_LANGUAGES, t } from "@utils/translation";
import { SettingElement, SettingElementType } from "@utils/settings"; import { SettingElement, SettingElementType } from "@utils/settings";
import { UserAgentProfile } from "@utils/user-agent"; import { UserAgent } from "@utils/user-agent";
import { StreamStat } from "@modules/stream/stream-stats"; import { StreamStat } from "@modules/stream/stream-stats";
import type { PreferenceSetting, PreferenceSettings } from "@/types/preferences"; import type { PreferenceSetting, PreferenceSettings } from "@/types/preferences";
import { STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { StreamPlayerType, StreamVideoProcessing } from "@enums/stream-player";
import { UserAgentProfile } from "@/enums/user-agent";
export enum PrefKey { export enum PrefKey {
LAST_UPDATE_CHECK = 'version_last_check', LAST_UPDATE_CHECK = 'version_last_check',
@ -44,7 +46,10 @@ export enum PrefKey {
CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration', CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration',
CONTROLLER_VIBRATION_INTENSITY = 'controller_vibration_intensity', CONTROLLER_VIBRATION_INTENSITY = 'controller_vibration_intensity',
NATIVE_MKB_DISABLED = 'native_mkb_disabled', NATIVE_MKB_ENABLED = 'native_mkb_enabled',
NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY = 'native_mkb_scroll_x_sensitivity',
NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY = 'native_mkb_scroll_y_sensitivity',
MKB_ENABLED = 'mkb_enabled', MKB_ENABLED = 'mkb_enabled',
MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor', MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor',
MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse', MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse',
@ -67,7 +72,9 @@ export enum PrefKey {
UI_HOME_CONTEXT_MENU_DISABLED = 'ui_home_context_menu_disabled', UI_HOME_CONTEXT_MENU_DISABLED = 'ui_home_context_menu_disabled',
VIDEO_CLARITY = 'video_clarity', VIDEO_PLAYER_TYPE = 'video_player_type',
VIDEO_PROCESSING = 'video_processing',
VIDEO_SHARPNESS = 'video_sharpness',
VIDEO_RATIO = 'video_ratio', VIDEO_RATIO = 'video_ratio',
VIDEO_BRIGHTNESS = 'video_brightness', VIDEO_BRIGHTNESS = 'video_brightness',
VIDEO_CONTRAST = 'video_contrast', VIDEO_CONTRAST = 'video_contrast',
@ -260,7 +267,7 @@ export class Preferences {
all: t('tc-all-games'), all: t('tc-all-games'),
off: t('off'), off: t('off'),
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
ready: (setting: PreferenceSetting) => { ready: (setting: PreferenceSetting) => {
if (setting.unsupported) { if (setting.unsupported) {
setting.default = 'default'; setting.default = 'default';
@ -270,7 +277,7 @@ export class Preferences {
[PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: { [PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: {
label: t('tc-auto-off'), label: t('tc-auto-off'),
default: false, default: false,
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: { [PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY]: {
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
@ -284,7 +291,7 @@ export class Preferences {
ticks: 10, ticks: 10,
hideSlider: true, hideSlider: true,
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: { [PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: {
label: t('tc-standard-layout-style'), label: t('tc-standard-layout-style'),
@ -294,7 +301,7 @@ export class Preferences {
white: t('tc-all-white'), white: t('tc-all-white'),
muted: t('tc-muted-colors'), muted: t('tc-muted-colors'),
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: { [PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: {
label: t('tc-custom-layout-style'), label: t('tc-custom-layout-style'),
@ -303,7 +310,7 @@ export class Preferences {
default: t('default'), default: t('default'),
muted: t('tc-muted-colors'), muted: t('tc-muted-colors'),
}, },
unsupported: !STATES.hasTouchSupport, unsupported: !STATES.userAgentHasTouchSupport,
}, },
[PrefKey.STREAM_SIMPLIFY_MENU]: { [PrefKey.STREAM_SIMPLIFY_MENU]: {
@ -325,21 +332,30 @@ export class Preferences {
note: '⚠️ ' + t('unexpected-behavior'), note: '⚠️ ' + t('unexpected-behavior'),
default: 0, default: 0,
min: 0, min: 0,
max: 14, max: 14 * 1024 * 1000,
steps: 1, steps: 100 * 1024,
params: { params: {
suffix: ' Mb/s', exactTicks: 5 * 1024 * 1000,
exactTicks: 5,
customTextValue: (value: any) => { customTextValue: (value: any) => {
value = parseInt(value); value = parseInt(value);
if (value === 0) { if (value === 0) {
return t('unlimited'); return t('unlimited');
} else {
return (value / (1024 * 1000)).toFixed(1) + ' Mb/s';
} }
return null;
}, },
}, },
migrate: function(savedPrefs: any, value: any) {
try {
value = parseInt(value);
if (value !== 0 && value < 100) {
value *= 1024 * 1000;
}
this.set(PrefKey.BITRATE_VIDEO_MAX, value, true);
savedPrefs[PrefKey.BITRATE_VIDEO_MAX] = value;
} catch (e) {}
},
}, },
[PrefKey.GAME_BAR_POSITION]: { [PrefKey.GAME_BAR_POSITION]: {
@ -373,10 +389,12 @@ export class Preferences {
}, },
[PrefKey.CONTROLLER_ENABLE_VIBRATION]: { [PrefKey.CONTROLLER_ENABLE_VIBRATION]: {
label: t('controller-vibration'),
default: true, default: true,
}, },
[PrefKey.CONTROLLER_DEVICE_VIBRATION]: { [PrefKey.CONTROLLER_DEVICE_VIBRATION]: {
label: t('device-vibration'),
default: 'off', default: 'off',
options: { options: {
on: t('on'), on: t('on'),
@ -386,6 +404,7 @@ export class Preferences {
}, },
[PrefKey.CONTROLLER_VIBRATION_INTENSITY]: { [PrefKey.CONTROLLER_VIBRATION_INTENSITY]: {
label: t('vibration-intensity'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 100, default: 100,
min: 0, min: 0,
@ -402,7 +421,7 @@ export class Preferences {
default: false, default: false,
unsupported: ((): string | boolean => { unsupported: ((): string | boolean => {
const userAgent = ((window.navigator as any).orgUserAgent || window.navigator.userAgent || '').toLowerCase(); const userAgent = ((window.navigator as any).orgUserAgent || window.navigator.userAgent || '').toLowerCase();
return userAgent.match(/(android|iphone|ipad)/) ? t('browser-unsupported-feature') : false; return !AppInterface && userAgent.match(/(android|iphone|ipad)/) ? t('browser-unsupported-feature') : false;
})(), })(),
ready: (setting: PreferenceSetting) => { ready: (setting: PreferenceSetting) => {
let note; let note;
@ -422,9 +441,64 @@ export class Preferences {
}, },
}, },
[PrefKey.NATIVE_MKB_DISABLED]: { [PrefKey.NATIVE_MKB_ENABLED]: {
label: t('disable-native-mkb'), label: t('native-mkb'),
default: false, default: 'default',
options: {
default: t('default'),
on: t('on'),
off: t('off'),
},
ready: (setting: PreferenceSetting) => {
if (AppInterface) {
} else if (UserAgent.isMobile()) {
setting.unsupported = true;
setting.default = 'off';
delete setting.options!['default'];
delete setting.options!['on'];
} else {
delete setting.options!['on'];
}
},
},
[PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY]: {
label: t('horizontal-scroll-sensitivity'),
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 100 * 100,
steps: 10,
params: {
exactTicks: 20 * 100,
customTextValue: (value: any) => {
if (!value) {
return t('default');
}
return (value / 100).toFixed(1) + 'x';
},
},
},
[PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY]: {
label: t('vertical-scroll-sensitivity'),
type: SettingElementType.NUMBER_STEPPER,
default: 0,
min: 0,
max: 100 * 100,
steps: 10,
params: {
exactTicks: 20 * 100,
customTextValue: (value: any) => {
if (!value) {
return t('default');
}
return (value / 100).toFixed(1) + 'x';
},
},
}, },
[PrefKey.MKB_DEFAULT_PRESET_ID]: { [PrefKey.MKB_DEFAULT_PRESET_ID]: {
@ -474,7 +548,7 @@ export class Preferences {
[PrefKey.UI_HOME_CONTEXT_MENU_DISABLED]: { [PrefKey.UI_HOME_CONTEXT_MENU_DISABLED]: {
label: t('disable-home-context-menu'), label: t('disable-home-context-menu'),
default: false, default: STATES.browserHasTouchSupport,
}, },
[PrefKey.BLOCK_SOCIAL_FEATURES]: { [PrefKey.BLOCK_SOCIAL_FEATURES]: {
@ -500,16 +574,35 @@ export class Preferences {
[UserAgentProfile.CUSTOM]: t('custom'), [UserAgentProfile.CUSTOM]: t('custom'),
}, },
}, },
[PrefKey.VIDEO_CLARITY]: { [PrefKey.VIDEO_PLAYER_TYPE]: {
label: t('renderer'),
default: 'default',
options: {
[StreamPlayerType.VIDEO]: t('default'),
[StreamPlayerType.WEBGL2]: t('webgl2'),
},
},
[PrefKey.VIDEO_PROCESSING]: {
label: t('clarity-boost'),
default: StreamVideoProcessing.USM,
options: {
[StreamVideoProcessing.USM]: t('unsharp-masking'),
[StreamVideoProcessing.CAS]: t('amd-fidelity-cas'),
},
},
[PrefKey.VIDEO_SHARPNESS]: {
label: t('sharpness'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 0, default: 0,
min: 0, min: 0,
max: 5, max: 10,
params: { params: {
hideSlider: true, hideSlider: true,
}, },
}, },
[PrefKey.VIDEO_RATIO]: { [PrefKey.VIDEO_RATIO]: {
label: t('aspect-ratio'),
note: t('aspect-ratio-note'),
default: '16:9', default: '16:9',
options: { options: {
'16:9': '16:9', '16:9': '16:9',
@ -523,6 +616,7 @@ export class Preferences {
}, },
}, },
[PrefKey.VIDEO_SATURATION]: { [PrefKey.VIDEO_SATURATION]: {
label: t('saturation'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 100, default: 100,
min: 50, min: 50,
@ -533,6 +627,7 @@ export class Preferences {
}, },
}, },
[PrefKey.VIDEO_CONTRAST]: { [PrefKey.VIDEO_CONTRAST]: {
label: t('contrast'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 100, default: 100,
min: 50, min: 50,
@ -543,6 +638,7 @@ export class Preferences {
}, },
}, },
[PrefKey.VIDEO_BRIGHTNESS]: { [PrefKey.VIDEO_BRIGHTNESS]: {
label: t('brightness'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 100, default: 100,
min: 50, min: 50,
@ -562,6 +658,7 @@ export class Preferences {
default: false, default: false,
}, },
[PrefKey.AUDIO_VOLUME]: { [PrefKey.AUDIO_VOLUME]: {
label: t('volume'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 100, default: 100,
min: 0, min: 0,
@ -574,6 +671,7 @@ export class Preferences {
[PrefKey.STATS_ITEMS]: { [PrefKey.STATS_ITEMS]: {
label: t('stats'),
default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST], default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST],
multipleOptions: { multipleOptions: {
[StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`, [StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`,
@ -588,12 +686,15 @@ export class Preferences {
}, },
}, },
[PrefKey.STATS_SHOW_WHEN_PLAYING]: { [PrefKey.STATS_SHOW_WHEN_PLAYING]: {
label: t('show-stats-on-startup'),
default: false, default: false,
}, },
[PrefKey.STATS_QUICK_GLANCE]: { [PrefKey.STATS_QUICK_GLANCE]: {
label: '👀 ' + t('enable-quick-glance-mode'),
default: true, default: true,
}, },
[PrefKey.STATS_POSITION]: { [PrefKey.STATS_POSITION]: {
label: t('position'),
default: 'top-right', default: 'top-right',
options: { options: {
'top-left': t('top-left'), 'top-left': t('top-left'),
@ -602,6 +703,7 @@ export class Preferences {
}, },
}, },
[PrefKey.STATS_TEXT_SIZE]: { [PrefKey.STATS_TEXT_SIZE]: {
label: t('text-size'),
default: '0.9rem', default: '0.9rem',
options: { options: {
'0.9rem': t('small'), '0.9rem': t('small'),
@ -610,9 +712,11 @@ export class Preferences {
}, },
}, },
[PrefKey.STATS_TRANSPARENT]: { [PrefKey.STATS_TRANSPARENT]: {
label: t('transparent-background'),
default: false, default: false,
}, },
[PrefKey.STATS_OPACITY]: { [PrefKey.STATS_OPACITY]: {
label: t('opacity'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 80, default: 80,
min: 50, min: 50,
@ -623,6 +727,7 @@ export class Preferences {
}, },
}, },
[PrefKey.STATS_CONDITIONAL_FORMATTING]: { [PrefKey.STATS_CONDITIONAL_FORMATTING]: {
label: t('conditional-formatting'),
default: false, default: false,
}, },
@ -671,11 +776,12 @@ export class Preferences {
for (let settingId in Preferences.SETTINGS) { for (let settingId in Preferences.SETTINGS) {
const setting = Preferences.SETTINGS[settingId]; const setting = Preferences.SETTINGS[settingId];
setting.ready && setting.ready.call(this, setting);
if (setting.migrate && settingId in savedPrefs) { if (setting.migrate && settingId in savedPrefs) {
setting.migrate.call(this, savedPrefs, savedPrefs[settingId]); setting.migrate.call(this, savedPrefs, savedPrefs[settingId]);
} }
setting.ready && setting.ready.call(this, setting);
} }
for (let settingId in Preferences.SETTINGS) { for (let settingId in Preferences.SETTINGS) {
@ -686,7 +792,7 @@ export class Preferences {
continue; continue;
} }
// Ignore deprecated settings // Ignore deprecated/migrated settings
if (setting.migrate) { if (setting.migrate) {
continue; continue;
} }
@ -753,11 +859,13 @@ export class Preferences {
return this.#prefs[key]; return this.#prefs[key];
} }
set(key: PrefKey, value: any) { set(key: PrefKey, value: any, skipSave?: boolean): any {
value = this.#validateValue(key, value); value = this.#validateValue(key, value);
this.#prefs[key] = value; this.#prefs[key] = value;
this.#updateStorage(); !skipSave && this.#updateStorage();
return value;
} }
#updateStorage() { #updateStorage() {

View File

@ -1,8 +1,9 @@
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { BxLogger } from "./bx-logger"; import { BxLogger } from "./bx-logger";
import { TouchController } from "@modules/touch-controller"; import { TouchController } from "@modules/touch-controller";
import { GamePassCloudGallery } from "./gamepass-gallery"; import { GamePassCloudGallery } from "../enums/game-pass-gallery";
import { getPref, PrefKey } from "./preferences"; import { getPref, PrefKey } from "./preferences";
import { BX_FLAGS } from "./bx-flags";
const LOG_TAG = 'PreloadState'; const LOG_TAG = 'PreloadState';
@ -23,7 +24,7 @@ export function overridePreloadState() {
} }
// Add list of games with custom layouts to the official list // Add list of games with custom layouts to the official list
if (STATES.hasTouchSupport) { if (STATES.userAgentHasTouchSupport) {
try { try {
const sigls = state.xcloud.sigls; const sigls = state.xcloud.sigls;
if (GamePassCloudGallery.TOUCH in sigls) { if (GamePassCloudGallery.TOUCH in sigls) {
@ -37,6 +38,12 @@ export function overridePreloadState() {
// Add to the official list // Add to the official list
sigls[GamePassCloudGallery.TOUCH]?.data.products.push(...customList); sigls[GamePassCloudGallery.TOUCH]?.data.products.push(...customList);
} }
if (BX_FLAGS.ForceNativeMkbTitles && GamePassCloudGallery.NATIVE_MKB in sigls) {
// Add to the official list
sigls[GamePassCloudGallery.NATIVE_MKB]?.data.products.push(...BX_FLAGS.ForceNativeMkbTitles);
}
} catch (e) { } catch (e) {
BxLogger.error(LOG_TAG, e); BxLogger.error(LOG_TAG, e);
} }

View File

@ -1,23 +1,28 @@
import { StreamPlayerType } from "@enums/stream-player";
import { AppInterface, STATES } from "./global"; import { AppInterface, STATES } from "./global";
import { CE } from "./html"; import { CE } from "./html";
import { getPref, PrefKey } from "./preferences";
export class Screenshot { export class Screenshot {
static setup() { static #$canvas: HTMLCanvasElement;
const currentStream = STATES.currentStream; static #canvasContext: CanvasRenderingContext2D;
if (!currentStream.$screenshotCanvas) {
currentStream.$screenshotCanvas = CE<HTMLCanvasElement>('canvas', {'class': 'bx-gone'});
currentStream.screenshotCanvasContext = currentStream.$screenshotCanvas.getContext('2d', { static setup() {
if (Screenshot.#$canvas) {
return;
}
Screenshot.#$canvas = CE<HTMLCanvasElement>('canvas', {'class': 'bx-gone'});
Screenshot.#canvasContext = Screenshot.#$canvas.getContext('2d', {
alpha: false, alpha: false,
willReadFrequently: false, willReadFrequently: false,
}); })!;
}
// document.documentElement.appendChild(currentStream.$screenshotCanvas!);
} }
static updateCanvasSize(width: number, height: number) { static updateCanvasSize(width: number, height: number) {
const $canvas = STATES.currentStream.$screenshotCanvas; const $canvas = Screenshot.#$canvas;
if ($canvas) { if ($canvas) {
$canvas.width = width; $canvas.width = width;
$canvas.height = height; $canvas.height = height;
@ -25,26 +30,42 @@ export class Screenshot {
} }
static updateCanvasFilters(filters: string) { static updateCanvasFilters(filters: string) {
STATES.currentStream.screenshotCanvasContext && (STATES.currentStream.screenshotCanvasContext.filter = filters); Screenshot.#canvasContext.filter = filters;
} }
private static onAnimationEnd(e: Event) { static #onAnimationEnd(e: Event) {
(e.target as any).classList.remove('bx-taking-screenshot'); const $target = e.target as HTMLElement;
$target.classList.remove('bx-taking-screenshot');
} }
static takeScreenshot(callback?: any) { static takeScreenshot(callback?: any) {
const currentStream = STATES.currentStream; const currentStream = STATES.currentStream;
const $video = currentStream.$video; const streamPlayer = currentStream.streamPlayer;
const $canvas = currentStream.$screenshotCanvas; const $canvas = Screenshot.#$canvas;
if (!$video || !$canvas) { if (!streamPlayer || !$canvas) {
return; return;
} }
$video.parentElement?.addEventListener('animationend', this.onAnimationEnd); let $player;
$video.parentElement?.classList.add('bx-taking-screenshot'); if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
$player = streamPlayer.getPlayerElement();
} else {
$player = streamPlayer.getPlayerElement(StreamPlayerType.VIDEO);
}
const canvasContext = currentStream.screenshotCanvasContext!; if (!$player || !$player.isConnected) {
canvasContext.drawImage($video, 0, 0, $canvas.width, $canvas.height); return;
}
$player.parentElement!.addEventListener('animationend', this.#onAnimationEnd);
$player.parentElement!.classList.add('bx-taking-screenshot');
const canvasContext = Screenshot.#canvasContext;
if ($player instanceof HTMLCanvasElement) {
streamPlayer.getWebGL2Player().drawFrame();
}
canvasContext.drawImage($player, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app // Get data URL and pass to parent app
if (AppInterface) { if (AppInterface) {

View File

@ -155,7 +155,7 @@ export class SettingElement {
return textContent; return textContent;
}; };
const $wrapper = CE('div', {'class': 'bx-number-stepper'}, const $wrapper = CE('div', {'class': 'bx-number-stepper', id: `bx_setting_${key}`},
$decBtn = CE('button', { $decBtn = CE('button', {
'data-type': 'dec', 'data-type': 'dec',
type: 'button', type: 'button',
@ -183,7 +183,7 @@ export class SettingElement {
$range.addEventListener('input', e => { $range.addEventListener('input', e => {
value = parseInt((e.target as HTMLInputElement).value); value = parseInt((e.target as HTMLInputElement).value);
$text.textContent = renderTextValue(value); $text.textContent = renderTextValue(value);
onChange && onChange(e, value); !(e as any).ignoreOnChange && onChange && onChange(e, value);
}); });
$wrapper.appendChild($range); $wrapper.appendChild($range);

View File

@ -43,7 +43,7 @@ export class Toast {
// Get values from item // Get values from item
const [msg, status, options] = Toast.#stack.shift()!; const [msg, status, options] = Toast.#stack.shift()!;
if (options.html) { if (options && options.html) {
Toast.#$msg.innerHTML = msg; Toast.#$msg.innerHTML = msg;
} else { } else {
Toast.#$msg.textContent = msg; Toast.#$msg.textContent = msg;

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,11 +1,17 @@
import { PrefKey, getPref, setPref } from "@utils/preferences"; import { PrefKey, getPref, setPref } from "@utils/preferences";
import { AppInterface, SCRIPT_VERSION } from "@utils/global"; import { AppInterface, SCRIPT_VERSION } from "@utils/global";
import { UserAgent } from "@utils/user-agent"; import { UserAgent } from "@utils/user-agent";
import { Translations } from "./translation";
/** /**
* Check for update * Check for update
*/ */
export function checkForUpdate() { export function checkForUpdate() {
// Don't check update for beta version
if (SCRIPT_VERSION.includes('beta')) {
return;
}
const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours
const currentVersion = getPref(PrefKey.CURRENT_VERSION); const currentVersion = getPref(PrefKey.CURRENT_VERSION);
@ -25,6 +31,9 @@ export function checkForUpdate() {
setPref(PrefKey.LATEST_VERSION, json.tag_name.substring(1)); setPref(PrefKey.LATEST_VERSION, json.tag_name.substring(1));
setPref(PrefKey.CURRENT_VERSION, SCRIPT_VERSION); setPref(PrefKey.CURRENT_VERSION, SCRIPT_VERSION);
}); });
// Update translations
Translations.updateTranslations(currentVersion === SCRIPT_VERSION);
} }
@ -38,7 +47,7 @@ export function disablePwa() {
} }
// Check if it's Safari on mobile // Check if it's Safari on mobile
if (!!AppInterface || UserAgent.isSafari(true)) { if (!!AppInterface || UserAgent.isSafariMobile()) {
// Disable the PWA prompt // Disable the PWA prompt
Object.defineProperty(window.navigator, 'standalone', { Object.defineProperty(window.navigator, 'standalone', {
value: true, value: true,
@ -61,3 +70,28 @@ export function hashCode(str: string): number {
return hash; return hash;
} }
export function renderString(str: string, obj: any){
return str.replace(/\$\{.+?\}/g, match => {
const key = match.substring(2, match.length - 1);
if (key in obj) {
return obj[key];
}
return match;
});
}
export function ceilToNearest(value: number, interval: number): number {
return Math.ceil(value / interval) * interval;
}
export function floorToNearest(value: number, interval: number): number {
return Math.floor(value / interval) * interval;
}
export function roundToNearest(value: number, interval: number): number {
return Math.round(value / interval) * interval;
}

View File

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