Compare commits

...

50 Commits

Author SHA1 Message Date
297c0848d5 Bump version to 5.0.1 2024-06-26 18:14:57 +07:00
51ef9f9e8f Update package.json 2024-06-26 18:14:29 +07:00
9717315b79 Update README.md 2024-06-26 08:44:16 +07:00
e176ef6fc0 Update package.json 2024-06-23 17:59:24 +07:00
52694d8f8e Update better-xcloud.user.js 2024-06-23 17:30:00 +07:00
b7928ebe68 Fix stream badge showing "1h60m" instead of "2h" 2024-06-23 17:27:11 +07:00
05eddce11e Update better-xcloud.user.js 2024-06-22 16:43:22 +07:00
057da5b3ea Fix exception with navigator.vibrate() on start up 2024-06-22 16:43:18 +07:00
11ef014c74 Update build.ts 2024-06-22 16:30:22 +07:00
fa82f0ba95 Update better-xcloud.user.js 2024-06-22 16:30:02 +07:00
36db8db1e7 Minify syntax in dist file 2024-06-22 16:29:54 +07:00
d906de7803 Update better-xcloud.user.js 2024-06-22 10:36:27 +07:00
cf546123db Show "Off" when Sharpness is 0 2024-06-22 10:36:02 +07:00
d6a4d1741b Update NumberStepper 2024-06-22 10:35:45 +07:00
22e7400e06 Bump version to 5.0.0 2024-06-21 18:10:50 +07:00
f169c17e18 Add WebGL2 renderer 2024-06-21 17:45:43 +07:00
6150c2ea70 Update better-xcloud.user.js 2024-06-20 20:46:15 +07:00
2cdf92b159 Update better-xcloud.user.js 2024-06-19 18:17:42 +07:00
6f6a9e223e Show WS error in toast 2024-06-10 08:57:07 +07:00
f71904c30b Bump version to 4.7.1 2024-06-10 08:29:53 +07:00
3a16187504 Update Guide menu detection 2024-06-10 08:03:13 +07:00
00ebb3f672 Fix dispatching STREAM_PLAYING event when playing normal video 2024-06-09 18:31:57 +07:00
ebb4d3c141 Add FeatureGates 2024-06-09 18:31:15 +07:00
902918d7fb Update URLs 2024-06-09 15:56:32 +07:00
b780e4e63b Minor fix 2024-06-09 15:45:41 +07:00
32889e0cf1 Minor fix 2024-06-09 15:43:19 +07:00
d8b9fcc951 Update better-xcloud.user.js 2024-06-09 15:40:15 +07:00
c7734245ae Don't check update for beta version 2024-06-09 11:50:46 +07:00
35e7fdacb5 Disable context menu on devices with touch support by default 2024-06-09 11:48:12 +07:00
504f16b802 Rename "hasTouchSupport" to "userAgentHasTouchSupport" 2024-06-09 11:47:08 +07:00
a3a7a57b51 Get PointerServer's port from the app 2024-06-09 11:41:00 +07:00
0d59ab2ee2 Bump version to 4.7.0 2024-06-08 17:22:43 +07:00
ff794c44b5 Update better-xcloud.user.js 2024-06-08 17:21:55 +07:00
ccc824d544 Add vscode files 2024-06-08 17:05:02 +07:00
eb8490a798 Add native MKB support for Android app 2024-06-08 17:04:49 +07:00
a41d0cda0c Update better-xcloud.user.js 2024-06-07 21:04:52 +07:00
559c3c52c3 Update better-xcloud.user.js 2024-06-07 07:57:10 +07:00
2ed1e8735f Update better-xcloud.user.js 2024-06-07 07:20:25 +07:00
03d5550f05 Update better-xcloud.user.js 2024-06-06 20:53:53 +07:00
fb1ce5306d Update better-xcloud.user.js 2024-06-05 21:37:38 +07:00
e8e37aa575 Update better-xcloud.user.js 2024-06-05 18:28:31 +07:00
9f1f28a2d7 Update better-xcloud.user.js 2024-06-03 15:52:55 +07:00
44cf4f1d19 Fix video ratio not working properly 2024-06-03 15:52:52 +07:00
bb20f408a3 Update custom-flags.user.js 2024-06-03 05:44:58 +07:00
c03737e224 Update custom-flags.user.js 2024-06-02 11:13:47 +07:00
5b137f7791 Update 01-bug-report.yml 2024-06-02 10:54:14 +07:00
7f52479f0a Update better-xcloud.user.js 2024-06-02 10:44:23 +07:00
2e0a59cbe1 Add back the ability to use native MKB feature on unofficial titles 2024-06-02 10:43:59 +07:00
850afb4ca7 Disable xCloud analytics also remove the Feedback button in the Guide menu 2024-06-02 09:57:04 +07:00
e98fa29271 Disable social features also hide the "Start a party" button in the Guide menu 2024-06-02 09:52:41 +07:00
70 changed files with 5053 additions and 4158 deletions

View File

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

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

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

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

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

View File

@ -1,6 +1,7 @@
MIT License MIT License
Copyright (c) 2023 redphx Copyright (c) 2023 redphx
Copyright (c) 2023 Advanced Micro Devices, Inc.
Copyright (c) 2020 Phosphor Icons Copyright (c) 2020 Phosphor Icons
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy

View File

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

View File

@ -45,6 +45,9 @@ const build = async (target: BuildTarget, version: string, config: any={}) => {
entrypoints: ['src/index.ts'], entrypoints: ['src/index.ts'],
outdir: outDir, outdir: outDir,
naming: outputScriptName, naming: outputScriptName,
minify: {
syntax: true,
},
define: { define: {
'Bun.env.BUILD_TARGET': JSON.stringify(target), 'Bun.env.BUILD_TARGET': JSON.stringify(target),
'Bun.env.SCRIPT_VERSION': JSON.stringify(version), 'Bun.env.SCRIPT_VERSION': JSON.stringify(version),
@ -99,5 +102,5 @@ console.log('Building: ', values['version']);
const config = {}; const config = {};
for (const target of buildTargets) { for (const target of buildTargets) {
await build(target, values['version'], config); await build(target, values['version']!!, config);
} }

View File

@ -1,5 +1,5 @@
// ==UserScript== // ==UserScript==
// @name Better xCloud // @name Better xCloud
// @namespace https://github.com/redphx // @namespace https://github.com/redphx
// @version 4.6.3 // @version 5.0.1
// ==/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": "^1.1.3", "@types/bun": "^1.1.5",
"@types/node": "^20.13.0", "@types/node": "^20.14.9",
"@types/stylus": "^0.48.42", "@types/stylus": "^0.48.42",
"stylus": "^0.63.0" "stylus": "^0.63.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5.4.5" "typescript": "^5.5.2"
} }
} }

View File

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

View File

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

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,7 +16,6 @@
} }
.bx-mkb-pointer-lock-msg { .bx-mkb-pointer-lock-msg {
cursor: pointer;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
position: fixed; position: fixed;
@ -24,7 +23,7 @@
top: 50%; top: 50%;
transform: translateX(-50%) translateY(-50%); transform: translateX(-50%) translateY(-50%);
margin: auto; margin: auto;
background: #000000b3; background: #151515;
z-index: var(--bx-mkb-pointer-lock-msg-z-index); z-index: var(--bx-mkb-pointer-lock-msg-z-index);
color: #fff; color: #fff;
text-align: center; text-align: center;
@ -35,9 +34,11 @@
border-radius: 8px; border-radius: 8px;
align-items: center; align-items: center;
box-shadow: 0 0 6px #000; box-shadow: 0 0 6px #000;
min-width: 220px;
opacity: 0.9;
&:hover { &:hover {
background: #151515; opacity: 1;
} }
> div:first-of-type { > div:first-of-type {
@ -51,19 +52,32 @@
&:first-child { &:first-child {
font-size: 22px; font-size: 22px;
margin-bottom: 8px; margin-bottom: 4px;
font-weight: bold;
} }
&:last-child { &:last-child {
font-size: 14px; font-size: 12px;
font-style: italic; font-style: italic;
} }
} }
> div:last-of-type { > div:last-of-type {
margin-top: 10px;
&[data-type='native'] {
button {
&:first-of-type {
margin-bottom: 8px;
}
}
}
&[data-type='virtual'] {
div {
display: flex; display: flex;
flex-flow: row; flex-flow: row;
margin-top: 10px; margin-top: 8px;
button { button {
flex: 1; flex: 1;
@ -76,8 +90,8 @@
margin-left: 5px; margin-left: 5px;
} }
} }
}
button }
} }
} }

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;
} }

View File

@ -113,6 +113,11 @@
background: transparent; background: transparent;
text-align-last: right; text-align-last: right;
border: none; border: none;
color: #fff;
}
select option:disabled {
display: none;
} }
} }

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 619 B

View File

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

After

Width:  |  Height:  |  Size: 680 B

View File

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

View File

@ -1,5 +1,5 @@
import type { GamepadKeyNameType } from "@/types/mkb"; import type { GamepadKeyNameType } from "@/types/mkb";
import { PrompFont } from "@/utils/prompt-font"; import { PrompFont } from "@enums/prompt-font";
export enum GamepadKey { export enum GamepadKey {
A = 0, A = 0,

View File

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

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

@ -1,17 +1,17 @@
import "@utils/global"; import "@utils/global";
import { BxEvent, XcloudGuideWhere } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS } from "@utils/bx-flags"; import { BX_FLAGS } from "@utils/bx-flags";
import { BxExposed } from "@utils/bx-exposed"; import { BxExposed } from "@utils/bx-exposed";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { interceptHttpRequests } from "@utils/network"; import { interceptHttpRequests } from "@utils/network";
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { showGamepadToast } from "@utils/gamepad"; import { showGamepadToast } from "@utils/gamepad";
import { MkbHandler } from "@modules/mkb/mkb-handler"; import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler";
import { StreamBadges } from "@modules/stream/stream-badges"; import { StreamBadges } from "@modules/stream/stream-badges";
import { StreamStats } from "@modules/stream/stream-stats"; import { StreamStats } from "@modules/stream/stream-stats";
import { addCss } from "@utils/css"; import { addCss } 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 { AppInterface, STATES } from "@utils/global"; import { AppInterface, STATES } from "@utils/global";
import { injectStreamMenuButtons, setupStreamUiEvents } from "@modules/stream/stream-ui"; import { injectStreamMenuButtons } from "@modules/stream/stream-ui";
import { BxLogger } from "@utils/bx-logger"; import { BxLogger } from "@utils/bx-logger";
import { GameBar } from "./modules/game-bar/game-bar"; import { GameBar } from "./modules/game-bar/game-bar";
import { Screenshot } from "./utils/screenshot"; import { Screenshot } from "./utils/screenshot";
import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler";
import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu";
// 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,39 +156,48 @@ 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
getPref(PrefKey.MKB_ENABLED) && MkbHandler.INSTANCE.destroy();
const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog'); const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog');
if ($streamSettingsDialog) { if ($streamSettingsDialog) {
$streamSettingsDialog.classList.add('bx-gone'); $streamSettingsDialog.classList.add('bx-gone');
} }
STATES.currentStream.audioGainNode = null;
STATES.currentStream.$video = null;
StreamStats.getInstance().onStoppedPlaying(); StreamStats.getInstance().onStoppedPlaying();
MouseCursorHider.stop(); MouseCursorHider.stop();
TouchController.reset(); TouchController.reset();
GameBar.getInstance().disable(); GameBar.getInstance().disable();
}
window.addEventListener(BxEvent.STREAM_STOPPED, unload);
window.addEventListener('pagehide', e => {
BxEvent.dispatch(window, BxEvent.STREAM_STOPPED);
}); });
window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => { window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => {
@ -210,6 +218,8 @@ function observeRootDialog($root: HTMLElement) {
const $addedElm = mutation.addedNodes[0]; const $addedElm = mutation.addedNodes[0];
if ($addedElm instanceof HTMLElement && $addedElm.className) { if ($addedElm instanceof HTMLElement && $addedElm.className) {
if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) { if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) {
// Make sure it's Guide dialog
if (document.querySelector('#gamepass-dialog-root div[class*=GuideDialog]')) {
// Find navigation bar // Find navigation bar
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true'); const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
if ($selectedTab) { if ($selectedTab) {
@ -218,7 +228,8 @@ function observeRootDialog($root: HTMLElement) {
for (index = 0; ($elm = $elm?.previousElementSibling); index++); for (index = 0; ($elm = $elm?.previousElementSibling); index++);
if (index === 0) { if (index === 0) {
BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_SHOWN, {where: XcloudGuideWhere.HOME}); BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
}
} }
} }
} }
@ -263,11 +274,12 @@ function main() {
interceptHttpRequests(); interceptHttpRequests();
patchVideoApi(); patchVideoApi();
patchCanvasContext(); patchCanvasContext();
AppInterface && patchPointerLockApi();
getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext(); getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext();
getPref(PrefKey.BLOCK_TRACKING) && patchMeControl(); getPref(PrefKey.BLOCK_TRACKING) && patchMeControl();
STATES.hasTouchSupport && TouchController.updateCustomList(); STATES.userAgentHasTouchSupport && TouchController.updateCustomList();
overridePreloadState(); overridePreloadState();
VibrationManager.initialSetup(); VibrationManager.initialSetup();
@ -281,10 +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();
setupStreamUiEvents(); GuideMenu.observe();
StreamBadges.setupEvents(); StreamBadges.setupEvents();
StreamStats.setupEvents(); StreamStats.setupEvents();
MkbHandler.setupEvents(); EmulatedMkbHandler.setupEvents();
Patcher.init(); Patcher.init();
@ -304,7 +316,10 @@ function main() {
} }
// Start PointerProviderServer // Start PointerProviderServer
(getPref(PrefKey.MKB_ENABLED)) && AppInterface && AppInterface.startPointerServer(); if (getPref(PrefKey.MKB_ENABLED) && AppInterface) {
STATES.pointerServerPort = AppInterface.startPointerServer() || 9269;
BxLogger.info('startPointerServer', 'Port', STATES.pointerServerPort.toString());
}
} }
main(); main();

View File

@ -1,9 +1,9 @@
import { Screenshot } from "@utils/screenshot"; import { Screenshot } from "@utils/screenshot";
import { GamepadKey } from "./mkb/definitions"; import { GamepadKey } from "@enums/mkb";
import { PrompFont } from "@utils/prompt-font"; import { PrompFont } from "@enums/prompt-font";
import { CE } from "@utils/html"; import { CE } from "@utils/html";
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { MkbHandler } from "./mkb/mkb-handler"; import { EmulatedMkbHandler } from "./mkb/mkb-handler";
import { StreamStats } from "./stream/stream-stats"; import { StreamStats } from "./stream/stream-stats";
import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone"; import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone";
import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui"; import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui";
@ -172,7 +172,7 @@ export class ControllerShortcut {
} }
// Ignore emulated gamepad // Ignore emulated gamepad
if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) { if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) {
continue; continue;
} }

View File

@ -41,7 +41,7 @@ export class GameBar {
this.actions = [ this.actions = [
new ScreenshotAction(), new ScreenshotAction(),
...(STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []), ...(STATES.userAgentHasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []),
new MicrophoneAction(), new MicrophoneAction(),
]; ];

View File

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

View File

@ -1,4 +1,4 @@
import { MouseButtonCode, WheelCode } from "./definitions"; import { MouseButtonCode, WheelCode } from "@enums/mkb";
export class KeyHelper { export class KeyHelper {
static #NON_PRINTABLE_KEYS = { static #NON_PRINTABLE_KEYS = {

View File

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

View File

@ -1,16 +1,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);
} }
@ -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

@ -1,8 +1,6 @@
import { BxLogger } from "@/utils/bx-logger"; import { BxLogger } from "@/utils/bx-logger";
import type { MkbHandler } from "./mkb-handler";
import { KeyHelper } from "./key-helper";
import { WheelCode } from "./definitions";
import { Toast } from "@/utils/toast"; import { Toast } from "@/utils/toast";
import type { MkbHandler } from "./base-mkb-handler";
const LOG_TAG = 'PointerClient'; const LOG_TAG = 'PointerClient';
@ -14,15 +12,8 @@ enum PointerAction {
POINTER_CAPTURE_CHANGED = 5, POINTER_CAPTURE_CHANGED = 5,
} }
const FixedMouseIndex = {
1: 0,
2: 2,
4: 1,
}
export class PointerClient { export class PointerClient {
static #PORT = 9269;
private static instance: PointerClient; private static instance: PointerClient;
public static getInstance(): PointerClient { public static getInstance(): PointerClient {
if (!PointerClient.instance) { if (!PointerClient.instance) {
@ -35,11 +26,15 @@ export class PointerClient {
#socket: WebSocket | undefined | null; #socket: WebSocket | undefined | null;
#mkbHandler: MkbHandler | undefined; #mkbHandler: MkbHandler | undefined;
start(mkbHandler: MkbHandler) { start(port: number, mkbHandler: MkbHandler) {
if (!port) {
throw new Error('PointerServer port is 0');
}
this.#mkbHandler = mkbHandler; this.#mkbHandler = mkbHandler;
// Create WebSocket connection. // Create WebSocket connection.
this.#socket = new WebSocket(`ws://localhost:${PointerClient.#PORT}`); this.#socket = new WebSocket(`ws://localhost:${port}`);
this.#socket.binaryType = 'arraybuffer'; this.#socket.binaryType = 'arraybuffer';
// Connection opened // Connection opened
@ -50,7 +45,7 @@ export class PointerClient {
// Error // Error
this.#socket.addEventListener('error', (event) => { this.#socket.addEventListener('error', (event) => {
BxLogger.error(LOG_TAG, event); BxLogger.error(LOG_TAG, event);
Toast.show('Cannot setup mouse'); Toast.show('Cannot setup mouse: ' + event);
}); });
this.#socket.addEventListener('close', (event) => { this.#socket.addEventListener('close', (event) => {
@ -97,15 +92,10 @@ export class PointerClient {
} }
onPress(messageType: PointerAction, dataView: DataView, offset: number) { onPress(messageType: PointerAction, dataView: DataView, offset: number) {
const buttonIndex = dataView.getInt8(offset); const button = dataView.getUint8(offset);
const fixedIndex = FixedMouseIndex[buttonIndex as keyof typeof FixedMouseIndex];
const keyCode = 'Mouse' + fixedIndex;
this.#mkbHandler?.handleMouseClick({ this.#mkbHandler?.handleMouseClick({
key: { pointerButton: button,
code: keyCode,
name: KeyHelper.codeToKeyName(keyCode),
},
pressed: messageType === PointerAction.BUTTON_PRESS, pressed: messageType === PointerAction.BUTTON_PRESS,
}); });
@ -114,26 +104,13 @@ export class PointerClient {
onScroll(dataView: DataView, offset: number) { onScroll(dataView: DataView, offset: number) {
// [V_SCROLL, H_SCROLL] // [V_SCROLL, H_SCROLL]
const vScroll = dataView.getInt8(offset); const vScroll = dataView.getInt16(offset);
offset += Int8Array.BYTES_PER_ELEMENT; offset += Int16Array.BYTES_PER_ELEMENT;
const hScroll = dataView.getInt8(offset); const hScroll = dataView.getInt16(offset);
let code = ''; this.#mkbHandler?.handleMouseWheel({
if (vScroll < 0) { vertical: vScroll,
code = WheelCode.SCROLL_UP; horizontal: hScroll,
} else if (vScroll > 0) {
code = WheelCode.SCROLL_DOWN;
} else if (hScroll < 0) {
code = WheelCode.SCROLL_LEFT;
} else if (hScroll > 0) {
code = WheelCode.SCROLL_RIGHT;
}
code && this.#mkbHandler?.handleMouseWheel({
key: {
code: code,
name: KeyHelper.codeToKeyName(code),
},
}); });
// BxLogger.info(LOG_TAG, 'scroll', vScroll, hScroll); // BxLogger.info(LOG_TAG, 'scroll', vScroll, hScroll);
@ -148,5 +125,6 @@ export class PointerClient {
try { try {
this.#socket?.close(); this.#socket?.close();
} catch (e) {} } catch (e) {}
this.#socket = null;
} }
} }

View File

@ -7,10 +7,12 @@ import { hashCode, renderString } from "@utils/utils";
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
import codeControllerShortcuts from "./patches/controller-shortcuts.js" with { type: "text" }; import codeControllerShortcuts from "./patches/controller-shortcuts.js" with { type: "text" };
import codeExposeStreamSession from "./patches/expose-stream-session.js" with { type: "text" };
import codeLocalCoOpEnable from "./patches/local-co-op-enable.js" with { type: "text" }; import codeLocalCoOpEnable from "./patches/local-co-op-enable.js" with { type: "text" };
import codeRemotePlayEnable from "./patches/remote-play-enable.js" with { type: "text" }; import codeRemotePlayEnable from "./patches/remote-play-enable.js" with { type: "text" };
import codeRemotePlayKeepAlive from "./patches/remote-play-keep-alive.js" with { type: "text" }; import codeRemotePlayKeepAlive from "./patches/remote-play-keep-alive.js" with { type: "text" };
import codeVibrationAdjust from "./patches/vibration-adjust.js" with { type: "text" }; import codeVibrationAdjust from "./patches/vibration-adjust.js" with { type: "text" };
import { FeatureGates } from "@/utils/feature-gates.js";
type PatchArray = (keyof typeof PATCHES)[]; type PatchArray = (keyof typeof PATCHES)[];
@ -228,12 +230,10 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
// Find the next "}," // Find the next "},"
const endIndex = str.indexOf('},', index); const endIndex = str.indexOf('},', index);
const newSettings = [ let newSettings = JSON.stringify(FeatureGates);
// 'EnableStreamGate: false', newSettings = newSettings.substring(1, newSettings.length - 1);
'PwaPrompt: false',
];
const newCode = newSettings.join(','); const newCode = newSettings;
str = str.substring(0, endIndex) + ',' + newCode + str.substring(endIndex); str = str.substring(0, endIndex) + ',' + newCode + str.substring(endIndex);
return str; return str;
@ -304,6 +304,37 @@ window.dispatchEvent(new Event("${BxEvent.TOUCH_LAYOUT_MANAGER_READY}"));
return str; return str;
}, },
patchBabylonRendererClass(str: string) {
// ()=>{a.current.render(),h.current=window.requestAnimationFrame(l)
let index = str.indexOf('.current.render(),');
if (index === -1) {
return false;
}
// Move back a character
index -= 1;
// Get variable of the "BabylonRendererClass" object
const rendererVar = str[index];
const newCode = `
if (window.BX_EXPOSED.stopTakRendering) {
try {
document.getElementById('BabylonCanvasContainer-main')?.parentElement.classList.add('bx-offscreen');
${rendererVar}.current.dispose();
} catch (e) {}
window.BX_EXPOSED.stopTakRendering = false;
return;
}
`;
str = str.substring(0, index) + newCode + str.substring(index);
return str;
},
supportLocalCoOp(str: string) { supportLocalCoOp(str: string) {
const text = 'this.gamepadMappingsToSend=[],'; const text = 'this.gamepadMappingsToSend=[],';
if (!str.includes(text)) { if (!str.includes(text)) {
@ -535,20 +566,7 @@ window.dispatchEvent(new Event('${BxEvent.STREAM_EVENT_TARGET_READY}'))
} }
const newCode = `; const newCode = `;
window.BX_EXPOSED.streamSession = this; ${codeExposeStreamSession}
const orgSetMicrophoneState = this.setMicrophoneState.bind(this);
this.setMicrophoneState = state => {
orgSetMicrophoneState(state);
const evt = new Event('${BxEvent.MICROPHONE_STATE_CHANGED}');
evt.microphoneState = state;
window.dispatchEvent(evt);
};
window.dispatchEvent(new Event('${BxEvent.STREAM_SESSION_READY}'))
true` + text; true` + text;
str = str.replace(text, newCode); str = str.replace(text, newCode);
@ -564,9 +582,58 @@ true` + text;
str = str.replace(text, '&& false ' + text); str = str.replace(text, '&& false ' + text);
return str; return str;
}, },
enableNativeMkb(str: string) {
const text = 'e.mouseSupported&&e.keyboardSupported&&e.fullscreenSupported;';
if ((!str.includes(text))) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
patchMouseAndKeyboardEnabled(str: string) {
const text = 'get mouseAndKeyboardEnabled(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return true;');
return str;
},
exposeInputSink(str: string) {
const text = 'this.controlChannel=null,this.inputChannel=null';
if (!str.includes(text)) {
return false;
}
const newCode = 'window.BX_EXPOSED.inputSink = this;';
str = str.replace(text, newCode + text);
return str;
},
disableNativeRequestPointerLock(str: string) {
const text = 'async requestPointerLock(){';
if (!str.includes(text)) {
return false;
}
str = str.replace(text, text + 'return;');
return str;
}
}; };
let PATCH_ORDERS: PatchArray = [ let PATCH_ORDERS: PatchArray = [
...(getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' ? [
'enableNativeMkb',
'patchMouseAndKeyboardEnabled',
'disableNativeRequestPointerLock',
'exposeInputSink',
] : []),
'disableStreamGate', 'disableStreamGate',
'overrideSettings', 'overrideSettings',
'broadcastPollingMode', 'broadcastPollingMode',
@ -592,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 ? [
@ -618,11 +685,13 @@ let PLAYING_PATCH_ORDERS: PatchArray = [
// Skip feedback dialog // Skip feedback dialog
getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog', getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog',
...(STATES.userAgentHasTouchSupport ? [
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager',
STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer', (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer',
STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity', getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity',
'patchBabylonRendererClass',
] : []),
BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging', BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { BxEvent, XcloudGuideWhere } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { CE, createSvgIcon } from "@utils/html"; import { CE, createSvgIcon } from "@utils/html";
import { STATES } from "@utils/global"; import { STATES } from "@utils/global";
import { BxLogger } from "@/utils/bx-logger"; import { BxLogger } from "@/utils/bx-logger";
@ -158,19 +158,26 @@ export class StreamBadges {
} }
#secondsToHm(seconds: number) { #secondsToHm(seconds: number) {
const h = Math.floor(seconds / 3600); let h = Math.floor(seconds / 3600);
const m = Math.floor(seconds % 3600 / 60) + 1; let m = Math.floor(seconds % 3600 / 60) + 1;
const hDisplay = h > 0 ? `${h}h`: ''; if (m === 60) {
const mDisplay = m > 0 ? `${m}m`: ''; h += 1;
return hDisplay + mDisplay; m = 0;
}
const output = [];
h > 0 && output.push(`${h}h`);
m > 0 && output.push(`${m}m`);
return output.join(' ');
} }
// https://stackoverflow.com/a/20732091 // https://stackoverflow.com/a/20732091
#humanFileSize(size: number) { #humanFileSize(size: number) {
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; return (size / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
} }

View File

@ -161,7 +161,12 @@ export class StreamStats {
const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime; const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime;
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded;
const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000; const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000;
if (isNaN(currentDecodeTime)) {
this.#$dt!.textContent = '??ms';
} else {
this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`; this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`;
}
if (PREF_STATS_CONDITIONAL_FORMATTING) { if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : ''; grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : '';

View File

@ -1,7 +1,7 @@
import { STATES } from "@utils/global.ts"; import { STATES } from "@utils/global.ts";
import { ButtonStyle, createButton, createSvgIcon } from "@utils/html.ts"; import { createSvgIcon } from "@utils/html.ts";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { BxEvent, XcloudGuideWhere } from "@utils/bx-event.ts"; import { BxEvent } from "@utils/bx-event.ts";
import { t } from "@utils/translation.ts"; import { t } from "@utils/translation.ts";
import { StreamBadges } from "./stream-badges.ts"; import { StreamBadges } from "./stream-badges.ts";
import { StreamStats } from "./stream-stats.ts"; import { StreamStats } from "./stream-stats.ts";
@ -283,43 +283,3 @@ export function showStreamSettings(tabId: string) {
$parent.addEventListener('click', onClick); $parent.addEventListener('click', onClick);
} }
} }
export function setupStreamUiEvents() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_SHOWN, async e => {
const where = (e as any).where as XcloudGuideWhere;
if (where !== XcloudGuideWhere.HOME || !STATES.isPlaying) {
return;
}
const $btnQuit = document.querySelector('#gamepass-dialog-root a[class*=QuitGameButton]');
if (!$btnQuit) {
return;
}
// Add buttons
const $btnReload = createButton({
label: t('reload-stream'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('confirm-reload-stream')) && window.location.reload();
},
});
const $btnHome = createButton({
label: t('back-to-home'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31));
},
});
$btnQuit.insertAdjacentElement('afterend', $btnReload);
$btnReload.insertAdjacentElement('afterend', $btnHome);
// Hide xCloud's Home button
const $btnXcloudHome = document.querySelector('#gamepass-dialog-root div[class^=HomeButtonWithDivider]') as HTMLElement;
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
});
}

View File

@ -1,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, Translations } 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': {
@ -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');
} }
@ -375,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,10 +1,9 @@
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, Preferences, 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";
@ -12,6 +11,10 @@ import { VibrationManager } from "@modules/vibration-manager";
import { Screenshot } from "@/utils/screenshot"; import { Screenshot } from "@/utils/screenshot";
import { ControllerShortcut } from "../controller-shortcut"; import { ControllerShortcut } from "../controller-shortcut";
import { SoundShortcut } from "../shortcuts/shortcut-sound"; 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) {
@ -37,54 +40,8 @@ export function localRedirect(path: string) {
$anchor.click(); $anchor.click();
} }
function getVideoPlayerFilterStyle() {
const filters = [];
const clarity = getPref(PrefKey.VIDEO_CLARITY);
if (clarity != 0) {
const level = (7 - (clarity - 1) * 0.5).toFixed(1); // 5, 5.5, 6, 6.5, 7
const matrix = `0 -1 0 -1 ${level} -1 0 -1 0`;
document.getElementById('bx-filter-clarity-matrix')!.setAttributeNS(null, 'kernelMatrix', matrix);
filters.push(`url(#bx-filter-clarity)`);
}
const saturation = getPref(PrefKey.VIDEO_SATURATION);
if (saturation != 100) {
filters.push(`saturate(${saturation}%)`);
}
const contrast = getPref(PrefKey.VIDEO_CONTRAST);
if (contrast != 100) {
filters.push(`contrast(${contrast}%)`);
}
const brightness = getPref(PrefKey.VIDEO_BRIGHTNESS);
if (brightness != 100) {
filters.push(`brightness(${brightness}%)`);
}
return filters.join(' ');
}
function setupStreamSettingsDialog() { function setupStreamSettingsDialog() {
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',
@ -121,29 +78,38 @@ function setupStreamSettingsDialog() {
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,
onChange: updateVideoPlayerCss, onChange: onChangeVideoPlayerType,
}, },
{ {
pref: PrefKey.VIDEO_CLARITY, pref: PrefKey.VIDEO_RATIO,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
unsupported: isSafari, },
{
pref: PrefKey.VIDEO_PROCESSING,
onChange: updateVideoPlayer,
},
{
pref: PrefKey.VIDEO_SHARPNESS,
onChange: updateVideoPlayer,
}, },
{ {
pref: PrefKey.VIDEO_SATURATION, pref: PrefKey.VIDEO_SATURATION,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
}, },
{ {
pref: PrefKey.VIDEO_CONTRAST, pref: PrefKey.VIDEO_CONTRAST,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
}, },
{ {
pref: PrefKey.VIDEO_BRIGHTNESS, pref: PrefKey.VIDEO_BRIGHTNESS,
onChange: updateVideoPlayerCss, onChange: updateVideoPlayer,
}, },
], ],
}, },
@ -162,24 +128,24 @@ function setupStreamSettingsDialog() {
{ {
pref: PrefKey.CONTROLLER_ENABLE_VIBRATION, pref: PrefKey.CONTROLLER_ENABLE_VIBRATION,
unsupported: !VibrationManager.supportControllerVibration(), unsupported: !VibrationManager.supportControllerVibration(),
onChange: VibrationManager.updateGlobalVars, onChange: () => VibrationManager.updateGlobalVars(),
}, },
{ {
pref: PrefKey.CONTROLLER_DEVICE_VIBRATION, pref: PrefKey.CONTROLLER_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,
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: [
@ -241,6 +207,44 @@ function setupStreamSettingsDialog() {
], ],
}, },
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, icon: BxIcon.COMMAND,
group: 'shortcuts', group: 'shortcuts',
@ -410,94 +414,51 @@ function setupStreamSettingsDialog() {
} }
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.min(parentRect.width, Math.ceil(width));
height = Math.min(parentRect.height, Math.ceil(height));
// Update size
$video.style.width = `${width}px`;
$video.style.height = `${height}px`;
$video.style.objectFit = 'contain';
} else {
$video.style.width = '100%';
$video.style.height = '100%';
$video.style.objectFit = PREF_RATIO;
}
} }
@ -519,11 +480,11 @@ export function setupStreamUi() {
if (!document.querySelector('.bx-stream-settings-dialog')) { if (!document.querySelector('.bx-stream-settings-dialog')) {
preloadFonts(); preloadFonts();
window.addEventListener('resize', updateVideoPlayerCss); window.addEventListener('resize', updateVideoPlayer);
setupStreamSettingsDialog(); setupStreamSettingsDialog();
Screenshot.setup(); Screenshot.setup();
} }
updateVideoPlayerCss(); onChangeVideoPlayerType();
} }

View File

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

24
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,7 +37,7 @@ type BxStates = {
productId: string; productId: string;
titleInfo: XcloudTitleInfo; titleInfo: XcloudTitleInfo;
$video: HTMLVideoElement | null; streamPlayer: StreamPlayer | null;
peerConnection: RTCPeerConnection; peerConnection: RTCPeerConnection;
audioContext: AudioContext | null; audioContext: AudioContext | null;
@ -51,6 +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};
@ -60,6 +62,7 @@ type XcloudTitleInfo = {
productId: string; productId: string;
supportedInputTypes: InputType[]; supportedInputTypes: InputType[];
supportedTabs: any[]; supportedTabs: any[];
hasNativeTouchSupport: boolean;
hasTouchSupport: boolean; hasTouchSupport: boolean;
hasFakeTouchSupport: boolean; hasFakeTouchSupport: boolean;
hasMkbSupport: boolean; hasMkbSupport: boolean;
@ -76,22 +79,21 @@ declare module '*.js';
declare module '*.svg'; declare module '*.svg';
declare module '*.styl'; declare module '*.styl';
declare module '*.fs';
declare module '*.vert';
type MkbMouseMove = { type MkbMouseMove = {
movementX: number; movementX: number;
movementY: number; movementY: number;
} }
type MkbMouseClick = { type MkbMouseClick = {
key: { pointerButton?: number,
code: string; mouseButton?: number,
name: string; pressed: boolean,
} | null;
pressed: boolean;
} }
type MkbMouseWheel = { type MkbMouseWheel = {
key: { vertical: number;
code: string; horizontal: number;
name: string;
} | null;
} }

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

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

View File

@ -35,11 +35,14 @@ export enum BxEvent {
CAPTURE_SCREENSHOT = 'bx-capture-screenshot', CAPTURE_SCREENSHOT = 'bx-capture-screenshot',
GAINNODE_VOLUME_CHANGED = 'bx-gainnode-volume-changed', GAINNODE_VOLUME_CHANGED = 'bx-gainnode-volume-changed',
POINTER_LOCK_REQUESTED = 'bx-pointer-lock-requested',
POINTER_LOCK_EXITED = 'bx-pointer-lock-exited',
// xCloud Dialog events // xCloud Dialog events
XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown', XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown',
XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed', XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed',
XCLOUD_GUIDE_SHOWN = 'bx-xcloud-guide-shown', XCLOUD_GUIDE_MENU_SHOWN = 'bx-xcloud-guide-menu-shown',
XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed', XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed',
} }
@ -48,10 +51,6 @@ export enum XcloudEvent {
MICROPHONE_STATE_CHANGED = 'microphoneStateChanged', MICROPHONE_STATE_CHANGED = 'microphoneStateChanged',
} }
export enum XcloudGuideWhere {
HOME,
}
export namespace BxEvent { export namespace BxEvent {
export function dispatch(target: HTMLElement | Window, eventName: string, data?: any) { export function dispatch(target: HTMLElement | Window, eventName: string, data?: any) {
if (!eventName) { if (!eventName) {
@ -67,8 +66,8 @@ export namespace BxEvent {
} }
} }
AppInterface && AppInterface.onEvent(eventName);
target.dispatchEvent(event); target.dispatchEvent(event);
AppInterface && AppInterface.onEvent(eventName);
} }
} }

View File

@ -2,8 +2,8 @@ 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 { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags";
export enum InputType { export enum InputType {
CONTROLLER = 'Controller', CONTROLLER = 'Controller',
@ -23,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
@ -56,7 +60,8 @@ export const BxExposed = {
} }
// Pre-check supported input types // Pre-check supported input types
titleInfo.details.hasTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH) || titleInfo.details.hasNativeTouchSupport = supportedInputTypes.includes(InputType.NATIVE_TOUCH);
titleInfo.details.hasTouchSupport = titleInfo.details.hasNativeTouchSupport ||
supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) || supportedInputTypes.includes(InputType.CUSTOM_TOUCH_OVERLAY) ||
supportedInputTypes.includes(InputType.GENERIC_TOUCH); supportedInputTypes.includes(InputType.GENERIC_TOUCH);

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,12 @@ 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) {}

View File

@ -4,8 +4,7 @@ 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 iconHome from "@assets/svg/home.svg" with { type: "text" }; import iconHome from "@assets/svg/home.svg" with { type: "text" };
import iconMouseSettings from "@assets/svg/mouse-settings.svg" with { type: "text" }; import iconNativeMkb from "@assets/svg/native-mkb.svg" with { type: "text" };
import iconMouse from "@assets/svg/mouse.svg" with { type: "text" };
import iconNew from "@assets/svg/new.svg" with { type: "text" }; import iconNew from "@assets/svg/new.svg" with { type: "text" };
import iconQuestion from "@assets/svg/question.svg" with { type: "text" }; import iconQuestion from "@assets/svg/question.svg" with { type: "text" };
import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" }; import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" };
@ -15,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" };
@ -39,14 +39,14 @@ export const BxIcon = {
CONTROLLER: iconController, CONTROLLER: iconController,
DISPLAY: iconDisplay, DISPLAY: iconDisplay,
HOME: iconHome, HOME: iconHome,
MOUSE: iconMouse, NATIVE_MKB: iconNativeMkb,
MOUSE_SETTINGS: iconMouseSettings,
NEW: iconNew, NEW: iconNew,
COPY: iconCopy, COPY: iconCopy,
TRASH: iconTrash, TRASH: iconTrash,
CURSOR_TEXT: iconCursorText, CURSOR_TEXT: iconCursorText,
QUESTION: iconQuestion, QUESTION: iconQuestion,
REFRESH: iconRefresh, REFRESH: iconRefresh,
VIRTUAL_CONTROLLER: iconVirtualController,
REMOTE_PLAY: iconRemotePlay, REMOTE_PLAY: iconRemotePlay,

View File

@ -4,13 +4,17 @@ import { renderStylus } from "@macros/build" with {type: "macro"};
export function addCss() { export function addCss() {
let css = renderStylus(); const STYLUS_CSS = renderStylus();
let css = STYLUS_CSS;
// 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 +58,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,12 +108,13 @@ 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, Math.round(maxVideoBitrate / 1000));
} }
} catch (e) { } catch (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

@ -7,9 +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";
enum RequestType { enum RequestType {
XCLOUD = 'xcloud', XCLOUD = 'xcloud',
@ -439,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,
}); });
} }
@ -570,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);
@ -588,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();
@ -614,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 { AppInterface, 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]: {
@ -337,8 +344,6 @@ export class Preferences {
} else { } else {
return (value / (1024 * 1000)).toFixed(1) + ' Mb/s'; return (value / (1024 * 1000)).toFixed(1) + ' Mb/s';
} }
return null;
}, },
}, },
migrate: function(savedPrefs: any, value: any) { migrate: function(savedPrefs: any, value: any) {
@ -436,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]: {
@ -488,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]: {
@ -514,19 +574,39 @@ export class Preferences {
[UserAgentProfile.CUSTOM]: t('custom'), [UserAgentProfile.CUSTOM]: t('custom'),
}, },
}, },
[PrefKey.VIDEO_CLARITY]: { [PrefKey.VIDEO_PLAYER_TYPE]: {
label: t('clarity'), label: t('renderer'),
default: 'default',
options: {
[StreamPlayerType.VIDEO]: t('default'),
[StreamPlayerType.WEBGL2]: t('webgl2'),
},
},
[PrefKey.VIDEO_PROCESSING]: {
label: t('clarity-boost'),
default: StreamVideoProcessing.USM,
options: {
[StreamVideoProcessing.USM]: t('unsharp-masking'),
[StreamVideoProcessing.CAS]: t('amd-fidelity-cas'),
},
},
[PrefKey.VIDEO_SHARPNESS]: {
label: t('sharpness'),
type: SettingElementType.NUMBER_STEPPER, type: SettingElementType.NUMBER_STEPPER,
default: 0, default: 0,
min: 0, min: 0,
max: 5, max: 10,
params: { params: {
hideSlider: true, hideSlider: true,
customTextValue: (value: any) => {
value = parseInt(value);
return value === 0 ? t('off') : value.toString();
},
}, },
}, },
[PrefKey.VIDEO_RATIO]: { [PrefKey.VIDEO_RATIO]: {
label: t('ratio'), label: t('aspect-ratio'),
note: t('stretch-note'), note: t('aspect-ratio-note'),
default: '16:9', default: '16:9',
options: { options: {
'16:9': '16:9', '16:9': '16:9',

View File

@ -1,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,5 +1,7 @@
import { StreamPlayerType } from "@enums/stream-player";
import { AppInterface, STATES } from "./global"; import { AppInterface, STATES } from "./global";
import { CE } from "./html"; import { CE } from "./html";
import { getPref, PrefKey } from "./preferences";
export class Screenshot { export class Screenshot {
@ -31,23 +33,39 @@ export class Screenshot {
Screenshot.#canvasContext.filter = filters; Screenshot.#canvasContext.filter = filters;
} }
private static onAnimationEnd(e: Event) { static #onAnimationEnd(e: Event) {
(e.target as any).classList.remove('bx-taking-screenshot'); const $target = e.target as HTMLElement;
$target.classList.remove('bx-taking-screenshot');
} }
static takeScreenshot(callback?: any) { static takeScreenshot(callback?: any) {
const currentStream = STATES.currentStream; const currentStream = STATES.currentStream;
const $video = currentStream.$video; const streamPlayer = currentStream.streamPlayer;
const $canvas = Screenshot.#$canvas; const $canvas = Screenshot.#$canvas;
if (!$video || !$canvas) { if (!streamPlayer || !$canvas) {
return; return;
} }
$video.parentElement?.addEventListener('animationend', this.onAnimationEnd); let $player;
$video.parentElement?.classList.add('bx-taking-screenshot'); if (getPref(PrefKey.SCREENSHOT_APPLY_FILTERS)) {
$player = streamPlayer.getPlayerElement();
} else {
$player = streamPlayer.getPlayerElement(StreamPlayerType.VIDEO);
}
if (!$player || !$player.isConnected) {
return;
}
$player.parentElement!.addEventListener('animationend', this.#onAnimationEnd);
$player.parentElement!.classList.add('bx-taking-screenshot');
const canvasContext = Screenshot.#canvasContext; const canvasContext = Screenshot.#canvasContext;
canvasContext.drawImage($video, 0, 0, $canvas.width, $canvas.height);
if ($player instanceof HTMLCanvasElement) {
streamPlayer.getWebGL2Player().drawFrame();
}
canvasContext.drawImage($player, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app // Get data URL and pass to parent app
if (AppInterface) { if (AppInterface) {

View File

@ -132,10 +132,12 @@ export class SettingElement {
options.hideSlider = !!options.hideSlider; options.hideSlider = !!options.hideSlider;
let $text: HTMLSpanElement; let $text: HTMLSpanElement;
let $decBtn: HTMLButtonElement; let $btnDec: HTMLButtonElement;
let $incBtn: HTMLButtonElement; let $btnInc: HTMLButtonElement;
let $range: HTMLInputElement; let $range: HTMLInputElement;
let controlValue = value;
const MIN = setting.min!; const MIN = setting.min!;
const MAX = setting.max!; const MAX = setting.max!;
const STEPS = Math.max(setting.steps || 1, 1); const STEPS = Math.max(setting.steps || 1, 1);
@ -155,14 +157,19 @@ export class SettingElement {
return textContent; return textContent;
}; };
const $wrapper = CE('div', {'class': 'bx-number-stepper'}, const updateButtonsVisibility = () => {
$decBtn = CE('button', { $btnDec.classList.toggle('bx-hidden', controlValue === MIN);
$btnInc.classList.toggle('bx-hidden', controlValue === MAX);
}
const $wrapper = CE('div', {'class': 'bx-number-stepper', id: `bx_setting_${key}`},
$btnDec = CE('button', {
'data-type': 'dec', 'data-type': 'dec',
type: 'button', type: 'button',
tabindex: -1, tabindex: -1,
}, '-') as HTMLButtonElement, }, '-') as HTMLButtonElement,
$text = CE('span', {}, renderTextValue(value)) as HTMLSpanElement, $text = CE('span', {}, renderTextValue(value)) as HTMLSpanElement,
$incBtn = CE('button', { $btnInc = CE('button', {
'data-type': 'inc', 'data-type': 'inc',
type: 'button', type: 'button',
tabindex: -1, tabindex: -1,
@ -182,6 +189,9 @@ export class SettingElement {
$range.addEventListener('input', e => { $range.addEventListener('input', e => {
value = parseInt((e.target as HTMLInputElement).value); value = parseInt((e.target as HTMLInputElement).value);
controlValue = value;
updateButtonsVisibility();
$text.textContent = renderTextValue(value); $text.textContent = renderTextValue(value);
!(e as any).ignoreOnChange && onChange && onChange(e, value); !(e as any).ignoreOnChange && onChange && onChange(e, value);
}); });
@ -212,14 +222,16 @@ export class SettingElement {
} }
if (options.disabled) { if (options.disabled) {
$incBtn.disabled = true; $btnInc.disabled = true;
$incBtn.classList.add('bx-hidden'); $btnInc.classList.add('bx-hidden');
$decBtn.disabled = true; $btnDec.disabled = true;
$decBtn.classList.add('bx-hidden'); $btnDec.classList.add('bx-hidden');
return $wrapper; return $wrapper;
} }
updateButtonsVisibility();
let interval: number; let interval: number;
let isHolding = false; let isHolding = false;
@ -231,19 +243,19 @@ export class SettingElement {
return; return;
} }
let value: number; const $btn = e.target as HTMLElement;
if ($range) { let value = parseInt(controlValue);
value = parseInt($range.value);
} else { const btnType = $btn.dataset.type;
value = parseInt($text.textContent!);
}
const btnType = (e.target as HTMLElement).getAttribute('data-type');
if (btnType === 'dec') { if (btnType === 'dec') {
value = Math.max(MIN, value - STEPS); value = Math.max(MIN, value - STEPS);
} else { } else {
value = Math.min(MAX, value + STEPS); value = Math.min(MAX, value + STEPS);
} }
controlValue = value;
updateButtonsVisibility();
$text.textContent = renderTextValue(value); $text.textContent = renderTextValue(value);
$range && ($range.value = value.toString()); $range && ($range.value = value.toString());
@ -277,19 +289,21 @@ export class SettingElement {
// Custom method // Custom method
($wrapper as any).setValue = (value: any) => { ($wrapper as any).setValue = (value: any) => {
controlValue = parseInt(value);
$text.textContent = renderTextValue(value); $text.textContent = renderTextValue(value);
$range && ($range.value = value); $range && ($range.value = value);
}; };
$decBtn.addEventListener('click', onClick); $btnDec.addEventListener('click', onClick);
$decBtn.addEventListener('pointerdown', onMouseDown); $btnDec.addEventListener('pointerdown', onMouseDown);
$decBtn.addEventListener('pointerup', onMouseUp); $btnDec.addEventListener('pointerup', onMouseUp);
$decBtn.addEventListener('contextmenu', onContextMenu); $btnDec.addEventListener('contextmenu', onContextMenu);
$incBtn.addEventListener('click', onClick); $btnInc.addEventListener('click', onClick);
$incBtn.addEventListener('pointerdown', onMouseDown); $btnInc.addEventListener('pointerdown', onMouseDown);
$incBtn.addEventListener('pointerup', onMouseUp); $btnInc.addEventListener('pointerup', onMouseUp);
$incBtn.addEventListener('contextmenu', onContextMenu); $btnInc.addEventListener('contextmenu', onContextMenu);
return $wrapper; return $wrapper;
} }

View File

@ -1,11 +1,12 @@
import { NATIVE_FETCH } from "./bx-flags"; import { NATIVE_FETCH } from "./bx-flags";
import { BxLogger } from "./bx-logger";
export const SUPPORTED_LANGUAGES = { export const SUPPORTED_LANGUAGES = {
'en-US': 'English (United States)', 'en-US': 'English (United States)',
'ca-CA': 'Català', 'ca-CA': 'Català',
'en-ID': 'Bahasa Indonesia',
'de-DE': 'Deutsch', 'de-DE': 'Deutsch',
'en-ID': 'Bahasa Indonesia',
'es-ES': 'español (España)', 'es-ES': 'español (España)',
'fr-FR': 'français', 'fr-FR': 'français',
'it-IT': 'italiano', 'it-IT': 'italiano',
@ -26,8 +27,11 @@ const Texts = {
"activated": "Activated", "activated": "Activated",
"active": "Active", "active": "Active",
"advanced": "Advanced", "advanced": "Advanced",
"amd-fidelity-cas": "AMD FidelityFX CAS",
"android-app-settings": "Android app settings", "android-app-settings": "Android app settings",
"apply": "Apply", "apply": "Apply",
"aspect-ratio": "Aspect ratio",
"aspect-ratio-note": "Don't use with native touch games",
"audio": "Audio", "audio": "Audio",
"auto": "Auto", "auto": "Auto",
"back-to-home": "Back to home", "back-to-home": "Back to home",
@ -48,10 +52,11 @@ const Texts = {
"can-stream-xbox-360-games": "Can stream Xbox 360 games", "can-stream-xbox-360-games": "Can stream Xbox 360 games",
"cancel": "Cancel", "cancel": "Cancel",
"cant-stream-xbox-360-games": "Can't stream Xbox 360 games", "cant-stream-xbox-360-games": "Can't stream Xbox 360 games",
"clarity": "Clarity", "clarity-boost": "Clarity boost",
"clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON", "clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON",
"clear": "Clear", "clear": "Clear",
"close": "Close", "close": "Close",
"close-app": "Close app",
"combine-audio-video-streams": "Combine audio & video streams", "combine-audio-video-streams": "Combine audio & video streams",
"combine-audio-video-streams-summary": "May fix the laggy audio problem", "combine-audio-video-streams-summary": "May fix the laggy audio problem",
"conditional-formatting": "Conditional formatting text color", "conditional-formatting": "Conditional formatting text color",
@ -77,7 +82,6 @@ const Texts = {
"device-vibration-not-using-gamepad": "On when not using gamepad", "device-vibration-not-using-gamepad": "On when not using gamepad",
"disable": "Disable", "disable": "Disable",
"disable-home-context-menu": "Disable context menu in Home page", "disable-home-context-menu": "Disable context menu in Home page",
"disable-native-mkb": "Disable native Mouse & Keyboard feature",
"disable-post-stream-feedback-dialog": "Disable post-stream feedback dialog", "disable-post-stream-feedback-dialog": "Disable post-stream feedback dialog",
"disable-social-features": "Disable social features", "disable-social-features": "Disable social features",
"disable-xcloud-analytics": "Disable xCloud analytics", "disable-xcloud-analytics": "Disable xCloud analytics",
@ -106,7 +110,9 @@ const Texts = {
"hide-scrollbar": "Hide web page's scrollbar", "hide-scrollbar": "Hide web page's scrollbar",
"hide-system-menu-icon": "Hide System menu's icon", "hide-system-menu-icon": "Hide System menu's icon",
"hide-touch-controller": "Hide touch controller", "hide-touch-controller": "Hide touch controller",
"horizontal-scroll-sensitivity": "Horizontal scroll sensitivity",
"horizontal-sensitivity": "Horizontal sensitivity", "horizontal-sensitivity": "Horizontal sensitivity",
"ignore": "Ignore",
"import": "Import", "import": "Import",
"increase": "Increase", "increase": "Increase",
"install-android": "Install Better xCloud app for Android", "install-android": "Install Better xCloud app for Android",
@ -125,8 +131,10 @@ const Texts = {
"mkb-click-to-activate": "Click to activate", "mkb-click-to-activate": "Click to activate",
"mkb-disclaimer": "Using this feature when playing online could be viewed as cheating", "mkb-disclaimer": "Using this feature when playing online could be viewed as cheating",
"mouse-and-keyboard": "Mouse & Keyboard", "mouse-and-keyboard": "Mouse & Keyboard",
"mouse-wheel": "Mouse wheel",
"muted": "Muted", "muted": "Muted",
"name": "Name", "name": "Name",
"native-mkb": "Native Mouse & Keyboard",
"new": "New", "new": "New",
"no-consoles-found": "No consoles found", "no-consoles-found": "No consoles found",
"normal": "Normal", "normal": "Normal",
@ -144,32 +152,32 @@ const Texts = {
"preset": "Preset", "preset": "Preset",
"press-esc-to-cancel": "Press Esc to cancel", "press-esc-to-cancel": "Press Esc to cancel",
"press-key-to-toggle-mkb": [ "press-key-to-toggle-mkb": [
(e: any) => `Press ${e.key} to toggle the Mouse and Keyboard feature`, (e: any) => `Press ${e.key} to toggle this feature`,
(e: any) => `Premeu ${e.key} per alternar la funció de teclat i ratolí`, ,
(e: any) => `${e.key}: Maus- und Tastaturunterstützung an-/ausschalten`, (e: any) => `${e.key}: Funktion an-/ausschalten`,
(e: any) => `Tekan ${e.key} untuk mengaktifkan fitur Mouse dan Keyboard`, ,
(e: any) => `Pulsa ${e.key} para activar la función de ratón y teclado`, (e: any) => `Pulsa ${e.key} para alternar esta función`,
(e: any) => `Appuyez sur ${e.key} pour activer/désactiver la fonction Souris et Clavier`, (e: any) => `Appuyez sur ${e.key} pour activer cette fonctionnalité`,
(e: any) => `Premi ${e.key} per attivare o disattivare la funzione Mouse e Tastiera`, (e: any) => `Premi ${e.key} per attivare questa funzionalità`,
(e: any) => `${e.key} キーでマウスとキーボードの機能を切り替える`, (e: any) => `${e.key} でこの機能を切`,
(e: any) => `${e.key} 키를 눌러 마우스와 키보드 기능을 활성화 하십시오`, (e: any) => `${e.key} 키를 눌러 이 기능을 켜고 끄세요`,
(e: any) => `Naciśnij ${e.key}, aby przełączyć funkcję myszy i klawiatury`, (e: any) => `Naciśnij ${e.key} aby przełączyć funkcję`,
(e: any) => `Pressione ${e.key} para ativar/desativar a função de Mouse e Teclado`, ,
(e: any) => `Нажмите ${e.key} для переключения функции мыши и клавиатуры`, (e: any) => `Нажмите ${e.key} для переключения этой функции`,
,
(e: any) => `Etkinleştirmek için ${e.key} tuşuna basın`,
(e: any) => `Натисніть ${e.key} щоб перемкнути цю функцію`,
(e: any) => `Nhấn ${e.key} để bật/tắt tính năng này`,
, ,
(e: any) => `Klavye ve fare özelliğini açmak için ${e.key} tuşuna basın`,
(e: any) => `Натисніть ${e.key}, щоб увімкнути або вимкнути функцію миші та клавіатури`,
(e: any) => `Nhấn ${e.key} để bật/tắt tính năng Chuột và Bàn phím`,
(e: any) => `按下 ${e.key} 切换键鼠模式`,
], ],
"press-to-bind": "Press a key or do a mouse click to bind...", "press-to-bind": "Press a key or do a mouse click to bind...",
"prompt-preset-name": "Preset's name:", "prompt-preset-name": "Preset's name:",
"ratio": "Ratio",
"reduce-animations": "Reduce UI animations", "reduce-animations": "Reduce UI animations",
"region": "Region", "region": "Region",
"reload-stream": "Reload stream", "reload-stream": "Reload stream",
"remote-play": "Remote Play", "remote-play": "Remote Play",
"rename": "Rename", "rename": "Rename",
"renderer": "Renderer",
"right-click-to-unbind": "Right-click on a key to unbind it", "right-click-to-unbind": "Right-click on a key to unbind it",
"right-stick": "Right stick", "right-stick": "Right stick",
"rocket-always-hide": "Always hide", "rocket-always-hide": "Always hide",
@ -187,6 +195,7 @@ const Texts = {
"settings": "Settings", "settings": "Settings",
"settings-reload": "Reload page to reflect changes", "settings-reload": "Reload page to reflect changes",
"settings-reloading": "Reloading...", "settings-reloading": "Reloading...",
"sharpness": "Sharpness",
"shortcut-keys": "Shortcut keys", "shortcut-keys": "Shortcut keys",
"show": "Show", "show": "Show",
"show-game-art": "Show game art", "show-game-art": "Show game art",
@ -214,7 +223,6 @@ const Texts = {
"stream-settings": "Stream settings", "stream-settings": "Stream settings",
"stream-stats": "Stream stats", "stream-stats": "Stream stats",
"stretch": "Stretch", "stretch": "Stretch",
"stretch-note": "Don't use with native touch games",
"support-better-xcloud": "Support Better xCloud", "support-better-xcloud": "Support Better xCloud",
"swap-buttons": "Swap buttons", "swap-buttons": "Swap buttons",
"take-screenshot": "Take screenshot", "take-screenshot": "Take screenshot",
@ -259,12 +267,15 @@ const Texts = {
"unknown": "Unknown", "unknown": "Unknown",
"unlimited": "Unlimited", "unlimited": "Unlimited",
"unmuted": "Unmuted", "unmuted": "Unmuted",
"unsharp-masking": "Unsharp masking",
"use-mouse-absolute-position": "Use mouse's absolute position", "use-mouse-absolute-position": "Use mouse's absolute position",
"user-agent-profile": "User-Agent profile", "user-agent-profile": "User-Agent profile",
"vertical-scroll-sensitivity": "Vertical scroll sensitivity",
"vertical-sensitivity": "Vertical sensitivity", "vertical-sensitivity": "Vertical sensitivity",
"vibration-intensity": "Vibration intensity", "vibration-intensity": "Vibration intensity",
"vibration-status": "Vibration", "vibration-status": "Vibration",
"video": "Video", "video": "Video",
"virtual-controller": "Virtual controller",
"visual-quality": "Visual quality", "visual-quality": "Visual quality",
"visual-quality-high": "High", "visual-quality-high": "High",
"visual-quality-low": "Low", "visual-quality-low": "Low",
@ -272,6 +283,7 @@ const Texts = {
"volume": "Volume", "volume": "Volume",
"wait-time-countdown": "Countdown", "wait-time-countdown": "Countdown",
"wait-time-estimated": "Estimated finish time", "wait-time-estimated": "Estimated finish time",
"webgl2": "WebGL2",
}; };
export class Translations { export class Translations {
@ -384,5 +396,9 @@ export class Translations {
} }
export const t = Translations.get; export const t = Translations.get;
export const ut = (text: string): string => {
BxLogger.warning('Untranslated text', text);
return text;
}
Translations.init(); Translations.init();

View File

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

@ -7,6 +7,11 @@ import { Translations } from "./translation";
* Check for update * Check for update
*/ */
export function checkForUpdate() { export function checkForUpdate() {
// Don't check update for beta version
if (SCRIPT_VERSION.includes('beta')) {
return;
}
const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours const CHECK_INTERVAL_SECONDS = 2 * 3600; // check every 2 hours
const currentVersion = getPref(PrefKey.CURRENT_VERSION); const currentVersion = getPref(PrefKey.CURRENT_VERSION);
@ -42,7 +47,7 @@ export function disablePwa() {
} }
// Check if it's Safari on mobile // Check if it's Safari on mobile
if (!!AppInterface || UserAgent.isSafari(true)) { if (!!AppInterface || UserAgent.isSafariMobile()) {
// Disable the PWA prompt // Disable the PWA prompt
Object.defineProperty(window.navigator, 'standalone', { Object.defineProperty(window.navigator, 'standalone', {
value: true, value: true,

View File

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