mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-06-03 06:07:19 +02:00
Initial commit
This commit is contained in:
commit
0febae28da
175
.gitignore
vendored
Normal file
175
.gitignore
vendored
Normal file
@ -0,0 +1,175 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Caches
|
||||
|
||||
.cache
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
15
README.md
Normal file
15
README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# better-xcloud
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run src/index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.1.4. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
11
package.json
Normal file
11
package.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "better-xcloud",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
102
src/index.ts
Normal file
102
src/index.ts
Normal file
@ -0,0 +1,102 @@
|
||||
// ==UserScript==
|
||||
// @name Better xCloud
|
||||
// @namespace https://github.com/redphx
|
||||
// @version 3.5.3
|
||||
// @description Improve Xbox Cloud Gaming (xCloud) experience
|
||||
// @author redphx
|
||||
// @license MIT
|
||||
// @match https://www.xbox.com/*/play*
|
||||
// @match https://www.xbox.com/*/auth/msa?*loggedIn*
|
||||
// @run-at document-start
|
||||
// @grant none
|
||||
// @updateURL https://raw.githubusercontent.com/redphx/better-xcloud/main/better-xcloud.meta.js
|
||||
// @downloadURL https://github.com/redphx/better-xcloud/releases/latest/download/better-xcloud.user.js
|
||||
// ==/UserScript==
|
||||
'use strict';
|
||||
|
||||
import { BxEvent } from "./modules/bx-event";
|
||||
import { BX_FLAGS } from "./modules/bx-flags";
|
||||
import { CE, CTN, createButton, createSvgIcon, Icon } from "./utils/html";
|
||||
import { BxExposed } from "./modules/bx-exposed";
|
||||
import { t } from "./modules/translation";
|
||||
import { Dialog } from "./modules/dialog";
|
||||
|
||||
const SCRIPT_VERSION = '3.5.3';
|
||||
const SCRIPT_HOME = 'https://github.com/redphx/better-xcloud';
|
||||
|
||||
const AppInterface = window.AppInterface;
|
||||
const States: BxStates = {
|
||||
isPlaying: false,
|
||||
appContext: {},
|
||||
};
|
||||
|
||||
|
||||
/* ADDITIONAL CODE */
|
||||
|
||||
// Handle login page
|
||||
if (window.location.pathname.includes('/auth/msa')) {
|
||||
window.addEventListener('load', e => {
|
||||
window.location.search.includes('loggedIn') && setTimeout(() => {
|
||||
const location = window.location;
|
||||
// @ts-ignore
|
||||
location.pathname.includes('/play') && location.reload(true);
|
||||
}, 2000);
|
||||
});
|
||||
// Stop processing the script
|
||||
throw new Error('[Better xCloud] Refreshing the page after logging in');
|
||||
}
|
||||
|
||||
console.log(`[Better xCloud] readyState: ${document.readyState}`);
|
||||
|
||||
console.log(BxEvent.CUSTOM_TOUCH_LAYOUTS_LOADED)
|
||||
console.log(BX_FLAGS)
|
||||
|
||||
if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') {
|
||||
// Stop loading
|
||||
window.stop();
|
||||
|
||||
// Show the reloading overlay
|
||||
const css = `
|
||||
.bx-reload-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
background: #000000cc;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
line-height: 100vh;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
`;
|
||||
const $fragment = document.createDocumentFragment();
|
||||
$fragment.appendChild(CE('style', {}, css));
|
||||
$fragment.appendChild(CE('div', {'class': 'bx-reload-overlay'}, t('safari-failed-message')));
|
||||
|
||||
document.documentElement.appendChild($fragment);
|
||||
|
||||
// Reload the page
|
||||
// @ts-ignore
|
||||
window.location.reload(true);
|
||||
|
||||
// Stop processing the script
|
||||
throw new Error('[Better xCloud] Executing workaround for Safari');
|
||||
}
|
||||
|
||||
// Automatically reload the page when running into the "We are sorry..." error message
|
||||
window.addEventListener('load', e => {
|
||||
setTimeout(() => {
|
||||
if (document.body.classList.contains('legacyBackground')) {
|
||||
// Has error message -> reload page
|
||||
window.stop();
|
||||
// @ts-ignore
|
||||
window.location.reload(true);
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
window.BX_EXPOSED = BxExposed;
|
||||
|
||||
new Dialog({})
|
44
src/modules/bx-event.ts
Normal file
44
src/modules/bx-event.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export enum BxEvent {
|
||||
JUMP_BACK_IN_READY = 'bx-jump-back-in-ready',
|
||||
POPSTATE = 'bx-popstate',
|
||||
|
||||
STREAM_LOADING = 'bx-stream-loading',
|
||||
STREAM_STARTING = 'bx-stream-starting',
|
||||
STREAM_STARTED = 'bx-stream-started',
|
||||
STREAM_PLAYING = 'bx-stream-playing',
|
||||
STREAM_STOPPED = 'bx-stream-stopped',
|
||||
STREAM_ERROR_PAGE = 'bx-stream-error-page',
|
||||
|
||||
STREAM_MENU_SHOWN = 'bx-stream-menu-shown',
|
||||
STREAM_MENU_HIDDEN = 'bx-stream-menu-hidden',
|
||||
|
||||
STREAM_WEBRTC_CONNECTED = 'bx-stream-webrtc-connected',
|
||||
STREAM_WEBRTC_DISCONNECTED = 'bx-stream-webrtc-disconnected',
|
||||
|
||||
CUSTOM_TOUCH_LAYOUTS_LOADED = 'bx-custom-touch-layouts-loaded',
|
||||
|
||||
REMOTE_PLAY_READY = 'bx-remote-play-ready',
|
||||
REMOTE_PLAY_FAILED = 'bx-remote-play-failed',
|
||||
|
||||
DATA_CHANNEL_CREATED = 'bx-data-channel-created',
|
||||
}
|
||||
|
||||
export namespace BxEvent {
|
||||
export function dispatch(target: HTMLElement | Window, eventName: string, data: any) {
|
||||
if (!eventName) {
|
||||
alert('BxEvent.dispatch(): eventName is null');
|
||||
return;
|
||||
}
|
||||
|
||||
const event = new Event(eventName);
|
||||
|
||||
if (data) {
|
||||
for (const key in data) {
|
||||
(event as any)[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
AppInterface && AppInterface.onEvent(eventName);
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
}
|
26
src/modules/bx-exposed.ts
Normal file
26
src/modules/bx-exposed.ts
Normal file
@ -0,0 +1,26 @@
|
||||
declare var States: BxStates;
|
||||
|
||||
export const BxExposed = {
|
||||
onPollingModeChanged: (mode: 'All' | 'None') => {
|
||||
if (!States.isPlaying) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $screenshotBtn = document.querySelector('.bx-screenshot-button');
|
||||
const $touchControllerBar = document.getElementById('bx-touch-controller-bar');
|
||||
|
||||
if (mode !== 'None') {
|
||||
// Hide screenshot button
|
||||
$screenshotBtn && $screenshotBtn.classList.add('bx-gone');
|
||||
|
||||
// Hide touch controller bar
|
||||
$touchControllerBar && $touchControllerBar.classList.add('bx-gone');
|
||||
} else {
|
||||
// Show screenshot button
|
||||
$screenshotBtn && $screenshotBtn.classList.remove('bx-gone');
|
||||
|
||||
// Show touch controller bar
|
||||
$touchControllerBar && $touchControllerBar.classList.remove('bx-gone');
|
||||
}
|
||||
},
|
||||
};
|
25
src/modules/bx-flags.ts
Normal file
25
src/modules/bx-flags.ts
Normal file
@ -0,0 +1,25 @@
|
||||
type BxFlags = {
|
||||
CheckForUpdate?: boolean;
|
||||
PreloadRemotePlay?: boolean;
|
||||
PreloadUi?: boolean;
|
||||
EnableXcloudLogging?: boolean;
|
||||
SafariWorkaround?: boolean;
|
||||
|
||||
UseDevTouchLayout?: boolean;
|
||||
}
|
||||
|
||||
// Setup flags
|
||||
const DEFAULT_FLAGS: BxFlags = {
|
||||
CheckForUpdate: true,
|
||||
PreloadRemotePlay: true,
|
||||
PreloadUi: false,
|
||||
EnableXcloudLogging: false,
|
||||
SafariWorkaround: true,
|
||||
|
||||
UseDevTouchLayout: false,
|
||||
}
|
||||
|
||||
const BX_FLAGS = Object.assign(DEFAULT_FLAGS, window.BX_FLAGS || {});
|
||||
delete window.BX_FLAGS;
|
||||
|
||||
export { BX_FLAGS }
|
98
src/modules/dialog.ts
Normal file
98
src/modules/dialog.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { t } from "./translation";
|
||||
import { CE, createButton, ButtonStyle, Icon } from "../utils/html";
|
||||
|
||||
type DialogOptions = {
|
||||
title?: string;
|
||||
className?: string;
|
||||
content?: string | HTMLElement;
|
||||
hideCloseButton?: boolean;
|
||||
onClose?: string;
|
||||
helpUrl?: string;
|
||||
}
|
||||
|
||||
export class Dialog {
|
||||
$dialog?: HTMLElement;
|
||||
$title?: HTMLElement;
|
||||
$content?: HTMLElement;
|
||||
$overlay?: Element | null;
|
||||
|
||||
onClose: any;
|
||||
|
||||
constructor(options: DialogOptions) {
|
||||
const {
|
||||
title,
|
||||
className,
|
||||
content,
|
||||
hideCloseButton,
|
||||
onClose,
|
||||
helpUrl,
|
||||
} = options;
|
||||
|
||||
// Create dialog overlay
|
||||
this.$overlay = document.querySelector('.bx-dialog-overlay');
|
||||
if (!this.$overlay) {
|
||||
this.$overlay = CE('div', {'class': 'bx-dialog-overlay bx-gone'});
|
||||
|
||||
// Disable right click
|
||||
this.$overlay.addEventListener('contextmenu', e => e.preventDefault());
|
||||
|
||||
document.documentElement.appendChild(this.$overlay);
|
||||
}
|
||||
|
||||
let $close;
|
||||
this.onClose = onClose;
|
||||
this.$dialog = CE('div', {'class': `bx-dialog ${className || ''} bx-gone`},
|
||||
this.$title = CE('h2', {}, CE('b', {}, title),
|
||||
helpUrl && createButton({
|
||||
icon: Icon.QUESTION,
|
||||
style: (ButtonStyle.GHOST as number),
|
||||
title: t('help'),
|
||||
url: helpUrl,
|
||||
}),
|
||||
),
|
||||
this.$content = CE('div', {'class': 'bx-dialog-content'}, content),
|
||||
!hideCloseButton && ($close = CE('button', {}, t('close'))),
|
||||
);
|
||||
|
||||
$close && $close.addEventListener('click', e => {
|
||||
this.hide(e);
|
||||
});
|
||||
|
||||
!title && this.$title.classList.add('bx-gone');
|
||||
!content && this.$content.classList.add('bx-gone');
|
||||
|
||||
// Disable right click
|
||||
this.$dialog.addEventListener('contextmenu', e => e.preventDefault());
|
||||
|
||||
document.documentElement.appendChild(this.$dialog);
|
||||
}
|
||||
|
||||
show(newOptions: DialogOptions) {
|
||||
// Clear focus
|
||||
document.activeElement && (document.activeElement as HTMLElement).blur();
|
||||
|
||||
if (newOptions && newOptions.title) {
|
||||
this.$title!.querySelector('b')!.textContent = newOptions.title;
|
||||
this.$title!.classList.remove('bx-gone');
|
||||
}
|
||||
|
||||
this.$dialog!.classList.remove('bx-gone');
|
||||
this.$overlay!.classList.remove('bx-gone');
|
||||
|
||||
document.body.classList.add('bx-no-scroll');
|
||||
}
|
||||
|
||||
hide(e?: any) {
|
||||
this.$dialog!.classList.add('bx-gone');
|
||||
this.$overlay!.classList.add('bx-gone');
|
||||
|
||||
document.body.classList.remove('bx-no-scroll');
|
||||
|
||||
this.onClose && this.onClose(e);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.$dialog!.classList.toggle('bx-gone');
|
||||
this.$overlay!.classList.toggle('bx-gone');
|
||||
}
|
||||
}
|
98
src/modules/mkb/definitions.ts
Normal file
98
src/modules/mkb/definitions.ts
Normal file
@ -0,0 +1,98 @@
|
||||
export const GamepadKey: {[index: string | number]: string | number} = {};
|
||||
GamepadKey[GamepadKey.A = 0] = 'A';
|
||||
GamepadKey[GamepadKey.B = 1] = 'B';
|
||||
GamepadKey[GamepadKey.X = 2] = 'X';
|
||||
GamepadKey[GamepadKey.Y = 3] = 'Y';
|
||||
GamepadKey[GamepadKey.LB = 4] = 'LB';
|
||||
GamepadKey[GamepadKey.RB = 5] = 'RB';
|
||||
GamepadKey[GamepadKey.LT = 6] = 'LT';
|
||||
GamepadKey[GamepadKey.RT = 7] = 'RT';
|
||||
GamepadKey[GamepadKey.SELECT = 8] = 'SELECT';
|
||||
GamepadKey[GamepadKey.START = 9] = 'START';
|
||||
GamepadKey[GamepadKey.L3 = 10] = 'L3';
|
||||
GamepadKey[GamepadKey.R3 = 11] = 'R3';
|
||||
GamepadKey[GamepadKey.UP = 12] = 'UP';
|
||||
GamepadKey[GamepadKey.DOWN = 13] = 'DOWN';
|
||||
GamepadKey[GamepadKey.LEFT = 14] = 'LEFT';
|
||||
GamepadKey[GamepadKey.RIGHT = 15] = 'RIGHT';
|
||||
GamepadKey[GamepadKey.HOME = 16] = 'HOME';
|
||||
|
||||
GamepadKey[GamepadKey.LS_UP = 100] = 'LS_UP';
|
||||
GamepadKey[GamepadKey.LS_DOWN = 101] = 'LS_DOWN';
|
||||
GamepadKey[GamepadKey.LS_LEFT = 102] = 'LS_LEFT';
|
||||
GamepadKey[GamepadKey.LS_RIGHT = 103] = 'LS_RIGHT';
|
||||
GamepadKey[GamepadKey.RS_UP = 200] = 'RS_UP';
|
||||
GamepadKey[GamepadKey.RS_DOWN = 201] = 'RS_DOWN';
|
||||
GamepadKey[GamepadKey.RS_LEFT = 202] = 'RS_LEFT';
|
||||
GamepadKey[GamepadKey.RS_RIGHT = 203] = 'RS_RIGHT';
|
||||
|
||||
|
||||
export const GamepadKeyName: {[index: string | number]: string[]} = {
|
||||
[GamepadKey.A]: ['A', '⇓'],
|
||||
[GamepadKey.B]: ['B', '⇒'],
|
||||
[GamepadKey.X]: ['X', '⇐'],
|
||||
[GamepadKey.Y]: ['Y', '⇑'],
|
||||
|
||||
[GamepadKey.LB]: ['LB', '↘'],
|
||||
[GamepadKey.RB]: ['RB', '↙'],
|
||||
[GamepadKey.LT]: ['LT', '↖'],
|
||||
[GamepadKey.RT]: ['RT', '↗'],
|
||||
|
||||
[GamepadKey.SELECT]: ['Select', '⇺'],
|
||||
[GamepadKey.START]: ['Start', '⇻'],
|
||||
[GamepadKey.HOME]: ['Home', ''],
|
||||
|
||||
[GamepadKey.UP]: ['D-Pad Up', '≻'],
|
||||
[GamepadKey.DOWN]: ['D-Pad Down', '≽'],
|
||||
[GamepadKey.LEFT]: ['D-Pad Left', '≺'],
|
||||
[GamepadKey.RIGHT]: ['D-Pad Right', '≼'],
|
||||
|
||||
[GamepadKey.L3]: ['L3', '↺'],
|
||||
[GamepadKey.LS_UP]: ['Left Stick Up', '↾'],
|
||||
[GamepadKey.LS_DOWN]: ['Left Stick Down', '⇂'],
|
||||
[GamepadKey.LS_LEFT]: ['Left Stick Left', '↼'],
|
||||
[GamepadKey.LS_RIGHT]: ['Left Stick Right', '⇀'],
|
||||
|
||||
[GamepadKey.R3]: ['R3', '↻'],
|
||||
[GamepadKey.RS_UP]: ['Right Stick Up', '↿'],
|
||||
[GamepadKey.RS_DOWN]: ['Right Stick Down', '⇃'],
|
||||
[GamepadKey.RS_LEFT]: ['Right Stick Left', '↽'],
|
||||
[GamepadKey.RS_RIGHT]: ['Right Stick Right', '⇁'],
|
||||
};
|
||||
|
||||
|
||||
export enum GamepadStick {
|
||||
LEFT = 0,
|
||||
RIGHT = 1,
|
||||
};
|
||||
|
||||
export enum MouseButtonCode {
|
||||
LEFT_CLICK = 'Mouse0',
|
||||
RIGHT_CLICK = 'Mouse2',
|
||||
MIDDLE_CLICK = 'Mouse1',
|
||||
};
|
||||
|
||||
export const MouseMapTo: {[index: string | number]: string | number} = {};
|
||||
MouseMapTo[MouseMapTo.OFF = 0] = 'OFF';
|
||||
MouseMapTo[MouseMapTo.LS = 1] = 'LS';
|
||||
MouseMapTo[MouseMapTo.RS = 2] = 'RS';
|
||||
|
||||
|
||||
export enum WheelCode {
|
||||
SCROLL_UP = 'ScrollUp',
|
||||
SCROLL_DOWN = 'ScrollDown',
|
||||
SCROLL_LEFT = 'ScrollLeft',
|
||||
SCROLL_RIGHT = 'ScrollRight',
|
||||
};
|
||||
|
||||
export enum MkbPresetKey {
|
||||
MOUSE_MAP_TO = 'map_to',
|
||||
|
||||
MOUSE_SENSITIVITY_X = 'sensitivity_x',
|
||||
MOUSE_SENSITIVITY_Y = 'sensitivity_y',
|
||||
|
||||
MOUSE_DEADZONE_COUNTERWEIGHT = 'deadzone_counterweight',
|
||||
|
||||
MOUSE_STICK_DECAY_STRENGTH = 'stick_decay_strength',
|
||||
MOUSE_STICK_DECAY_MIN = 'stick_decay_min',
|
||||
}
|
67
src/modules/mkb/key-helper.ts
Normal file
67
src/modules/mkb/key-helper.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { MouseButtonCode, WheelCode } from "./definitions";
|
||||
|
||||
export class KeyHelper {
|
||||
static #NON_PRINTABLE_KEYS = {
|
||||
'Backquote': '`',
|
||||
|
||||
// Mouse buttons
|
||||
[MouseButtonCode.LEFT_CLICK]: 'Left Click',
|
||||
[MouseButtonCode.RIGHT_CLICK]: 'Right Click',
|
||||
[MouseButtonCode.MIDDLE_CLICK]: 'Middle Click',
|
||||
|
||||
[WheelCode.SCROLL_UP]: 'Scroll Up',
|
||||
[WheelCode.SCROLL_DOWN]: 'Scroll Down',
|
||||
[WheelCode.SCROLL_LEFT]: 'Scroll Left',
|
||||
[WheelCode.SCROLL_RIGHT]: 'Scroll Right',
|
||||
};
|
||||
|
||||
static getKeyFromEvent(e: Event) {
|
||||
let code;
|
||||
let name;
|
||||
|
||||
if (e instanceof KeyboardEvent) {
|
||||
code = e.code;
|
||||
} else if (e instanceof MouseEvent) {
|
||||
code = 'Mouse' + e.button;
|
||||
} else if (e instanceof WheelEvent) {
|
||||
if (e.deltaY < 0) {
|
||||
code = WheelCode.SCROLL_UP;
|
||||
} else if (e.deltaY > 0) {
|
||||
code = WheelCode.SCROLL_DOWN;
|
||||
} else if (e.deltaX < 0) {
|
||||
code = WheelCode.SCROLL_LEFT;
|
||||
} else {
|
||||
code = WheelCode.SCROLL_RIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
if (code) {
|
||||
name = KeyHelper.codeToKeyName(code);
|
||||
}
|
||||
|
||||
return code ? {code, name} : null;
|
||||
}
|
||||
|
||||
static codeToKeyName(code: string) {
|
||||
return (
|
||||
// @ts-ignore
|
||||
KeyHelper.#NON_PRINTABLE_KEYS[code]
|
||||
||
|
||||
(code.startsWith('Key') && code.substring(3))
|
||||
||
|
||||
(code.startsWith('Digit') && code.substring(5))
|
||||
||
|
||||
(code.startsWith('Numpad') && ('Numpad ' + code.substring(6)))
|
||||
||
|
||||
(code.startsWith('Arrow') && ('Arrow ' + code.substring(5)))
|
||||
||
|
||||
(code.endsWith('Lock') && (code.replace('Lock', ' Lock')))
|
||||
||
|
||||
(code.endsWith('Left') && ('Left ' + code.replace('Left', '')))
|
||||
||
|
||||
(code.endsWith('Right') && ('Right ' + code.replace('Right', '')))
|
||||
||
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
467
src/modules/mkb/mkb-handler.ts
Normal file
467
src/modules/mkb/mkb-handler.ts
Normal file
@ -0,0 +1,467 @@
|
||||
import { MkbPreset } from "./mkb-preset";
|
||||
import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo } from "./definitions";
|
||||
import { createButton, Icon, ButtonStyle, CE } from "../../utils/html";
|
||||
import { BxEvent } from "../bx-event";
|
||||
import { PrefKey, getPref } from "../preferences";
|
||||
import { Toast } from "../../utils/toast";
|
||||
import { t } from "../translation";
|
||||
import { LocalDb } from "../../utils/local-db";
|
||||
import { KeyHelper } from "./key-helper";
|
||||
|
||||
/*
|
||||
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
|
||||
*/
|
||||
export class MkbHandler {
|
||||
static #instance: MkbHandler;
|
||||
static get INSTANCE() {
|
||||
if (!MkbHandler.#instance) {
|
||||
MkbHandler.#instance = new MkbHandler();
|
||||
}
|
||||
|
||||
return MkbHandler.#instance;
|
||||
}
|
||||
|
||||
#CURRENT_PRESET_DATA = MkbPreset.convert(MkbPreset.DEFAULT_PRESET);
|
||||
|
||||
static get DEFAULT_PANNING_SENSITIVITY() { return 0.0010; }
|
||||
static get DEFAULT_STICK_SENSITIVITY() { return 0.0006; }
|
||||
static get DEFAULT_DEADZONE_COUNTERWEIGHT() { return 0.01; }
|
||||
static get MAXIMUM_STICK_RANGE() { return 1.1; }
|
||||
|
||||
static VIRTUAL_GAMEPAD_ID = 'Xbox 360 Controller';
|
||||
|
||||
#VIRTUAL_GAMEPAD = {
|
||||
id: MkbHandler.VIRTUAL_GAMEPAD_ID,
|
||||
index: 3,
|
||||
connected: false,
|
||||
hapticActuators: null,
|
||||
mapping: 'standard',
|
||||
|
||||
axes: [0, 0, 0, 0],
|
||||
buttons: new Array(17).fill(null).map(() => ({pressed: false, value: 0})),
|
||||
timestamp: performance.now(),
|
||||
|
||||
vibrationActuator: null,
|
||||
};
|
||||
#nativeGetGamepads = window.navigator.getGamepads.bind(window.navigator);
|
||||
|
||||
#enabled = false;
|
||||
|
||||
#prevWheelCode = null;
|
||||
#wheelStoppedTimeout?: number | null;
|
||||
|
||||
#detectMouseStoppedTimeout?: number | null;
|
||||
#allowStickDecaying = false;
|
||||
|
||||
#$message?: HTMLElement;
|
||||
|
||||
#STICK_MAP: {[index: keyof typeof GamepadKey]: (number | number[])[]};
|
||||
#LEFT_STICK_X: number[] = [];
|
||||
#LEFT_STICK_Y: number[] = [];
|
||||
#RIGHT_STICK_X: number[] = [];
|
||||
#RIGHT_STICK_Y: number[] = [];
|
||||
|
||||
constructor() {
|
||||
this.#STICK_MAP = {
|
||||
[GamepadKey.LS_LEFT]: [this.#LEFT_STICK_X, 0, -1],
|
||||
[GamepadKey.LS_RIGHT]: [this.#LEFT_STICK_X, 0, 1],
|
||||
[GamepadKey.LS_UP]: [this.#LEFT_STICK_Y, 1, -1],
|
||||
[GamepadKey.LS_DOWN]: [this.#LEFT_STICK_Y, 1, 1],
|
||||
|
||||
[GamepadKey.RS_LEFT]: [this.#RIGHT_STICK_X, 2, -1],
|
||||
[GamepadKey.RS_RIGHT]: [this.#RIGHT_STICK_X, 2, 1],
|
||||
[GamepadKey.RS_UP]: [this.#RIGHT_STICK_Y, 3, -1],
|
||||
[GamepadKey.RS_DOWN]: [this.#RIGHT_STICK_Y, 3, 1],
|
||||
};
|
||||
}
|
||||
|
||||
#patchedGetGamepads = () => {
|
||||
const gamepads = this.#nativeGetGamepads() || [];
|
||||
(gamepads as any)[this.#VIRTUAL_GAMEPAD.index] = this.#VIRTUAL_GAMEPAD;
|
||||
|
||||
return gamepads;
|
||||
}
|
||||
|
||||
#getVirtualGamepad = () => this.#VIRTUAL_GAMEPAD;
|
||||
|
||||
#updateStick(stick: GamepadStick, x: number, y: number) {
|
||||
const virtualGamepad = this.#getVirtualGamepad();
|
||||
virtualGamepad.axes[stick * 2] = x;
|
||||
virtualGamepad.axes[stick * 2 + 1] = y;
|
||||
|
||||
virtualGamepad.timestamp = performance.now();
|
||||
}
|
||||
|
||||
#getStickAxes(stick: GamepadStick) {
|
||||
const virtualGamepad = this.#getVirtualGamepad();
|
||||
return {
|
||||
x: virtualGamepad.axes[stick * 2],
|
||||
y: virtualGamepad.axes[stick * 2 + 1],
|
||||
};
|
||||
}
|
||||
|
||||
#vectorLength = (x: number, y: number): number => Math.sqrt(x ** 2 + y ** 2);
|
||||
|
||||
#disableContextMenu = (e: Event) => e.preventDefault();
|
||||
|
||||
#resetGamepad = () => {
|
||||
const gamepad = this.#getVirtualGamepad();
|
||||
|
||||
// Reset axes
|
||||
gamepad.axes = [0, 0, 0, 0];
|
||||
|
||||
// Reset buttons
|
||||
for (const button of gamepad.buttons) {
|
||||
button.pressed = false;
|
||||
button.value = 0;
|
||||
}
|
||||
|
||||
gamepad.timestamp = performance.now();
|
||||
}
|
||||
|
||||
#pressButton = (buttonIndex: number, pressed: boolean) => {
|
||||
const virtualGamepad = this.#getVirtualGamepad();
|
||||
|
||||
if (buttonIndex >= 100) {
|
||||
let [valueArr, axisIndex, fullValue] = this.#STICK_MAP[buttonIndex];
|
||||
valueArr = valueArr as number[];
|
||||
axisIndex = axisIndex as number;
|
||||
|
||||
// Remove old index of the array
|
||||
for (let i = valueArr.length - 1; i >= 0; i--) {
|
||||
if (valueArr[i] === buttonIndex) {
|
||||
valueArr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
pressed && valueArr.push(buttonIndex);
|
||||
|
||||
let value;
|
||||
if (valueArr.length) {
|
||||
// Get value of the last key of the axis
|
||||
value = this.#STICK_MAP[valueArr[valueArr.length - 1]][2] as number;
|
||||
} else {
|
||||
value = 0;
|
||||
}
|
||||
|
||||
virtualGamepad.axes[axisIndex] = value;
|
||||
} else {
|
||||
virtualGamepad.buttons[buttonIndex].pressed = pressed;
|
||||
virtualGamepad.buttons[buttonIndex].value = pressed ? 1 : 0;
|
||||
}
|
||||
|
||||
virtualGamepad.timestamp = performance.now();
|
||||
}
|
||||
|
||||
#onKeyboardEvent = (e: KeyboardEvent) => {
|
||||
const isKeyDown = e.type === 'keydown';
|
||||
|
||||
// Toggle MKB feature
|
||||
if (isKeyDown && e.code === 'F8') {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[e.code];
|
||||
if (typeof buttonIndex === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore repeating keys
|
||||
if (e.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.#pressButton(buttonIndex, isKeyDown);
|
||||
}
|
||||
|
||||
#onMouseEvent = e => {
|
||||
const isMouseDown = e.type === 'mousedown';
|
||||
const key = KeyHelper.getKeyFromEvent(e);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code];
|
||||
if (typeof buttonIndex === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this.#pressButton(buttonIndex, isMouseDown);
|
||||
}
|
||||
|
||||
#onWheelEvent = (e: WheelEvent) => {
|
||||
const key = KeyHelper.getKeyFromEvent(e);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code];
|
||||
if (typeof buttonIndex === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (this.#prevWheelCode === null || this.#prevWheelCode === key.code) {
|
||||
this.#wheelStoppedTimeout && clearTimeout(this.#wheelStoppedTimeout);
|
||||
this.#pressButton(buttonIndex, true);
|
||||
}
|
||||
|
||||
this.#wheelStoppedTimeout = setTimeout(e => {
|
||||
this.#prevWheelCode = null;
|
||||
this.#pressButton(buttonIndex, false);
|
||||
}, 20);
|
||||
}
|
||||
|
||||
#decayStick = () => {
|
||||
if (!this.#allowStickDecaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
|
||||
if (mouseMapTo === MouseMapTo.OFF) {
|
||||
return;
|
||||
}
|
||||
|
||||
const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
|
||||
|
||||
const virtualGamepad = this.#getVirtualGamepad();
|
||||
let { x, y } = this.#getStickAxes(analog);
|
||||
const length = this.#vectorLength(x, y);
|
||||
|
||||
const clampedLength = Math.min(1.0, length);
|
||||
const decayStrength = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH];
|
||||
const decay = 1 - clampedLength * clampedLength * decayStrength;
|
||||
const minDecay = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_STICK_DECAY_MIN];
|
||||
const clampedDecay = Math.min(1 - minDecay, decay);
|
||||
|
||||
x *= clampedDecay;
|
||||
y *= clampedDecay;
|
||||
|
||||
const deadzoneCounterweight = 20 * MkbHandler.DEFAULT_DEADZONE_COUNTERWEIGHT;
|
||||
if (Math.abs(x) <= deadzoneCounterweight && Math.abs(y) <= deadzoneCounterweight) {
|
||||
x = 0;
|
||||
y = 0;
|
||||
}
|
||||
|
||||
if (this.#allowStickDecaying) {
|
||||
this.#updateStick(analog, x, y);
|
||||
|
||||
(x !== 0 || y !== 0) && requestAnimationFrame(this.#decayStick);
|
||||
}
|
||||
}
|
||||
|
||||
#onMouseStopped = (e: MouseEvent) => {
|
||||
this.#allowStickDecaying = true;
|
||||
requestAnimationFrame(this.#decayStick);
|
||||
}
|
||||
|
||||
#onMouseMoveEvent = (e: MouseEvent) => {
|
||||
// TODO: optimize this
|
||||
const mouseMapTo = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_MAP_TO];
|
||||
if (mouseMapTo === MouseMapTo.OFF) {
|
||||
// Ignore mouse movements
|
||||
return;
|
||||
}
|
||||
|
||||
this.#allowStickDecaying = false;
|
||||
this.#detectMouseStoppedTimeout && clearTimeout(this.#detectMouseStoppedTimeout);
|
||||
this.#detectMouseStoppedTimeout = setTimeout(this.#onMouseStopped.bind(this, e), 100);
|
||||
|
||||
const deltaX = e.movementX;
|
||||
const deltaY = e.movementY;
|
||||
|
||||
const deadzoneCounterweight = this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT];
|
||||
|
||||
let x = deltaX * this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_SENSITIVITY_X];
|
||||
let y = deltaY * this.#CURRENT_PRESET_DATA.mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y];
|
||||
|
||||
let length = this.#vectorLength(x, y);
|
||||
if (length !== 0 && length < deadzoneCounterweight) {
|
||||
x *= deadzoneCounterweight / length;
|
||||
y *= deadzoneCounterweight / length;
|
||||
} else if (length > MkbHandler.MAXIMUM_STICK_RANGE) {
|
||||
x *= MkbHandler.MAXIMUM_STICK_RANGE / length;
|
||||
y *= MkbHandler.MAXIMUM_STICK_RANGE / length;
|
||||
}
|
||||
|
||||
const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT;
|
||||
this.#updateStick(analog, x, y);
|
||||
}
|
||||
|
||||
toggle = () => {
|
||||
this.#enabled = !this.#enabled;
|
||||
this.#enabled ? document.pointerLockElement && this.start() : this.stop();
|
||||
|
||||
Toast.show(t('mouse-and-keyboard'), t(this.#enabled ? 'enabled' : 'disabled'), {instant: true});
|
||||
|
||||
if (this.#enabled) {
|
||||
!document.pointerLockElement && this.#waitForPointerLock(true);
|
||||
} else {
|
||||
this.#waitForPointerLock(false);
|
||||
document.pointerLockElement && document.exitPointerLock();
|
||||
}
|
||||
}
|
||||
|
||||
#getCurrentPreset = () => {
|
||||
return new Promise(resolve => {
|
||||
const presetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
|
||||
LocalDb.INSTANCE.getPreset(presetId).then(preset => {
|
||||
resolve(preset ? preset : MkbPreset.DEFAULT_PRESET);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
refreshPresetData = () => {
|
||||
this.#getCurrentPreset().then(preset => {
|
||||
this.#CURRENT_PRESET_DATA = MkbPreset.convert(preset.data);
|
||||
this.#resetGamepad();
|
||||
});
|
||||
}
|
||||
|
||||
#onPointerLockChange = e => {
|
||||
if (this.#enabled && !document.pointerLockElement) {
|
||||
this.stop();
|
||||
this.#waitForPointerLock(true);
|
||||
}
|
||||
}
|
||||
|
||||
#onPointerLockError = e => {
|
||||
console.log(e);
|
||||
this.stop();
|
||||
}
|
||||
|
||||
#onActivatePointerLock = () => {
|
||||
if (!document.pointerLockElement) {
|
||||
document.body.requestPointerLock();
|
||||
}
|
||||
|
||||
this.#waitForPointerLock(false);
|
||||
this.start();
|
||||
}
|
||||
|
||||
#waitForPointerLock = (wait: boolean) => {
|
||||
this.#$message && this.#$message.classList.toggle('bx-gone', !wait);
|
||||
}
|
||||
|
||||
#onStreamMenuShown = () => {
|
||||
this.#enabled && this.#waitForPointerLock(false);
|
||||
}
|
||||
|
||||
#onStreamMenuHidden = () => {
|
||||
this.#enabled && this.#waitForPointerLock(true);
|
||||
}
|
||||
|
||||
init = () => {
|
||||
this.refreshPresetData();
|
||||
this.#enabled = true;
|
||||
|
||||
window.addEventListener('keydown', this.#onKeyboardEvent);
|
||||
|
||||
document.addEventListener('pointerlockchange', this.#onPointerLockChange);
|
||||
document.addEventListener('pointerlockerror', this.#onPointerLockError);
|
||||
|
||||
this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg bx-gone'},
|
||||
createButton({
|
||||
icon: Icon.MOUSE_SETTINGS,
|
||||
style: ButtonStyle.PRIMARY as number,
|
||||
onClick: e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
showStreamSettings('mkb');
|
||||
},
|
||||
}),
|
||||
CE('div', {},
|
||||
CE('p', {}, t('mkb-click-to-activate')),
|
||||
CE('p', {}, t<any>('press-key-to-toggle-mkb')({key: 'F8'})),
|
||||
),
|
||||
);
|
||||
|
||||
this.#$message.addEventListener('click', this.#onActivatePointerLock);
|
||||
document.documentElement.appendChild(this.#$message);
|
||||
|
||||
window.addEventListener(BxEvent.STREAM_MENU_SHOWN, this.#onStreamMenuShown);
|
||||
window.addEventListener(BxEvent.STREAM_MENU_HIDDEN, this.#onStreamMenuHidden);
|
||||
|
||||
this.#waitForPointerLock(true);
|
||||
}
|
||||
|
||||
destroy = () => {
|
||||
this.#enabled = false;
|
||||
this.stop();
|
||||
|
||||
this.#waitForPointerLock(false);
|
||||
document.pointerLockElement && document.exitPointerLock();
|
||||
|
||||
window.removeEventListener('keydown', this.#onKeyboardEvent);
|
||||
|
||||
document.removeEventListener('pointerlockchange', this.#onPointerLockChange);
|
||||
document.removeEventListener('pointerlockerror', this.#onPointerLockError);
|
||||
|
||||
window.removeEventListener(BxEvent.STREAM_MENU_SHOWN, this.#onStreamMenuShown);
|
||||
window.removeEventListener(BxEvent.STREAM_MENU_HIDDEN, this.#onStreamMenuHidden);
|
||||
}
|
||||
|
||||
start = () => {
|
||||
window.navigator.getGamepads = this.#patchedGetGamepads;
|
||||
|
||||
this.#resetGamepad();
|
||||
|
||||
window.addEventListener('keyup', this.#onKeyboardEvent);
|
||||
|
||||
window.addEventListener('mousemove', this.#onMouseMoveEvent);
|
||||
window.addEventListener('mousedown', this.#onMouseEvent);
|
||||
window.addEventListener('mouseup', this.#onMouseEvent);
|
||||
window.addEventListener('wheel', this.#onWheelEvent);
|
||||
window.addEventListener('contextmenu', this.#disableContextMenu);
|
||||
|
||||
// Dispatch "gamepadconnected" event
|
||||
const virtualGamepad = this.#getVirtualGamepad();
|
||||
virtualGamepad.connected = true;
|
||||
virtualGamepad.timestamp = performance.now();
|
||||
|
||||
BxEvent.dispatch(window, 'gamepadconnected', {
|
||||
gamepad: virtualGamepad,
|
||||
});
|
||||
}
|
||||
|
||||
stop = () => {
|
||||
|
||||
// Dispatch "gamepaddisconnected" event
|
||||
const virtualGamepad = this.#getVirtualGamepad();
|
||||
virtualGamepad.connected = false;
|
||||
virtualGamepad.timestamp = performance.now();
|
||||
|
||||
BxEvent.dispatch(window, 'gamepaddisconnected', {
|
||||
gamepad: virtualGamepad,
|
||||
});
|
||||
|
||||
window.navigator.getGamepads = this.#nativeGetGamepads;
|
||||
|
||||
this.#resetGamepad();
|
||||
|
||||
window.removeEventListener('keyup', this.#onKeyboardEvent);
|
||||
|
||||
window.removeEventListener('mousemove', this.#onMouseMoveEvent);
|
||||
window.removeEventListener('mousedown', this.#onMouseEvent);
|
||||
window.removeEventListener('mouseup', this.#onMouseEvent);
|
||||
window.removeEventListener('wheel', this.#onWheelEvent);
|
||||
window.removeEventListener('contextmenu', this.#disableContextMenu);
|
||||
}
|
||||
|
||||
static setupEvents() {
|
||||
window.addEventListener(BxEvent.STREAM_PLAYING, e => {
|
||||
// Enable MKB
|
||||
if (getPref(PrefKey.MKB_ENABLED) && (!ENABLE_NATIVE_MKB_BETA || !window.NATIVE_MKB_TITLES.includes(GAME_PRODUCT_ID))) {
|
||||
console.log('Emulate MKB');
|
||||
MkbHandler.INSTANCE.init();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
163
src/modules/mkb/mkb-preset.ts
Normal file
163
src/modules/mkb/mkb-preset.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { t } from "../translation";
|
||||
import { SettingElementType } from "../settings";
|
||||
import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "./definitions";
|
||||
import { MkbHandler } from "./mkb-handler";
|
||||
|
||||
|
||||
export class MkbPreset {
|
||||
static MOUSE_SETTINGS = {
|
||||
[MkbPresetKey.MOUSE_MAP_TO]: {
|
||||
label: t('map-mouse-to'),
|
||||
type: SettingElementType.OPTIONS,
|
||||
default: MouseMapTo[MouseMapTo.RS],
|
||||
options: {
|
||||
[MouseMapTo[MouseMapTo.RS]]: t('right-stick'),
|
||||
[MouseMapTo[MouseMapTo.LS]]: t('left-stick'),
|
||||
[MouseMapTo[MouseMapTo.OFF]]: t('off'),
|
||||
},
|
||||
},
|
||||
|
||||
[MkbPresetKey.MOUSE_SENSITIVITY_Y]: {
|
||||
label: t('horizontal-sensitivity'),
|
||||
type: SettingElementType.NUMBER_STEPPER,
|
||||
default: 50,
|
||||
min: 1,
|
||||
max: 200,
|
||||
|
||||
params: {
|
||||
suffix: '%',
|
||||
exactTicks: 20,
|
||||
},
|
||||
},
|
||||
|
||||
[MkbPresetKey.MOUSE_SENSITIVITY_X]: {
|
||||
label: t('vertical-sensitivity'),
|
||||
type: SettingElementType.NUMBER_STEPPER,
|
||||
default: 50,
|
||||
min: 1,
|
||||
max: 200,
|
||||
|
||||
params: {
|
||||
suffix: '%',
|
||||
exactTicks: 20,
|
||||
},
|
||||
},
|
||||
|
||||
[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT]: {
|
||||
label: t('deadzone-counterweight'),
|
||||
type: SettingElementType.NUMBER_STEPPER,
|
||||
default: 20,
|
||||
min: 1,
|
||||
max: 100,
|
||||
|
||||
params: {
|
||||
suffix: '%',
|
||||
exactTicks: 10,
|
||||
},
|
||||
},
|
||||
|
||||
[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH]: {
|
||||
label: t('stick-decay-strength'),
|
||||
type: SettingElementType.NUMBER_STEPPER,
|
||||
default: 100,
|
||||
min: 10,
|
||||
max: 100,
|
||||
|
||||
params: {
|
||||
suffix: '%',
|
||||
exactTicks: 10,
|
||||
},
|
||||
},
|
||||
|
||||
[MkbPresetKey.MOUSE_STICK_DECAY_MIN]: {
|
||||
label: t('stick-decay-minimum'),
|
||||
type: SettingElementType.NUMBER_STEPPER,
|
||||
default: 10,
|
||||
min: 1,
|
||||
max: 10,
|
||||
|
||||
params: {
|
||||
suffix: '%',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
static DEFAULT_PRESET = {
|
||||
'mapping': {
|
||||
// Use "e.code" value from https://keyjs.dev
|
||||
[GamepadKey.UP]: ['ArrowUp'],
|
||||
[GamepadKey.DOWN]: ['ArrowDown'],
|
||||
[GamepadKey.LEFT]: ['ArrowLeft'],
|
||||
[GamepadKey.RIGHT]: ['ArrowRight'],
|
||||
|
||||
[GamepadKey.LS_UP]: ['KeyW'],
|
||||
[GamepadKey.LS_DOWN]: ['KeyS'],
|
||||
[GamepadKey.LS_LEFT]: ['KeyA'],
|
||||
[GamepadKey.LS_RIGHT]: ['KeyD'],
|
||||
|
||||
[GamepadKey.RS_UP]: ['KeyI'],
|
||||
[GamepadKey.RS_DOWN]: ['KeyK'],
|
||||
[GamepadKey.RS_LEFT]: ['KeyJ'],
|
||||
[GamepadKey.RS_RIGHT]: ['KeyL'],
|
||||
|
||||
[GamepadKey.A]: ['Space', 'KeyE'],
|
||||
[GamepadKey.X]: ['KeyR'],
|
||||
[GamepadKey.B]: ['ControlLeft', 'Backspace'],
|
||||
[GamepadKey.Y]: ['KeyV'],
|
||||
|
||||
[GamepadKey.START]: ['Enter'],
|
||||
[GamepadKey.SELECT]: ['Tab'],
|
||||
|
||||
[GamepadKey.LB]: ['KeyC', 'KeyG'],
|
||||
[GamepadKey.RB]: ['KeyQ'],
|
||||
|
||||
[GamepadKey.HOME]: ['Backquote'],
|
||||
|
||||
[GamepadKey.RT]: [MouseButtonCode.LEFT_CLICK],
|
||||
[GamepadKey.LT]: [MouseButtonCode.RIGHT_CLICK],
|
||||
|
||||
[GamepadKey.L3]: ['ShiftLeft'],
|
||||
[GamepadKey.R3]: ['KeyF'],
|
||||
},
|
||||
|
||||
'mouse': {
|
||||
[MkbPresetKey.MOUSE_MAP_TO]: MouseMapTo[MouseMapTo.RS],
|
||||
[MkbPresetKey.MOUSE_SENSITIVITY_X]: 50,
|
||||
[MkbPresetKey.MOUSE_SENSITIVITY_Y]: 50,
|
||||
[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT]: 20,
|
||||
[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH]: 18,
|
||||
[MkbPresetKey.MOUSE_STICK_DECAY_MIN]: 6,
|
||||
},
|
||||
};
|
||||
|
||||
static convert(preset) {
|
||||
const obj = {
|
||||
'mapping': {},
|
||||
'mouse': Object.assign({}, preset.mouse),
|
||||
};
|
||||
|
||||
for (const buttonIndex in preset.mapping) {
|
||||
for (const keyName of preset.mapping[buttonIndex]) {
|
||||
obj.mapping[keyName] = parseInt(buttonIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-calculate mouse's sensitivities
|
||||
const mouse = obj.mouse;
|
||||
mouse[MkbPresetKey.MOUSE_SENSITIVITY_X] *= MkbHandler.DEFAULT_PANNING_SENSITIVITY;
|
||||
mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y] *= MkbHandler.DEFAULT_PANNING_SENSITIVITY;
|
||||
mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT] *= MkbHandler.DEFAULT_DEADZONE_COUNTERWEIGHT;
|
||||
mouse[MkbPresetKey.MOUSE_STICK_DECAY_STRENGTH] *= 0.01;
|
||||
mouse[MkbPresetKey.MOUSE_STICK_DECAY_MIN] *= 0.01;
|
||||
|
||||
const mouseMapTo = MouseMapTo[mouse[MkbPresetKey.MOUSE_MAP_TO]];
|
||||
if (typeof mouseMapTo !== 'undefined') {
|
||||
mouse[MkbPresetKey.MOUSE_MAP_TO] = mouseMapTo;
|
||||
} else {
|
||||
mouse[MkbPresetKey.MOUSE_MAP_TO] = MkbPreset.MOUSE_SETTINGS[MkbPresetKey.MOUSE_MAP_TO].default;
|
||||
}
|
||||
|
||||
console.log(obj);
|
||||
return obj;
|
||||
}
|
||||
}
|
531
src/modules/mkb/mkb-remapper.ts
Normal file
531
src/modules/mkb/mkb-remapper.ts
Normal file
@ -0,0 +1,531 @@
|
||||
import { GamepadKey } from "./definitions";
|
||||
import { CE, createButton, ButtonStyle } from "../../utils/html";
|
||||
import { t } from "../translation";
|
||||
import { Dialog } from "../dialog";
|
||||
import { getPref, setPref, PrefKey } from "../preferences";
|
||||
import { MkbPresetKey, GamepadKeyName } from "./definitions";
|
||||
import { KeyHelper } from "./key-helper";
|
||||
import { MkbPreset } from "./mkb-preset";
|
||||
import { MkbHandler } from "./mkb-handler";
|
||||
import { LocalDb } from "../../utils/local-db";
|
||||
import { Icon } from "../../utils/html";
|
||||
import { SettingElement } from "../settings";
|
||||
|
||||
type MkbRemapperElements = {
|
||||
wrapper: HTMLElement | null,
|
||||
presetsSelect: HTMLSelectElement | null,
|
||||
activateButton: HTMLButtonElement | null,
|
||||
currentBindingKey: HTMLElement | null,
|
||||
|
||||
allKeyElements: HTMLElement[],
|
||||
allMouseElements: {[key in MkbPresetKey]?: HTMLElement},
|
||||
}
|
||||
|
||||
export class MkbRemapper {
|
||||
get #BUTTON_ORDERS() {
|
||||
return [
|
||||
GamepadKey.UP,
|
||||
GamepadKey.DOWN,
|
||||
GamepadKey.LEFT,
|
||||
GamepadKey.RIGHT,
|
||||
|
||||
GamepadKey.A,
|
||||
GamepadKey.B,
|
||||
GamepadKey.X,
|
||||
GamepadKey.Y,
|
||||
|
||||
GamepadKey.LB,
|
||||
GamepadKey.RB,
|
||||
GamepadKey.LT,
|
||||
GamepadKey.RT,
|
||||
|
||||
GamepadKey.SELECT,
|
||||
GamepadKey.START,
|
||||
GamepadKey.HOME,
|
||||
|
||||
GamepadKey.L3,
|
||||
GamepadKey.LS_UP,
|
||||
GamepadKey.LS_DOWN,
|
||||
GamepadKey.LS_LEFT,
|
||||
GamepadKey.LS_RIGHT,
|
||||
|
||||
GamepadKey.R3,
|
||||
GamepadKey.RS_UP,
|
||||
GamepadKey.RS_DOWN,
|
||||
GamepadKey.RS_LEFT,
|
||||
GamepadKey.RS_RIGHT,
|
||||
];
|
||||
};
|
||||
|
||||
static #instance: MkbRemapper;
|
||||
static get INSTANCE() {
|
||||
if (!MkbRemapper.#instance) {
|
||||
MkbRemapper.#instance = new MkbRemapper();
|
||||
}
|
||||
|
||||
return MkbRemapper.#instance;
|
||||
};
|
||||
|
||||
#STATE = {
|
||||
currentPresetId: 0,
|
||||
presets: [],
|
||||
|
||||
editingPresetData: {},
|
||||
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
#$: MkbRemapperElements = {
|
||||
wrapper: null,
|
||||
presetsSelect: null,
|
||||
activateButton: null,
|
||||
|
||||
currentBindingKey: null,
|
||||
|
||||
allKeyElements: [],
|
||||
allMouseElements: {},
|
||||
};
|
||||
|
||||
bindingDialog: Dialog;
|
||||
|
||||
constructor() {
|
||||
this.#STATE.currentPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
|
||||
|
||||
this.bindingDialog = new Dialog({
|
||||
className: 'bx-binding-dialog',
|
||||
content: CE('div', {},
|
||||
CE('p', {}, t('press-to-bind')),
|
||||
CE('i', {}, t('press-esc-to-cancel')),
|
||||
),
|
||||
hideCloseButton: true,
|
||||
});
|
||||
}
|
||||
|
||||
#clearEventListeners = () => {
|
||||
window.removeEventListener('keydown', this.#onKeyDown);
|
||||
window.removeEventListener('mousedown', this.#onMouseDown);
|
||||
window.removeEventListener('wheel', this.#onWheel);
|
||||
};
|
||||
|
||||
#bindKey = ($elm: HTMLElement, key: any) => {
|
||||
const buttonIndex = parseInt($elm.getAttribute('data-button-index')!);
|
||||
const keySlot = parseInt($elm.getAttribute('data-key-slot')!);
|
||||
|
||||
// Ignore if bind the save key to the same element
|
||||
if ($elm.getAttribute('data-key-code') === key.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unbind duplicated keys
|
||||
for (const $otherElm of this.#$.allKeyElements) {
|
||||
if ($otherElm.getAttribute('data-key-code') === key.code) {
|
||||
this.#unbindKey($otherElm);
|
||||
}
|
||||
}
|
||||
|
||||
this.#STATE.editingPresetData.mapping[buttonIndex][keySlot] = key.code;
|
||||
$elm.textContent = key.name;
|
||||
$elm.setAttribute('data-key-code', key.code);
|
||||
}
|
||||
|
||||
#unbindKey = ($elm: HTMLElement) => {
|
||||
const buttonIndex = parseInt($elm.getAttribute('data-button-index')!);
|
||||
const keySlot = parseInt($elm.getAttribute('data-key-slot')!);
|
||||
|
||||
// Remove key from preset
|
||||
this.#STATE.editingPresetData.mapping[buttonIndex][keySlot] = null;
|
||||
$elm.textContent = '';
|
||||
$elm.removeAttribute('data-key-code');
|
||||
}
|
||||
|
||||
#onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
this.#clearEventListeners();
|
||||
|
||||
this.#bindKey(this.#$.currentBindingKey!, KeyHelper.getKeyFromEvent(e));
|
||||
setTimeout(() => this.bindingDialog.hide(), 200);
|
||||
};
|
||||
|
||||
#onMouseDown = e => {
|
||||
e.preventDefault();
|
||||
this.#clearEventListeners();
|
||||
|
||||
this.#bindKey(this.#$.currentBindingKey!, KeyHelper.getKeyFromEvent(e));
|
||||
setTimeout(() => this.bindingDialog.hide(), 200);
|
||||
};
|
||||
|
||||
#onKeyDown = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.#clearEventListeners();
|
||||
|
||||
if (e.code !== 'Escape') {
|
||||
this.#bindKey(this.#$.currentBindingKey!, KeyHelper.getKeyFromEvent(e));
|
||||
}
|
||||
|
||||
setTimeout(() => this.bindingDialog.hide(), 200);
|
||||
};
|
||||
|
||||
#onBindingKey = e => {
|
||||
if (!this.#STATE.isEditing || e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(e);
|
||||
|
||||
this.#$.currentBindingKey = e.target;
|
||||
|
||||
window.addEventListener('keydown', this.#onKeyDown);
|
||||
window.addEventListener('mousedown', this.#onMouseDown);
|
||||
window.addEventListener('wheel', this.#onWheel);
|
||||
|
||||
this.bindingDialog.show({title: e.target.getAttribute('data-prompt')});
|
||||
};
|
||||
|
||||
#onContextMenu = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!this.#STATE.isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#unbindKey(e.target as HTMLElement);
|
||||
};
|
||||
|
||||
#getPreset = (presetId: string) => {
|
||||
return this.#STATE.presets[presetId];
|
||||
}
|
||||
|
||||
#getCurrentPreset = () => {
|
||||
return this.#getPreset(this.#STATE.currentPresetId);
|
||||
}
|
||||
|
||||
#switchPreset = presetId => {
|
||||
presetId = parseInt(presetId);
|
||||
|
||||
this.#STATE.currentPresetId = presetId;
|
||||
const presetData = this.#getCurrentPreset().data;
|
||||
|
||||
for (const $elm of this.#$.allKeyElements) {
|
||||
const buttonIndex = $elm.getAttribute('data-button-index');
|
||||
const keySlot = $elm.getAttribute('data-key-slot');
|
||||
|
||||
const buttonKeys = presetData.mapping[buttonIndex];
|
||||
if (buttonKeys && buttonKeys[keySlot]) {
|
||||
$elm.textContent = KeyHelper.codeToKeyName(buttonKeys[keySlot]);
|
||||
$elm.setAttribute('data-key-code', buttonKeys[keySlot]);
|
||||
} else {
|
||||
$elm.textContent = '';
|
||||
$elm.removeAttribute('data-key-code');
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in this.#$.allMouseElements) {
|
||||
const $elm = this.#$.allMouseElements[key as MkbPresetKey]!;
|
||||
let value = presetData.mouse[key];
|
||||
if (typeof value === 'undefined') {
|
||||
value = MkbPreset.MOUSE_SETTINGS[key as MkbPresetKey].default;
|
||||
}
|
||||
|
||||
'setValue' in $elm && ($elm as any).setValue(value);
|
||||
}
|
||||
|
||||
// Update state of Activate button
|
||||
const activated = getPref(PrefKey.MKB_DEFAULT_PRESET_ID) === this.#STATE.currentPresetId;
|
||||
this.#$.activateButton!.disabled = activated;
|
||||
this.#$.activateButton!.querySelector('span')!.textContent = activated ? t('activated') : t('activate');
|
||||
}
|
||||
|
||||
#refresh() {
|
||||
// Clear presets select
|
||||
while (this.#$.presetsSelect!.firstChild) {
|
||||
this.#$.presetsSelect!.removeChild(this.#$.presetsSelect!.firstChild);
|
||||
}
|
||||
|
||||
LocalDb.INSTANCE.getPresets()
|
||||
.then(presets => {
|
||||
this.#STATE.presets = presets;
|
||||
const $fragment = document.createDocumentFragment();
|
||||
|
||||
let defaultPresetId;
|
||||
if (this.#STATE.currentPresetId === 0) {
|
||||
this.#STATE.currentPresetId = parseInt(Object.keys(presets)[0]);
|
||||
|
||||
defaultPresetId = this.#STATE.currentPresetId;
|
||||
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, defaultPresetId);
|
||||
MkbHandler.INSTANCE.refreshPresetData();
|
||||
} else {
|
||||
defaultPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID);
|
||||
}
|
||||
|
||||
for (let id in presets) {
|
||||
id = parseInt(id);
|
||||
|
||||
const preset = presets[id];
|
||||
let name = preset.name;
|
||||
if (id === defaultPresetId) {
|
||||
name = `🎮 ` + name;
|
||||
}
|
||||
|
||||
const $options = CE('option', {value: id}, name);
|
||||
$options.selected = id === this.#STATE.currentPresetId;
|
||||
|
||||
$fragment.appendChild($options);
|
||||
};
|
||||
|
||||
this.#$.presetsSelect!.appendChild($fragment);
|
||||
|
||||
// Update state of Activate button
|
||||
const activated = defaultPresetId === this.#STATE.currentPresetId;
|
||||
this.#$.activateButton!.disabled = activated;
|
||||
this.#$.activateButton!.querySelector('span')!.textContent = activated ? t('activated') : t('activate');
|
||||
|
||||
!this.#STATE.isEditing && this.#switchPreset(this.#STATE.currentPresetId);
|
||||
});
|
||||
}
|
||||
|
||||
#toggleEditing = (force?: boolean) => {
|
||||
this.#STATE.isEditing = typeof force !== 'undefined' ? force : !this.#STATE.isEditing;
|
||||
this.#$.wrapper!.classList.toggle('bx-editing', this.#STATE.isEditing);
|
||||
|
||||
if (this.#STATE.isEditing) {
|
||||
this.#STATE.editingPresetData = structuredClone(this.#getCurrentPreset().data);
|
||||
} else {
|
||||
this.#STATE.editingPresetData = {};
|
||||
}
|
||||
|
||||
|
||||
const childElements = this.#$.wrapper!.querySelectorAll('select, button, input');
|
||||
for (const $elm of Array.from(childElements)) {
|
||||
if ($elm.parentElement!.parentElement!.classList.contains('bx-mkb-action-buttons')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let disable = !this.#STATE.isEditing;
|
||||
|
||||
if ($elm.parentElement!.classList.contains('bx-mkb-preset-tools')) {
|
||||
disable = !disable;
|
||||
}
|
||||
|
||||
($elm as HTMLButtonElement).disabled = disable;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.#$.wrapper = CE('div', {'class': 'bx-mkb-settings'});
|
||||
|
||||
this.#$.presetsSelect = CE<HTMLSelectElement>('select', {});
|
||||
this.#$.presetsSelect!.addEventListener('change', e => {
|
||||
this.#switchPreset((e.target as HTMLSelectElement).value);
|
||||
});
|
||||
|
||||
const promptNewName = (value?: string) => {
|
||||
let newName: string | null = '';
|
||||
while (!newName) {
|
||||
newName = prompt(t('prompt-preset-name'), value);
|
||||
if (newName === null) {
|
||||
return false;
|
||||
}
|
||||
newName = newName.trim();
|
||||
}
|
||||
|
||||
return newName ? newName : false;
|
||||
};
|
||||
|
||||
const $header = CE('div', {'class': 'bx-mkb-preset-tools'},
|
||||
this.#$.presetsSelect,
|
||||
// Rename button
|
||||
createButton({
|
||||
title: t('rename'),
|
||||
icon: Icon.CURSOR_TEXT,
|
||||
onClick: e => {
|
||||
const preset = this.#getCurrentPreset();
|
||||
|
||||
let newName = promptNewName(preset.name);
|
||||
if (!newName || newName === preset.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update preset with new name
|
||||
preset.name = newName;
|
||||
LocalDb.INSTANCE.updatePreset(preset).then(id => this.#refresh());
|
||||
},
|
||||
}),
|
||||
|
||||
// New button
|
||||
createButton({
|
||||
icon: Icon.NEW,
|
||||
title: t('new'),
|
||||
onClick: e => {
|
||||
let newName = promptNewName('');
|
||||
if (!newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new preset selected name
|
||||
LocalDb.INSTANCE.newPreset(newName, MkbPreset.DEFAULT_PRESET).then(id => {
|
||||
this.#STATE.currentPresetId = id;
|
||||
this.#refresh();
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
// Copy button
|
||||
createButton({
|
||||
icon: Icon.COPY,
|
||||
title: t('copy'),
|
||||
onClick: e => {
|
||||
const preset = this.#getCurrentPreset();
|
||||
|
||||
let newName = promptNewName(`${preset.name} (2)`);
|
||||
if (!newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new preset selected name
|
||||
LocalDb.INSTANCE.newPreset(newName, preset.data).then(id => {
|
||||
this.#STATE.currentPresetId = id;
|
||||
this.#refresh();
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
// Delete button
|
||||
createButton({
|
||||
icon: Icon.TRASH,
|
||||
style: ButtonStyle.DANGER,
|
||||
title: t('delete'),
|
||||
onClick: e => {
|
||||
if (!confirm(t('confirm-delete-preset'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDb.INSTANCE.deletePreset(this.#STATE.currentPresetId).then(id => {
|
||||
this.#STATE.currentPresetId = 0;
|
||||
this.#refresh();
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
this.#$.wrapper!.appendChild($header);
|
||||
|
||||
const $rows = CE('div', {'class': 'bx-mkb-settings-rows'},
|
||||
CE('i', {'class': 'bx-mkb-note'}, t('right-click-to-unbind')),
|
||||
);
|
||||
|
||||
// Render keys
|
||||
const keysPerButton = 2;
|
||||
for (const buttonIndex of this.#BUTTON_ORDERS) {
|
||||
const [buttonName, buttonPrompt] = GamepadKeyName[buttonIndex];
|
||||
|
||||
let $elm;
|
||||
const $fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < keysPerButton; i++) {
|
||||
$elm = CE('button', {
|
||||
'data-prompt': buttonPrompt,
|
||||
'data-button-index': buttonIndex,
|
||||
'data-key-slot': i,
|
||||
}, ' ');
|
||||
|
||||
$elm.addEventListener('mouseup', this.#onBindingKey);
|
||||
$elm.addEventListener('contextmenu', this.#onContextMenu);
|
||||
|
||||
$fragment.appendChild($elm);
|
||||
this.#$.allKeyElements.push($elm);
|
||||
}
|
||||
|
||||
const $keyRow = CE('div', {'class': 'bx-mkb-key-row'},
|
||||
CE('label', {'title': buttonName}, buttonPrompt),
|
||||
$fragment,
|
||||
);
|
||||
|
||||
$rows.appendChild($keyRow);
|
||||
}
|
||||
|
||||
$rows.appendChild(CE('i', {'class': 'bx-mkb-note'}, t('mkb-adjust-ingame-settings')),);
|
||||
|
||||
// Render mouse settings
|
||||
const $mouseSettings = document.createDocumentFragment();
|
||||
for (const key in MkbPreset.MOUSE_SETTINGS) {
|
||||
const setting = MkbPreset.MOUSE_SETTINGS[key as MkbPresetKey];
|
||||
const value = setting.default;
|
||||
|
||||
let $elm;
|
||||
const onChange = (e, value) => {
|
||||
this.#STATE.editingPresetData.mouse[key] = value;
|
||||
};
|
||||
const $row = CE('div', {'class': 'bx-quick-settings-row'},
|
||||
CE('label', {'for': `bx_setting_${key}`}, setting.label),
|
||||
$elm = SettingElement.render(setting.type, key, setting, value, onChange, setting.params),
|
||||
);
|
||||
|
||||
$mouseSettings.appendChild($row);
|
||||
this.#$.allMouseElements[key as MkbPresetKey] = $elm;
|
||||
}
|
||||
|
||||
$rows.appendChild($mouseSettings);
|
||||
this.#$.wrapper!.appendChild($rows);
|
||||
|
||||
// Render action buttons
|
||||
const $actionButtons = CE('div', {'class': 'bx-mkb-action-buttons'},
|
||||
CE('div', {},
|
||||
// Edit button
|
||||
createButton({
|
||||
label: t('edit'),
|
||||
onClick: e => this.#toggleEditing(true),
|
||||
}),
|
||||
|
||||
// Activate button
|
||||
this.#$.activateButton = createButton({
|
||||
label: t('activate'),
|
||||
style: ButtonStyle.PRIMARY,
|
||||
onClick: e => {
|
||||
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId);
|
||||
MkbHandler.INSTANCE.refreshPresetData();
|
||||
|
||||
this.#refresh();
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
CE('div', {},
|
||||
// Cancel button
|
||||
createButton({
|
||||
label: t('cancel'),
|
||||
style: ButtonStyle.GHOST,
|
||||
onClick: e => {
|
||||
// Restore preset
|
||||
this.#switchPreset(this.#STATE.currentPresetId);
|
||||
this.#toggleEditing(false);
|
||||
},
|
||||
}),
|
||||
|
||||
// Save button
|
||||
createButton({
|
||||
label: t('save'),
|
||||
style: ButtonStyle.PRIMARY,
|
||||
onClick: e => {
|
||||
const updatedPreset = structuredClone(this.#getCurrentPreset());
|
||||
updatedPreset.data = this.#STATE.editingPresetData;
|
||||
|
||||
LocalDb.INSTANCE.updatePreset(updatedPreset).then(id => {
|
||||
// If this is the default preset => refresh preset data
|
||||
if (id === getPref(PrefKey.MKB_DEFAULT_PRESET_ID)) {
|
||||
MkbHandler.INSTANCE.refreshPresetData();
|
||||
}
|
||||
|
||||
this.#toggleEditing(false);
|
||||
this.#refresh();
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.#$.wrapper!.appendChild($actionButtons);
|
||||
|
||||
this.#toggleEditing(false);
|
||||
this.#refresh();
|
||||
return this.#$.wrapper;
|
||||
}
|
||||
}
|
757
src/modules/preferences.ts
Normal file
757
src/modules/preferences.ts
Normal file
@ -0,0 +1,757 @@
|
||||
import { CE } from "../utils/html";
|
||||
import { t } from "./translation";
|
||||
import { SettingElement, SettingElementType } from "./settings";
|
||||
import { UserAgentProfile } from "../utils/user-agent";
|
||||
import { StreamStat } from "./stream-stats";
|
||||
|
||||
export type PreferenceSetting = {
|
||||
default: any;
|
||||
options?: {[index: string]: string};
|
||||
multiple_options?: {[index: string]: string};
|
||||
unsupported?: string | boolean;
|
||||
note?: string | HTMLElement;
|
||||
type?: SettingElementType;
|
||||
ready?: () => void;
|
||||
migrate?: (savedPrefs: any, value: any) => {};
|
||||
min?: number;
|
||||
max?: number;
|
||||
steps?: number;
|
||||
experimental?: boolean;
|
||||
params?: any;
|
||||
};
|
||||
|
||||
declare var HAS_TOUCH_SUPPORT: boolean;
|
||||
|
||||
export enum PrefKey {
|
||||
LAST_UPDATE_CHECK = 'version_last_check',
|
||||
LATEST_VERSION = 'version_latest',
|
||||
CURRENT_VERSION = 'version_current',
|
||||
|
||||
BETTER_XCLOUD_LOCALE = 'bx_locale',
|
||||
|
||||
SERVER_REGION = 'server_region',
|
||||
PREFER_IPV6_SERVER = 'prefer_ipv6_server',
|
||||
STREAM_TARGET_RESOLUTION = 'stream_target_resolution',
|
||||
STREAM_PREFERRED_LOCALE = 'stream_preferred_locale',
|
||||
STREAM_CODEC_PROFILE = 'stream_codec_profile',
|
||||
|
||||
USER_AGENT_PROFILE = 'user_agent_profile',
|
||||
USER_AGENT_CUSTOM = 'user_agent_custom',
|
||||
STREAM_SIMPLIFY_MENU = 'stream_simplify_menu',
|
||||
|
||||
STREAM_COMBINE_SOURCES = 'stream_combine_sources',
|
||||
|
||||
STREAM_TOUCH_CONTROLLER = 'stream_touch_controller',
|
||||
STREAM_TOUCH_CONTROLLER_AUTO_OFF = 'stream_touch_controller_auto_off',
|
||||
STREAM_TOUCH_CONTROLLER_STYLE_STANDARD = 'stream_touch_controller_style_standard',
|
||||
STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM = 'stream_touch_controller_style_custom',
|
||||
|
||||
STREAM_DISABLE_FEEDBACK_DIALOG = 'stream_disable_feedback_dialog',
|
||||
|
||||
LOCAL_CO_OP_ENABLED = 'local_co_op_enabled',
|
||||
// LOCAL_CO_OP_SEPARATE_TOUCH_CONTROLLER = 'local_co_op_separate_touch_controller',
|
||||
|
||||
CONTROLLER_ENABLE_SHORTCUTS = 'controller_enable_shortcuts',
|
||||
CONTROLLER_ENABLE_VIBRATION = 'controller_enable_vibration',
|
||||
CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration',
|
||||
CONTROLLER_VIBRATION_INTENSITY = 'controller_vibration_intensity',
|
||||
|
||||
MKB_ENABLED = 'mkb_enabled',
|
||||
MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor',
|
||||
MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse',
|
||||
MKB_DEFAULT_PRESET_ID = 'mkb_default_preset_id',
|
||||
|
||||
SCREENSHOT_BUTTON_POSITION = 'screenshot_button_position',
|
||||
SCREENSHOT_APPLY_FILTERS = 'screenshot_apply_filters',
|
||||
|
||||
BLOCK_TRACKING = 'block_tracking',
|
||||
BLOCK_SOCIAL_FEATURES = 'block_social_features',
|
||||
SKIP_SPLASH_VIDEO = 'skip_splash_video',
|
||||
HIDE_DOTS_ICON = 'hide_dots_icon',
|
||||
REDUCE_ANIMATIONS = 'reduce_animations',
|
||||
|
||||
UI_LOADING_SCREEN_GAME_ART = 'ui_loading_screen_game_art',
|
||||
UI_LOADING_SCREEN_WAIT_TIME = 'ui_loading_screen_wait_time',
|
||||
UI_LOADING_SCREEN_ROCKET = 'ui_loading_screen_rocket',
|
||||
|
||||
UI_LAYOUT = 'ui_layout',
|
||||
UI_SCROLLBAR_HIDE = 'ui_scrollbar_hide',
|
||||
|
||||
VIDEO_CLARITY = 'video_clarity',
|
||||
VIDEO_RATIO = 'video_ratio',
|
||||
VIDEO_BRIGHTNESS = 'video_brightness',
|
||||
VIDEO_CONTRAST = 'video_contrast',
|
||||
VIDEO_SATURATION = 'video_saturation',
|
||||
|
||||
AUDIO_MIC_ON_PLAYING = 'audio_mic_on_playing',
|
||||
AUDIO_ENABLE_VOLUME_CONTROL = 'audio_enable_volume_control',
|
||||
AUDIO_VOLUME = 'audio_volume',
|
||||
|
||||
STATS_ITEMS = 'stats_items',
|
||||
STATS_SHOW_WHEN_PLAYING = 'stats_show_when_playing',
|
||||
STATS_QUICK_GLANCE = 'stats_quick_glance',
|
||||
STATS_POSITION = 'stats_position',
|
||||
STATS_TEXT_SIZE = 'stats_text_size',
|
||||
STATS_TRANSPARENT = 'stats_transparent',
|
||||
STATS_OPACITY = 'stats_opacity',
|
||||
STATS_CONDITIONAL_FORMATTING = 'stats_conditional_formatting',
|
||||
|
||||
REMOTE_PLAY_ENABLED = 'xhome_enabled',
|
||||
REMOTE_PLAY_RESOLUTION = 'xhome_resolution',
|
||||
|
||||
GAME_FORTNITE_FORCE_CONSOLE = 'game_fortnite_force_console',
|
||||
}
|
||||
|
||||
export class Preferences {
|
||||
static SETTINGS: {[index: string]: PreferenceSetting} = {
|
||||
[PrefKey.LAST_UPDATE_CHECK]: {
|
||||
'default': 0,
|
||||
},
|
||||
[PrefKey.LATEST_VERSION]: {
|
||||
'default': '',
|
||||
},
|
||||
[PrefKey.CURRENT_VERSION]: {
|
||||
'default': '',
|
||||
},
|
||||
[PrefKey.BETTER_XCLOUD_LOCALE]: {
|
||||
'default': localStorage.getItem('better_xcloud_locale') || 'en-US',
|
||||
'options': {
|
||||
'en-ID': 'Bahasa Indonesia',
|
||||
'de-DE': 'Deutsch',
|
||||
'en-US': 'English (United States)',
|
||||
'es-ES': 'español (España)',
|
||||
'fr-FR': 'français',
|
||||
'it-IT': 'italiano',
|
||||
'ja-JP': '日本語',
|
||||
'ko-KR': '한국어',
|
||||
'pl-PL': 'polski',
|
||||
'pt-BR': 'português (Brasil)',
|
||||
'ru-RU': 'русский',
|
||||
'tr-TR': 'Türkçe',
|
||||
'uk-UA': 'українська',
|
||||
'vi-VN': 'Tiếng Việt',
|
||||
'zh-CN': '中文(简体)',
|
||||
},
|
||||
},
|
||||
[PrefKey.SERVER_REGION]: {
|
||||
'default': 'default',
|
||||
},
|
||||
[PrefKey.STREAM_PREFERRED_LOCALE]: {
|
||||
'default': 'default',
|
||||
'options': {
|
||||
'default': t('default'),
|
||||
'ar-SA': 'العربية',
|
||||
'cs-CZ': 'čeština',
|
||||
'da-DK': 'dansk',
|
||||
'de-DE': 'Deutsch',
|
||||
'el-GR': 'Ελληνικά',
|
||||
'en-GB': 'English (United Kingdom)',
|
||||
'en-US': 'English (United States)',
|
||||
'es-ES': 'español (España)',
|
||||
'es-MX': 'español (Latinoamérica)',
|
||||
'fi-FI': 'suomi',
|
||||
'fr-FR': 'français',
|
||||
'he-IL': 'עברית',
|
||||
'hu-HU': 'magyar',
|
||||
'it-IT': 'italiano',
|
||||
'ja-JP': '日本語',
|
||||
'ko-KR': '한국어',
|
||||
'nb-NO': 'norsk bokmål',
|
||||
'nl-NL': 'Nederlands',
|
||||
'pl-PL': 'polski',
|
||||
'pt-BR': 'português (Brasil)',
|
||||
'pt-PT': 'português (Portugal)',
|
||||
'ru-RU': 'русский',
|
||||
'sk-SK': 'slovenčina',
|
||||
'sv-SE': 'svenska',
|
||||
'tr-TR': 'Türkçe',
|
||||
'zh-CN': '中文(简体)',
|
||||
'zh-TW': '中文 (繁體)',
|
||||
},
|
||||
},
|
||||
[PrefKey.STREAM_TARGET_RESOLUTION]: {
|
||||
'default': 'auto',
|
||||
'options': {
|
||||
'auto': t('default'),
|
||||
'1080p': '1080p',
|
||||
'720p': '720p',
|
||||
},
|
||||
},
|
||||
[PrefKey.STREAM_CODEC_PROFILE]: {
|
||||
'default': 'default',
|
||||
'options': (() => {
|
||||
const options: {[index: string]: string} = {
|
||||
'default': t('default'),
|
||||
};
|
||||
|
||||
if (!('getCapabilities' in RTCRtpReceiver) || typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) {
|
||||
return options;
|
||||
}
|
||||
|
||||
let hasLowCodec = false;
|
||||
let hasNormalCodec = false;
|
||||
let hasHighCodec = false;
|
||||
|
||||
const codecs = RTCRtpReceiver.getCapabilities('video')!.codecs;
|
||||
for (let codec of codecs) {
|
||||
if (codec.mimeType.toLowerCase() !== 'video/h264' || !codec.sdpFmtpLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fmtp = codec.sdpFmtpLine.toLowerCase();
|
||||
if (!hasHighCodec && fmtp.includes('profile-level-id=4d')) {
|
||||
hasHighCodec = true;
|
||||
} else if (!hasNormalCodec && fmtp.includes('profile-level-id=42e')) {
|
||||
hasNormalCodec = true;
|
||||
} else if (!hasLowCodec && fmtp.includes('profile-level-id=420')) {
|
||||
hasLowCodec = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHighCodec) {
|
||||
if (!hasLowCodec && !hasNormalCodec) {
|
||||
options.default = `${t('visual-quality-high')} (${t('default')})`;
|
||||
} else {
|
||||
options.high = t('visual-quality-high');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNormalCodec) {
|
||||
if (!hasLowCodec && !hasHighCodec) {
|
||||
options.default = `${t('visual-quality-normal')} (${t('default')})`;
|
||||
} else {
|
||||
options.normal = t('visual-quality-normal');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLowCodec) {
|
||||
if (!hasNormalCodec && !hasHighCodec) {
|
||||
options.default = `${t('visual-quality-low')} (${t('default')})`;
|
||||
} else {
|
||||
options.low = t('visual-quality-low');
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
})(),
|
||||
'ready': () => {
|
||||
const setting = Preferences.SETTINGS[PrefKey.STREAM_CODEC_PROFILE]
|
||||
const options: any = setting.options;
|
||||
const keys = Object.keys(options);
|
||||
|
||||
if (keys.length <= 1) { // Unsupported
|
||||
setting.unsupported = true;
|
||||
setting.note = '⚠️ ' + t('browser-unsupported-feature');
|
||||
} else {
|
||||
// Set default value to the best codec profile
|
||||
// setting.default = keys[keys.length - 1];
|
||||
}
|
||||
},
|
||||
},
|
||||
[PrefKey.PREFER_IPV6_SERVER]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.SCREENSHOT_BUTTON_POSITION]: {
|
||||
'default': 'bottom-left',
|
||||
'options': {
|
||||
'bottom-left': t('bottom-left'),
|
||||
'bottom-right': t('bottom-right'),
|
||||
'none': t('disable'),
|
||||
},
|
||||
},
|
||||
[PrefKey.SCREENSHOT_APPLY_FILTERS]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.SKIP_SPLASH_VIDEO]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.HIDE_DOTS_ICON]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.STREAM_COMBINE_SOURCES]: {
|
||||
'default': false,
|
||||
'experimental': true,
|
||||
'note': t('combine-audio-video-streams-summary'),
|
||||
},
|
||||
|
||||
[PrefKey.STREAM_TOUCH_CONTROLLER]: {
|
||||
'default': 'all',
|
||||
'options': {
|
||||
'default': t('default'),
|
||||
'all': t('tc-all-games'),
|
||||
'off': t('off'),
|
||||
},
|
||||
'unsupported': !HAS_TOUCH_SUPPORT,
|
||||
'ready': () => {
|
||||
const setting = Preferences.SETTINGS[PrefKey.STREAM_TOUCH_CONTROLLER];
|
||||
if (setting.unsupported) {
|
||||
setting.default = 'default';
|
||||
}
|
||||
},
|
||||
},
|
||||
[PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF]: {
|
||||
'default': false,
|
||||
'unsupported': !HAS_TOUCH_SUPPORT,
|
||||
},
|
||||
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: {
|
||||
'default': 'default',
|
||||
'options': {
|
||||
'default': t('default'),
|
||||
'white': t('tc-all-white'),
|
||||
'muted': t('tc-muted-colors'),
|
||||
},
|
||||
'unsupported': !HAS_TOUCH_SUPPORT,
|
||||
},
|
||||
[PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: {
|
||||
'default': 'default',
|
||||
'options': {
|
||||
'default': t('default'),
|
||||
'muted': t('tc-muted-colors'),
|
||||
},
|
||||
'unsupported': !HAS_TOUCH_SUPPORT,
|
||||
},
|
||||
|
||||
[PrefKey.STREAM_SIMPLIFY_MENU]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.MKB_HIDE_IDLE_CURSOR]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.LOCAL_CO_OP_ENABLED]: {
|
||||
'default': false,
|
||||
'note': CE<HTMLAnchorElement>('a', {
|
||||
href: 'https://github.com/redphx/better-xcloud/discussions/275',
|
||||
target: '_blank',
|
||||
}, t('enable-local-co-op-support-note')),
|
||||
},
|
||||
|
||||
/*
|
||||
[Preferences.LOCAL_CO_OP_SEPARATE_TOUCH_CONTROLLER]: {
|
||||
'default': false,
|
||||
'note': t('separate-touch-controller-note'),
|
||||
},
|
||||
*/
|
||||
|
||||
[PrefKey.CONTROLLER_ENABLE_SHORTCUTS]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.CONTROLLER_ENABLE_VIBRATION]: {
|
||||
'default': true,
|
||||
},
|
||||
|
||||
[PrefKey.CONTROLLER_DEVICE_VIBRATION]: {
|
||||
'default': 'off',
|
||||
'options': {
|
||||
'on': t('on'),
|
||||
'auto': t('device-vibration-not-using-gamepad'),
|
||||
'off': t('off'),
|
||||
},
|
||||
},
|
||||
|
||||
[PrefKey.CONTROLLER_VIBRATION_INTENSITY]: {
|
||||
'type': SettingElementType.NUMBER_STEPPER,
|
||||
'default': 100,
|
||||
'min': 0,
|
||||
'max': 100,
|
||||
'steps': 10,
|
||||
'params': {
|
||||
suffix: '%',
|
||||
ticks: 10,
|
||||
},
|
||||
},
|
||||
|
||||
[PrefKey.MKB_ENABLED]: {
|
||||
'default': false,
|
||||
'unsupported': ((): string | boolean => {
|
||||
const userAgent = ((window.navigator as any).orgUserAgent || window.navigator.userAgent || '').toLowerCase();
|
||||
return userAgent.match(/(android|iphone|ipad)/) ? t('browser-unsupported-feature') : false;
|
||||
})(),
|
||||
'ready': () => {
|
||||
const pref = Preferences.SETTINGS[PrefKey.MKB_ENABLED];
|
||||
|
||||
let note;
|
||||
let url;
|
||||
if (pref.unsupported) {
|
||||
note = t('browser-unsupported-feature');
|
||||
url = 'https://github.com/redphx/better-xcloud/issues/206#issuecomment-1920475657';
|
||||
} else {
|
||||
note = t('mkb-disclaimer');
|
||||
url = 'https://better-xcloud.github.io/mouse-and-keyboard/#disclaimer';
|
||||
}
|
||||
|
||||
Preferences.SETTINGS[PrefKey.MKB_ENABLED].note = CE('a', {
|
||||
href: url,
|
||||
target: '_blank',
|
||||
}, '⚠️ ' + note);
|
||||
},
|
||||
},
|
||||
|
||||
[PrefKey.MKB_DEFAULT_PRESET_ID]: {
|
||||
'default': 0,
|
||||
},
|
||||
|
||||
[PrefKey.MKB_ABSOLUTE_MOUSE]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.REDUCE_ANIMATIONS]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.UI_LOADING_SCREEN_GAME_ART]: {
|
||||
'default': true,
|
||||
},
|
||||
[PrefKey.UI_LOADING_SCREEN_WAIT_TIME]: {
|
||||
'default': true,
|
||||
},
|
||||
[PrefKey.UI_LOADING_SCREEN_ROCKET]: {
|
||||
'default': 'show',
|
||||
'options': {
|
||||
'show': t('rocket-always-show'),
|
||||
'hide-queue': t('rocket-hide-queue'),
|
||||
'hide': t('rocket-always-hide'),
|
||||
},
|
||||
},
|
||||
[PrefKey.UI_LAYOUT]: {
|
||||
'default': 'default',
|
||||
'options': {
|
||||
'default': t('default'),
|
||||
'tv': t('smart-tv'),
|
||||
},
|
||||
},
|
||||
|
||||
[PrefKey.UI_SCROLLBAR_HIDE]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.BLOCK_SOCIAL_FEATURES]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.BLOCK_TRACKING]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.USER_AGENT_PROFILE]: {
|
||||
'default': 'default',
|
||||
'options': {
|
||||
[UserAgentProfile.DEFAULT]: t('default'),
|
||||
[UserAgentProfile.EDGE_WINDOWS]: 'Edge + Windows',
|
||||
[UserAgentProfile.SAFARI_MACOS]: 'Safari + macOS',
|
||||
[UserAgentProfile.SMARTTV_TIZEN]: 'Samsung Smart TV',
|
||||
[UserAgentProfile.CUSTOM]: t('custom'),
|
||||
},
|
||||
},
|
||||
[PrefKey.USER_AGENT_CUSTOM]: {
|
||||
'default': '',
|
||||
},
|
||||
[PrefKey.VIDEO_CLARITY]: {
|
||||
'type': SettingElementType.NUMBER_STEPPER,
|
||||
'default': 0,
|
||||
'min': 0,
|
||||
'max': 5,
|
||||
'params': {
|
||||
hideSlider: true,
|
||||
},
|
||||
},
|
||||
[PrefKey.VIDEO_RATIO]: {
|
||||
'default': '16:9',
|
||||
'options': {
|
||||
'16:9': '16:9',
|
||||
'18:9': '18:9',
|
||||
'21:9': '21:9',
|
||||
'16:10': '16:10',
|
||||
'4:3': '4:3',
|
||||
|
||||
'fill': t('stretch'),
|
||||
//'cover': 'Cover',
|
||||
},
|
||||
},
|
||||
[PrefKey.VIDEO_SATURATION]: {
|
||||
'type': SettingElementType.NUMBER_STEPPER,
|
||||
'default': 100,
|
||||
'min': 50,
|
||||
'max': 150,
|
||||
'params': {
|
||||
suffix: '%',
|
||||
ticks: 25,
|
||||
},
|
||||
},
|
||||
[PrefKey.VIDEO_CONTRAST]: {
|
||||
'type': SettingElementType.NUMBER_STEPPER,
|
||||
'default': 100,
|
||||
'min': 50,
|
||||
'max': 150,
|
||||
'params': {
|
||||
suffix: '%',
|
||||
ticks: 25,
|
||||
},
|
||||
},
|
||||
[PrefKey.VIDEO_BRIGHTNESS]: {
|
||||
'type': SettingElementType.NUMBER_STEPPER,
|
||||
'default': 100,
|
||||
'min': 50,
|
||||
'max': 150,
|
||||
'params': {
|
||||
suffix: '%',
|
||||
ticks: 25,
|
||||
},
|
||||
},
|
||||
|
||||
[PrefKey.AUDIO_MIC_ON_PLAYING]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.AUDIO_ENABLE_VOLUME_CONTROL]: {
|
||||
'default': false,
|
||||
'experimental': true,
|
||||
},
|
||||
[PrefKey.AUDIO_VOLUME]: {
|
||||
'type': SettingElementType.NUMBER_STEPPER,
|
||||
'default': 100,
|
||||
'min': 0,
|
||||
'max': 600,
|
||||
'params': {
|
||||
suffix: '%',
|
||||
ticks: 100,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
[PrefKey.STATS_ITEMS]: {
|
||||
'default': [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST],
|
||||
'multiple_options': {
|
||||
[StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`,
|
||||
[StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`,
|
||||
[StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`,
|
||||
[StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`,
|
||||
[StreamStat.PACKETS_LOST]: `${StreamStat.PACKETS_LOST.toUpperCase()}: ${t('stat-packets-lost')}`,
|
||||
[StreamStat.FRAMES_LOST]: `${StreamStat.FRAMES_LOST.toUpperCase()}: ${t('stat-frames-lost')}`,
|
||||
},
|
||||
'params': {
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
[PrefKey.STATS_SHOW_WHEN_PLAYING]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.STATS_QUICK_GLANCE]: {
|
||||
'default': true,
|
||||
},
|
||||
[PrefKey.STATS_POSITION]: {
|
||||
'default': 'top-right',
|
||||
'options': {
|
||||
'top-left': t('top-left'),
|
||||
'top-center': t('top-center'),
|
||||
'top-right': t('top-right'),
|
||||
},
|
||||
},
|
||||
[PrefKey.STATS_TEXT_SIZE]: {
|
||||
'default': '0.9rem',
|
||||
'options': {
|
||||
'0.9rem': t('small'),
|
||||
'1.0rem': t('normal'),
|
||||
'1.1rem': t('large'),
|
||||
},
|
||||
},
|
||||
[PrefKey.STATS_TRANSPARENT]: {
|
||||
'default': false,
|
||||
},
|
||||
[PrefKey.STATS_OPACITY]: {
|
||||
'type': SettingElementType.NUMBER_STEPPER,
|
||||
'default': 80,
|
||||
'min': 50,
|
||||
'max': 100,
|
||||
'params': {
|
||||
suffix: '%',
|
||||
ticks: 10,
|
||||
},
|
||||
},
|
||||
[PrefKey.STATS_CONDITIONAL_FORMATTING]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.REMOTE_PLAY_ENABLED]: {
|
||||
'default': false,
|
||||
},
|
||||
|
||||
[PrefKey.REMOTE_PLAY_RESOLUTION]: {
|
||||
'default': '1080p',
|
||||
'options': {
|
||||
'1080p': '1080p',
|
||||
'720p': '720p',
|
||||
},
|
||||
},
|
||||
|
||||
[PrefKey.GAME_FORTNITE_FORCE_CONSOLE]: {
|
||||
'default': false,
|
||||
'note': t('fortnite-allow-stw-mode'),
|
||||
},
|
||||
|
||||
// Deprecated
|
||||
/*
|
||||
[Preferences.DEPRECATED_CONTROLLER_SUPPORT_LOCAL_CO_OP]: {
|
||||
'default': false,
|
||||
'migrate': function(savedPrefs, value) {
|
||||
this.set(Preferences.LOCAL_CO_OP_ENABLED, value);
|
||||
savedPrefs[Preferences.LOCAL_CO_OP_ENABLED] = value;
|
||||
},
|
||||
},
|
||||
*/
|
||||
}
|
||||
|
||||
#storage = localStorage;
|
||||
#key = 'better_xcloud';
|
||||
#prefs: {[index: string]: any} = {};
|
||||
|
||||
constructor() {
|
||||
let savedPrefsStr = this.#storage.getItem(this.#key);
|
||||
if (savedPrefsStr == null) {
|
||||
savedPrefsStr = '{}';
|
||||
}
|
||||
|
||||
const savedPrefs = JSON.parse(savedPrefsStr);
|
||||
|
||||
for (let settingId in Preferences.SETTINGS) {
|
||||
const setting = Preferences.SETTINGS[settingId];
|
||||
setting.ready && setting.ready.call(this);
|
||||
|
||||
if (setting.migrate && settingId in savedPrefs) {
|
||||
setting.migrate.call(this, savedPrefs, savedPrefs[settingId]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let settingId in Preferences.SETTINGS) {
|
||||
const setting = Preferences.SETTINGS[settingId];
|
||||
if (!setting) {
|
||||
alert(`Undefined setting key: ${settingId}`);
|
||||
console.log('Undefined setting key');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore deprecated settings
|
||||
if (setting.migrate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (settingId in savedPrefs) {
|
||||
this.#prefs[settingId] = this.#validateValue(settingId, savedPrefs[settingId]);
|
||||
} else {
|
||||
this.#prefs[settingId] = setting.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#validateValue(key: keyof typeof Preferences.SETTINGS, value: any) {
|
||||
const config = Preferences.SETTINGS[key];
|
||||
if (!config) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
value = config.default;
|
||||
}
|
||||
|
||||
if ('min' in config) {
|
||||
value = Math.max(config.min!, value);
|
||||
}
|
||||
|
||||
if ('max' in config) {
|
||||
value = Math.min(config.max!, value);
|
||||
}
|
||||
|
||||
if ('options' in config && !(value in config.options!)) {
|
||||
value = config.default;
|
||||
} else if ('multiple_options' in config) {
|
||||
if (value.length) {
|
||||
const validOptions = Object.keys(config.multiple_options!);
|
||||
value.forEach((item: any, idx: number) => {
|
||||
(validOptions.indexOf(item) === -1) && value.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
|
||||
if (!value.length) {
|
||||
value = config.default;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
get(key: PrefKey) {
|
||||
if (typeof key === 'undefined') {
|
||||
debugger;
|
||||
return;
|
||||
}
|
||||
|
||||
// Return default value if the feature is not supported
|
||||
if (Preferences.SETTINGS[key].unsupported) {
|
||||
return Preferences.SETTINGS[key].default;
|
||||
}
|
||||
|
||||
if (!(key in this.#prefs)) {
|
||||
this.#prefs[key] = this.#validateValue(key, null);
|
||||
}
|
||||
|
||||
return this.#prefs[key];
|
||||
}
|
||||
|
||||
set(key: PrefKey, value: any) {
|
||||
value = this.#validateValue(key, value);
|
||||
|
||||
this.#prefs[key] = value;
|
||||
this.#updateStorage();
|
||||
}
|
||||
|
||||
#updateStorage() {
|
||||
this.#storage.setItem(this.#key, JSON.stringify(this.#prefs));
|
||||
}
|
||||
|
||||
toElement(key: keyof typeof Preferences.SETTINGS, onChange: any, overrideParams={}) {
|
||||
const setting = Preferences.SETTINGS[key];
|
||||
let currentValue = this.get(key as string);
|
||||
|
||||
let $control;
|
||||
let type;
|
||||
if ('type' in setting) {
|
||||
type = setting.type;
|
||||
} else if ('options' in setting) {
|
||||
type = SettingElementType.OPTIONS;
|
||||
} else if ('multiple_options' in setting) {
|
||||
type = SettingElementType.MULTIPLE_OPTIONS;
|
||||
} else if (typeof setting.default === 'number') {
|
||||
type = SettingElementType.NUMBER;
|
||||
} else {
|
||||
type = SettingElementType.CHECKBOX;
|
||||
}
|
||||
|
||||
const params = Object.assign(overrideParams, setting.params || {});
|
||||
if (params.disabled) {
|
||||
currentValue = Preferences.SETTINGS[key].default;
|
||||
}
|
||||
|
||||
$control = SettingElement.render(type!, key as string, setting, currentValue, (e: any, value: any) => {
|
||||
this.set(key as string, value);
|
||||
onChange && onChange(e, value);
|
||||
}, params);
|
||||
|
||||
return $control;
|
||||
}
|
||||
|
||||
toNumberStepper(key: string, onChange: any, options={}) {
|
||||
return SettingElement.render(SettingElementType.NUMBER_STEPPER, key, Preferences.SETTINGS[key], this.get(key), (e: any, value: any) => {
|
||||
this.set(key, value);
|
||||
onChange && onChange(e, value);
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const PREFS = new Preferences();
|
||||
export const getPref = PREFS.get.bind(PREFS);
|
||||
export const setPref = PREFS.set.bind(PREFS);
|
267
src/modules/settings.ts
Normal file
267
src/modules/settings.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import { CE } from "../utils/html";
|
||||
import type { PreferenceSetting } from "./preferences";
|
||||
|
||||
type MultipleOptionsParams = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
type NumberStepperParams = {
|
||||
suffix?: string;
|
||||
disabled?: boolean;
|
||||
hideSlider?: boolean;
|
||||
|
||||
ticks?: number;
|
||||
exactTicks?: number;
|
||||
}
|
||||
|
||||
export enum SettingElementType {
|
||||
OPTIONS = 'options',
|
||||
MULTIPLE_OPTIONS = 'multiple-options',
|
||||
NUMBER = 'number',
|
||||
NUMBER_STEPPER = 'number-stepper',
|
||||
CHECKBOX = 'checkbox',
|
||||
}
|
||||
|
||||
export class SettingElement {
|
||||
static #renderOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) {
|
||||
const $control = CE<HTMLSelectElement>('select') as HTMLSelectElement;
|
||||
for (let value in setting.options) {
|
||||
const label = setting.options[value];
|
||||
|
||||
const $option = CE<HTMLOptionElement>('option', {value: value}, label);
|
||||
$control.appendChild($option);
|
||||
}
|
||||
|
||||
$control.value = currentValue;
|
||||
onChange && $control.addEventListener('change', e => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
const value = (setting.type && setting.type === 'number') ? parseInt(target.value) : target.value;
|
||||
onChange(e, value);
|
||||
});
|
||||
|
||||
// Custom method
|
||||
($control as any).setValue = (value: any) => {
|
||||
$control.value = value;
|
||||
};
|
||||
|
||||
return $control;
|
||||
}
|
||||
|
||||
static #renderMultipleOptions(key: string, setting: PreferenceSetting, currentValue: any, onChange: any, params: MultipleOptionsParams={}) {
|
||||
const $control = CE<HTMLSelectElement>('select', {'multiple': true});
|
||||
if (params && params.size) {
|
||||
$control.setAttribute('size', params.size.toString());
|
||||
}
|
||||
|
||||
for (let value in setting.multiple_options) {
|
||||
const label = setting.multiple_options[value];
|
||||
|
||||
const $option = CE<HTMLOptionElement>('option', {value: value}, label) as HTMLOptionElement;
|
||||
$option.selected = currentValue.indexOf(value) > -1;
|
||||
|
||||
$option.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as HTMLOptionElement;
|
||||
target.selected = !target.selected;
|
||||
|
||||
const $parent = target.parentElement!;
|
||||
$parent.focus();
|
||||
$parent.dispatchEvent(new Event('change'));
|
||||
});
|
||||
|
||||
$control.appendChild($option);
|
||||
}
|
||||
|
||||
$control.addEventListener('mousedown', function(e) {
|
||||
const self = this;
|
||||
const orgScrollTop = self.scrollTop;
|
||||
setTimeout(() => (self.scrollTop = orgScrollTop), 0);
|
||||
});
|
||||
|
||||
$control.addEventListener('mousemove', e => e.preventDefault());
|
||||
|
||||
onChange && $control.addEventListener('change', (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement
|
||||
const values = Array.from(target.selectedOptions).map(i => i.value);
|
||||
onChange(e, values);
|
||||
});
|
||||
|
||||
return $control;
|
||||
}
|
||||
|
||||
static #renderNumber(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) {
|
||||
const $control = CE('input', {'type': 'number', 'min': setting.min, 'max': setting.max}) as HTMLInputElement;
|
||||
$control.value = currentValue;
|
||||
onChange && $control.addEventListener('change', (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
const value = Math.max(setting.min!, Math.min(setting.max!, parseInt(target.value)));
|
||||
target.value = value.toString();
|
||||
|
||||
onChange(e, value);
|
||||
});
|
||||
|
||||
return $control;
|
||||
}
|
||||
|
||||
static #renderCheckbox(key: string, setting: PreferenceSetting, currentValue: any, onChange: any) {
|
||||
const $control = CE('input', {'type': 'checkbox'}) as HTMLInputElement;
|
||||
$control.checked = currentValue;
|
||||
|
||||
onChange && $control.addEventListener('change', e => {
|
||||
onChange(e, (e.target as HTMLInputElement).checked);
|
||||
});
|
||||
|
||||
return $control;
|
||||
}
|
||||
|
||||
static #renderNumberStepper(key: string, setting: PreferenceSetting, value: any, onChange: any, options: NumberStepperParams={}) {
|
||||
options = options || {};
|
||||
options.suffix = options.suffix || '';
|
||||
options.disabled = !!options.disabled;
|
||||
options.hideSlider = !!options.hideSlider;
|
||||
|
||||
let $text: HTMLSpanElement;
|
||||
let $decBtn: HTMLButtonElement;
|
||||
let $incBtn: HTMLButtonElement;
|
||||
let $range: HTMLInputElement;
|
||||
|
||||
const MIN = setting.min!;
|
||||
const MAX = setting.max!;
|
||||
const STEPS = Math.max(setting.steps || 1, 1);
|
||||
|
||||
const $wrapper = CE('div', {'class': 'bx-number-stepper'},
|
||||
$decBtn = CE('button', {'data-type': 'dec'}, '-') as HTMLButtonElement,
|
||||
$text = CE('span', {}, value + options.suffix) as HTMLSpanElement,
|
||||
$incBtn = CE('button', {'data-type': 'inc'}, '+') as HTMLButtonElement,
|
||||
);
|
||||
|
||||
if (!options.disabled && !options.hideSlider) {
|
||||
$range = CE('input', {'type': 'range', 'min': MIN, 'max': MAX, 'value': value, 'step': STEPS}) as HTMLInputElement;
|
||||
$range.addEventListener('input', e => {
|
||||
value = parseInt((e.target as HTMLInputElement).value);
|
||||
|
||||
$text.textContent = value + options.suffix;
|
||||
onChange && onChange(e, value);
|
||||
});
|
||||
$wrapper.appendChild($range);
|
||||
|
||||
if (options.ticks || options.exactTicks) {
|
||||
const markersId = `markers-${key}`;
|
||||
const $markers = CE('datalist', {'id': markersId});
|
||||
$range.setAttribute('list', markersId);
|
||||
|
||||
if (options.exactTicks) {
|
||||
let start = Math.max(Math.floor(MIN / options.exactTicks), 1) * options.exactTicks;
|
||||
|
||||
if (start === MIN) {
|
||||
start += options.exactTicks;
|
||||
}
|
||||
|
||||
for (let i = start; i < MAX; i += options.exactTicks) {
|
||||
$markers.appendChild(CE<HTMLOptionElement>('option', {'value': i}));
|
||||
}
|
||||
} else {
|
||||
for (let i = MIN + options.ticks!; i < MAX; i += options.ticks!) {
|
||||
$markers.appendChild(CE<HTMLOptionElement>('option', {'value': i}));
|
||||
}
|
||||
}
|
||||
$wrapper.appendChild($markers);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.disabled) {
|
||||
$incBtn.disabled = true;
|
||||
$incBtn.classList.add('bx-hidden');
|
||||
|
||||
$decBtn.disabled = true;
|
||||
$decBtn.classList.add('bx-hidden');
|
||||
return $wrapper;
|
||||
}
|
||||
|
||||
let interval: number;
|
||||
let isHolding = false;
|
||||
|
||||
const onClick = (e: Event) => {
|
||||
if (isHolding) {
|
||||
e.preventDefault();
|
||||
isHolding = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const btnType = (e.target as HTMLElement).getAttribute('data-type');
|
||||
if (btnType === 'dec') {
|
||||
value = Math.max(MIN, value - STEPS);
|
||||
} else {
|
||||
value = Math.min(MAX, value + STEPS);
|
||||
}
|
||||
|
||||
$text.textContent = value + options.suffix;
|
||||
$range && ($range.value = value);
|
||||
|
||||
isHolding = false;
|
||||
onChange && onChange(e, value);
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent | TouchEvent) => {
|
||||
isHolding = true;
|
||||
|
||||
const args = arguments;
|
||||
interval = setInterval(() => {
|
||||
const event = new Event('click');
|
||||
(event as any).arguments = args;
|
||||
|
||||
e.target?.dispatchEvent(event);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onMouseUp = (e: MouseEvent | TouchEvent) => {
|
||||
clearInterval(interval);
|
||||
isHolding = false;
|
||||
};
|
||||
|
||||
// Custom method
|
||||
($wrapper as any).setValue = (value: any) => {
|
||||
$text.textContent = value + options.suffix;
|
||||
$range && ($range.value = value);
|
||||
};
|
||||
|
||||
$decBtn.addEventListener('click', onClick);
|
||||
$decBtn.addEventListener('mousedown', onMouseDown);
|
||||
$decBtn.addEventListener('mouseup', onMouseUp);
|
||||
$decBtn.addEventListener('touchstart', onMouseDown);
|
||||
$decBtn.addEventListener('touchend', onMouseUp);
|
||||
|
||||
$incBtn.addEventListener('click', onClick);
|
||||
$incBtn.addEventListener('mousedown', onMouseDown);
|
||||
$incBtn.addEventListener('mouseup', onMouseUp);
|
||||
$incBtn.addEventListener('touchstart', onMouseDown);
|
||||
$incBtn.addEventListener('touchend', onMouseUp);
|
||||
|
||||
return $wrapper;
|
||||
}
|
||||
|
||||
static #METHOD_MAP = {
|
||||
[SettingElementType.OPTIONS]: SettingElement.#renderOptions,
|
||||
[SettingElementType.MULTIPLE_OPTIONS]: SettingElement.#renderMultipleOptions,
|
||||
[SettingElementType.NUMBER]: SettingElement.#renderNumber,
|
||||
[SettingElementType.NUMBER_STEPPER]: SettingElement.#renderNumberStepper,
|
||||
[SettingElementType.CHECKBOX]: SettingElement.#renderCheckbox,
|
||||
};
|
||||
|
||||
static render(type: SettingElementType, key: string, setting: PreferenceSetting, currentValue: any, onChange: any, options: any) {
|
||||
const method = SettingElement.#METHOD_MAP[type];
|
||||
// @ts-ignore
|
||||
const $control = method(...Array.from(arguments).slice(1)) as HTMLElement;
|
||||
$control.id = `bx_setting_${key}`;
|
||||
|
||||
// Add "name" property to "select" elements
|
||||
if (type === SettingElementType.OPTIONS || type === SettingElementType.MULTIPLE_OPTIONS) {
|
||||
($control as HTMLSelectElement).name = $control.id;
|
||||
}
|
||||
|
||||
return $control;
|
||||
}
|
||||
}
|
240
src/modules/stream-badges.ts
Normal file
240
src/modules/stream-badges.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import { t } from "./translation";
|
||||
import { BxEvent } from "./bx-event";
|
||||
import { CE } from "../utils/html";
|
||||
|
||||
enum StreamBadge {
|
||||
PLAYTIME = 'playtime',
|
||||
BATTERY = 'battery',
|
||||
IN = 'in',
|
||||
OUT = 'out',
|
||||
|
||||
SERVER = 'server',
|
||||
VIDEO = 'video',
|
||||
AUDIO = 'audio',
|
||||
|
||||
BREAK = 'break',
|
||||
}
|
||||
|
||||
export class StreamBadges {
|
||||
static ipv6 = false;
|
||||
static resolution?: {width: number, height: number} | null = null;
|
||||
static video?: {codec: string, profile?: string | null} | null = null;
|
||||
static audio?: {codec: string, bitrate: number} | null = null;
|
||||
static fps = 0;
|
||||
static region = '';
|
||||
|
||||
static startBatteryLevel = 100;
|
||||
static startTimestamp = 0;
|
||||
|
||||
static #cachedDoms: {[index: string]: HTMLElement} = {};
|
||||
|
||||
static #interval?: number | null;
|
||||
static get #REFRESH_INTERVAL() { return 3000; };
|
||||
|
||||
static #renderBadge(name: StreamBadge, value: string, color: string) {
|
||||
if (name === StreamBadge.BREAK) {
|
||||
return CE('div', {'style': 'display: block'});
|
||||
}
|
||||
|
||||
let $badge;
|
||||
if (StreamBadges.#cachedDoms[name]) {
|
||||
$badge = StreamBadges.#cachedDoms[name];
|
||||
$badge.lastElementChild!.textContent = value;
|
||||
return $badge;
|
||||
}
|
||||
|
||||
$badge = CE('div', {'class': 'bx-badge'},
|
||||
CE('span', {'class': 'bx-badge-name'}, t(`badge-${name}`)),
|
||||
CE('span', {'class': 'bx-badge-value', 'style': `background-color: ${color}`}, value));
|
||||
|
||||
if (name === StreamBadge.BATTERY) {
|
||||
$badge.classList.add('bx-badge-battery');
|
||||
}
|
||||
|
||||
StreamBadges.#cachedDoms[name] = $badge;
|
||||
return $badge;
|
||||
}
|
||||
|
||||
static async #updateBadges(forceUpdate: boolean) {
|
||||
if (!forceUpdate && !document.querySelector('.bx-badges')) {
|
||||
StreamBadges.#stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Playtime
|
||||
let now = +new Date;
|
||||
const diffSeconds = Math.ceil((now - StreamBadges.startTimestamp) / 1000);
|
||||
const playtime = StreamBadges.#secondsToHm(diffSeconds);
|
||||
|
||||
// Battery
|
||||
let batteryLevel = '100%';
|
||||
let batteryLevelInt = 100;
|
||||
let isCharging = false;
|
||||
if ('getBattery' in navigator) {
|
||||
try {
|
||||
const bm = await (navigator as NavigatorBattery).getBattery();
|
||||
isCharging = bm.charging;
|
||||
batteryLevelInt = Math.round(bm.level * 100);
|
||||
batteryLevel = `${batteryLevelInt}%`;
|
||||
|
||||
if (batteryLevelInt != StreamBadges.startBatteryLevel) {
|
||||
const diffLevel = Math.round(batteryLevelInt - StreamBadges.startBatteryLevel);
|
||||
const sign = diffLevel > 0 ? '+' : '';
|
||||
batteryLevel += ` (${sign}${diffLevel}%)`;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
const stats = await STREAM_WEBRTC.getStats();
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
stats.forEach(stat => {
|
||||
if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
|
||||
totalIn += stat.bytesReceived;
|
||||
totalOut += stat.bytesSent;
|
||||
}
|
||||
});
|
||||
|
||||
const badges = {
|
||||
[StreamBadge.IN]: totalIn ? StreamBadges.#humanFileSize(totalIn) : null,
|
||||
[StreamBadge.OUT]: totalOut ? StreamBadges.#humanFileSize(totalOut) : null,
|
||||
[StreamBadge.PLAYTIME]: playtime,
|
||||
[StreamBadge.BATTERY]: batteryLevel,
|
||||
};
|
||||
|
||||
let name: keyof typeof badges;
|
||||
for (name in badges) {
|
||||
const value = badges[name];
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const $elm = StreamBadges.#cachedDoms[name];
|
||||
$elm && ($elm.lastElementChild!.textContent = value);
|
||||
|
||||
if (name === StreamBadge.BATTERY) {
|
||||
// Show charging status
|
||||
$elm.setAttribute('data-charging', isCharging.toString());
|
||||
|
||||
if (StreamBadges.startBatteryLevel === 100 && batteryLevelInt === 100) {
|
||||
$elm.style.display = 'none';
|
||||
} else {
|
||||
$elm.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static #stop() {
|
||||
StreamBadges.#interval && clearInterval(StreamBadges.#interval);
|
||||
StreamBadges.#interval = null;
|
||||
}
|
||||
|
||||
static #secondsToHm(seconds: number) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor(seconds % 3600 / 60) + 1;
|
||||
|
||||
const hDisplay = h > 0 ? `${h}h`: '';
|
||||
const mDisplay = m > 0 ? `${m}m`: '';
|
||||
return hDisplay + mDisplay;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/20732091
|
||||
static #humanFileSize(size: number) {
|
||||
const units = ['B', 'kB', 'MB', 'GB', 'TB'];
|
||||
|
||||
let i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
return (size / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
||||
}
|
||||
|
||||
static async render() {
|
||||
// Video
|
||||
let video = '';
|
||||
if (StreamBadges.resolution) {
|
||||
video = `${StreamBadges.resolution.height}p`;
|
||||
}
|
||||
|
||||
if (StreamBadges.video) {
|
||||
video && (video += '/');
|
||||
video += StreamBadges.video.codec;
|
||||
if (StreamBadges.video.profile) {
|
||||
const profile = StreamBadges.video.profile;
|
||||
|
||||
let quality = profile;
|
||||
if (profile.startsWith('4d')) {
|
||||
quality = t('visual-quality-high');
|
||||
} else if (profile.startsWith('42e')) {
|
||||
quality = t('visual-quality-normal');
|
||||
} else if (profile.startsWith('420')) {
|
||||
quality = t('visual-quality-low');
|
||||
}
|
||||
|
||||
video += ` (${quality})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Audio
|
||||
let audio;
|
||||
if (StreamBadges.audio) {
|
||||
audio = StreamBadges.audio.codec;
|
||||
const bitrate = StreamBadges.audio.bitrate / 1000;
|
||||
audio += ` (${bitrate} kHz)`;
|
||||
}
|
||||
|
||||
// Battery
|
||||
let batteryLevel = '';
|
||||
if ('getBattery' in navigator) {
|
||||
batteryLevel = '100%';
|
||||
}
|
||||
|
||||
// Server + Region
|
||||
let server = StreamBadges.region;
|
||||
server += '@' + (StreamBadges.ipv6 ? 'IPv6' : 'IPv4');
|
||||
|
||||
const BADGES = [
|
||||
[StreamBadge.PLAYTIME, '1m', '#ff004d'],
|
||||
[StreamBadge.BATTERY, batteryLevel, '#00b543'],
|
||||
[StreamBadge.IN, StreamBadges.#humanFileSize(0), '#29adff'],
|
||||
[StreamBadge.OUT, StreamBadges.#humanFileSize(0), '#ff77a8'],
|
||||
[StreamBadge.BREAK],
|
||||
[StreamBadge.SERVER, server, '#ff6c24'],
|
||||
video ? [StreamBadge.VIDEO, video, '#742f29'] : null,
|
||||
audio ? [StreamBadge.AUDIO, audio, '#5f574f'] : null,
|
||||
];
|
||||
|
||||
const $wrapper = CE('div', {'class': 'bx-badges'});
|
||||
BADGES.forEach(item => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $badge = StreamBadges.#renderBadge(...(item as [StreamBadge, string, string]));
|
||||
$wrapper.appendChild($badge);
|
||||
});
|
||||
|
||||
await StreamBadges.#updateBadges(true);
|
||||
StreamBadges.#stop();
|
||||
StreamBadges.#interval = setInterval(StreamBadges.#updateBadges, StreamBadges.#REFRESH_INTERVAL);
|
||||
|
||||
return $wrapper;
|
||||
}
|
||||
|
||||
static setupEvents() {
|
||||
window.addEventListener(BxEvent.STREAM_PLAYING, e => {
|
||||
const $video = (e as any).$video;
|
||||
|
||||
StreamBadges.resolution = {
|
||||
width: $video.videoWidth,
|
||||
height: $video.videoHeight
|
||||
};
|
||||
StreamBadges.startTimestamp = +new Date;
|
||||
|
||||
// Get battery level
|
||||
try {
|
||||
'getBattery' in navigator && (navigator as NavigatorBattery).getBattery().then(bm => {
|
||||
StreamBadges.startBatteryLevel = Math.round(bm.level * 100);
|
||||
});
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
}
|
300
src/modules/stream-stats.ts
Normal file
300
src/modules/stream-stats.ts
Normal file
@ -0,0 +1,300 @@
|
||||
import { Preferences } from "./preferences"
|
||||
import { BxEvent } from "./bx-event"
|
||||
import { getPref } from "./preferences"
|
||||
import { StreamBadges } from "./stream-badges"
|
||||
import { CE } from "../utils/html"
|
||||
import { t } from "./translation"
|
||||
|
||||
export enum StreamStat {
|
||||
PING = 'ping',
|
||||
FPS = 'fps',
|
||||
BITRATE = 'btr',
|
||||
DECODE_TIME = 'dt',
|
||||
PACKETS_LOST = 'pl',
|
||||
FRAMES_LOST = 'fl',
|
||||
};
|
||||
|
||||
export class StreamStats {
|
||||
static #interval?: number | null;
|
||||
static #updateInterval = 1000;
|
||||
|
||||
static #$container: HTMLElement;
|
||||
static #$fps: HTMLElement;
|
||||
static #$ping: HTMLElement;
|
||||
static #$dt: HTMLElement;
|
||||
static #$pl: HTMLElement;
|
||||
static #$fl: HTMLElement;
|
||||
static #$br: HTMLElement;
|
||||
|
||||
static #lastStat?: RTCBasicStat | null;
|
||||
|
||||
static #quickGlanceObserver?: MutationObserver | null;
|
||||
|
||||
static start(glancing=false) {
|
||||
if (!StreamStats.isHidden() || (glancing && StreamStats.isGlancing())) {
|
||||
return;
|
||||
}
|
||||
|
||||
StreamStats.#$container.classList.remove('bx-gone');
|
||||
StreamStats.#$container.setAttribute('data-display', glancing ? 'glancing' : 'fixed');
|
||||
|
||||
StreamStats.#interval = setInterval(StreamStats.update, StreamStats.#updateInterval);
|
||||
}
|
||||
|
||||
static stop(glancing=false) {
|
||||
if (glancing && !StreamStats.isGlancing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
StreamStats.#interval && clearInterval(StreamStats.#interval);
|
||||
StreamStats.#interval = null;
|
||||
StreamStats.#lastStat = null;
|
||||
|
||||
if (StreamStats.#$container) {
|
||||
StreamStats.#$container.removeAttribute('data-display');
|
||||
StreamStats.#$container.classList.add('bx-gone');
|
||||
}
|
||||
}
|
||||
|
||||
static toggle() {
|
||||
if (StreamStats.isGlancing()) {
|
||||
StreamStats.#$container.setAttribute('data-display', 'fixed');
|
||||
} else {
|
||||
StreamStats.isHidden() ? StreamStats.start() : StreamStats.stop();
|
||||
}
|
||||
}
|
||||
|
||||
static onStoppedPlaying() {
|
||||
StreamStats.stop();
|
||||
StreamStats.quickGlanceStop();
|
||||
StreamStats.hideSettingsUi();
|
||||
}
|
||||
|
||||
static isHidden = () => StreamStats.#$container && StreamStats.#$container.classList.contains('bx-gone');
|
||||
static isGlancing = () => StreamStats.#$container && StreamStats.#$container.getAttribute('data-display') === 'glancing';
|
||||
|
||||
static quickGlanceSetup() {
|
||||
if (StreamStats.#quickGlanceObserver) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $uiContainer = document.querySelector('div[data-testid=ui-container]')!;
|
||||
StreamStats.#quickGlanceObserver = new MutationObserver((mutationList, observer) => {
|
||||
for (let record of mutationList) {
|
||||
if (record.attributeName && record.attributeName === 'aria-expanded') {
|
||||
const expanded = (record.target as HTMLElement).ariaExpanded;
|
||||
if (expanded === 'true') {
|
||||
StreamStats.isHidden() && StreamStats.start(true);
|
||||
} else {
|
||||
StreamStats.stop(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
StreamStats.#quickGlanceObserver.observe($uiContainer, {
|
||||
attributes: true,
|
||||
attributeFilter: ['aria-expanded'],
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
static quickGlanceStop() {
|
||||
StreamStats.#quickGlanceObserver && StreamStats.#quickGlanceObserver.disconnect();
|
||||
StreamStats.#quickGlanceObserver = null;
|
||||
}
|
||||
|
||||
static update() {
|
||||
if (StreamStats.isHidden() || !STREAM_WEBRTC) {
|
||||
StreamStats.onStoppedPlaying();
|
||||
return;
|
||||
}
|
||||
|
||||
const PREF_STATS_CONDITIONAL_FORMATTING = getPref(Preferences.STATS_CONDITIONAL_FORMATTING);
|
||||
STREAM_WEBRTC.getStats().then(stats => {
|
||||
stats.forEach(stat => {
|
||||
let grade = '';
|
||||
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
|
||||
// FPS
|
||||
StreamStats.#$fps.textContent = stat.framesPerSecond || 0;
|
||||
|
||||
// Packets Lost
|
||||
const packetsLost = stat.packetsLost;
|
||||
const packetsReceived = stat.packetsReceived;
|
||||
const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2);
|
||||
StreamStats.#$pl.textContent = packetsLostPercentage === '0.00' ? packetsLost : `${packetsLost} (${packetsLostPercentage}%)`;
|
||||
|
||||
// Frames Dropped
|
||||
const framesDropped = stat.framesDropped;
|
||||
const framesReceived = stat.framesReceived;
|
||||
const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2);
|
||||
StreamStats.#$fl.textContent = framesDroppedPercentage === '0.00' ? framesDropped : `${framesDropped} (${framesDroppedPercentage}%)`;
|
||||
|
||||
if (StreamStats.#lastStat) {
|
||||
const lastStat = StreamStats.#lastStat;
|
||||
// Bitrate
|
||||
const timeDiff = stat.timestamp - lastStat.timestamp;
|
||||
const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000;
|
||||
StreamStats.#$br.textContent = `${bitrate.toFixed(2)} Mbps`;
|
||||
|
||||
// Decode time
|
||||
const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime;
|
||||
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded;
|
||||
const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000;
|
||||
StreamStats.#$dt.textContent = `${currentDecodeTime.toFixed(2)}ms`;
|
||||
|
||||
if (PREF_STATS_CONDITIONAL_FORMATTING) {
|
||||
grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : '';
|
||||
}
|
||||
StreamStats.#$dt.setAttribute('data-grade', grade);
|
||||
}
|
||||
|
||||
StreamStats.#lastStat = stat;
|
||||
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
|
||||
// Round Trip Time
|
||||
const roundTripTime = typeof stat.currentRoundTripTime !== 'undefined' ? stat.currentRoundTripTime * 1000 : -1;
|
||||
StreamStats.#$ping.textContent = roundTripTime === -1 ? '???' : roundTripTime.toString();
|
||||
|
||||
if (PREF_STATS_CONDITIONAL_FORMATTING) {
|
||||
grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : '';
|
||||
}
|
||||
StreamStats.#$ping.setAttribute('data-grade', grade);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static refreshStyles() {
|
||||
const PREF_ITEMS = getPref(Preferences.STATS_ITEMS);
|
||||
const PREF_POSITION = getPref(Preferences.STATS_POSITION);
|
||||
const PREF_TRANSPARENT = getPref(Preferences.STATS_TRANSPARENT);
|
||||
const PREF_OPACITY = getPref(Preferences.STATS_OPACITY);
|
||||
const PREF_TEXT_SIZE = getPref(Preferences.STATS_TEXT_SIZE);
|
||||
|
||||
const $container = StreamStats.#$container;
|
||||
$container.setAttribute('data-stats', '[' + PREF_ITEMS.join('][') + ']');
|
||||
$container.setAttribute('data-position', PREF_POSITION);
|
||||
$container.setAttribute('data-transparent', PREF_TRANSPARENT);
|
||||
$container.style.opacity = PREF_OPACITY + '%';
|
||||
$container.style.fontSize = PREF_TEXT_SIZE;
|
||||
}
|
||||
|
||||
static hideSettingsUi() {
|
||||
if (StreamStats.isGlancing() && !getPref(Preferences.STATS_QUICK_GLANCE)) {
|
||||
StreamStats.stop();
|
||||
}
|
||||
}
|
||||
|
||||
static render() {
|
||||
if (StreamStats.#$container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const STATS = {
|
||||
[StreamStat.PING]: [t('stat-ping'), StreamStats.#$ping = CE('span', {}, '0')],
|
||||
[StreamStat.FPS]: [t('stat-fps'), StreamStats.#$fps = CE('span', {}, '0')],
|
||||
[StreamStat.BITRATE]: [t('stat-bitrate'), StreamStats.#$br = CE('span', {}, '0 Mbps')],
|
||||
[StreamStat.DECODE_TIME]: [t('stat-decode-time'), StreamStats.#$dt = CE('span', {}, '0ms')],
|
||||
[StreamStat.PACKETS_LOST]: [t('stat-packets-lost'), StreamStats.#$pl = CE('span', {}, '0')],
|
||||
[StreamStat.FRAMES_LOST]: [t('stat-frames-lost'), StreamStats.#$fl = CE('span', {}, '0')],
|
||||
};
|
||||
|
||||
const $barFragment = document.createDocumentFragment();
|
||||
let statKey: keyof typeof STATS
|
||||
for (statKey in STATS) {
|
||||
const $div = CE('div', {'class': `bx-stat-${statKey}`, title: STATS[statKey][0]}, CE('label', {}, statKey.toUpperCase()), STATS[statKey][1]);
|
||||
$barFragment.appendChild($div);
|
||||
}
|
||||
|
||||
StreamStats.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment);
|
||||
document.documentElement.appendChild(StreamStats.#$container);
|
||||
|
||||
StreamStats.refreshStyles();
|
||||
}
|
||||
|
||||
static getServerStats() {
|
||||
STREAM_WEBRTC && STREAM_WEBRTC.getStats().then(stats => {
|
||||
const allVideoCodecs: {[index: string]: RTCBasicStat} = {};
|
||||
let videoCodecId;
|
||||
|
||||
const allAudioCodecs: {[index: string]: RTCBasicStat} = {};
|
||||
let audioCodecId;
|
||||
|
||||
const allCandidates: {[index: string]: string} = {};
|
||||
let candidateId;
|
||||
|
||||
stats.forEach((stat: RTCBasicStat) => {
|
||||
if (stat.type === 'codec') {
|
||||
const mimeType = stat.mimeType.split('/');
|
||||
if (mimeType[0] === 'video') {
|
||||
// Store all video stats
|
||||
allVideoCodecs[stat.id] = stat;
|
||||
} else if (mimeType[0] === 'audio') {
|
||||
// Store all audio stats
|
||||
allAudioCodecs[stat.id] = stat;
|
||||
}
|
||||
} else if (stat.type === 'inbound-rtp' && stat.packetsReceived > 0) {
|
||||
// Get the codecId of the video/audio track currently being used
|
||||
if (stat.kind === 'video') {
|
||||
videoCodecId = stat.codecId;
|
||||
} else if (stat.kind === 'audio') {
|
||||
audioCodecId = stat.codecId;
|
||||
}
|
||||
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
|
||||
candidateId = stat.remoteCandidateId;
|
||||
} else if (stat.type === 'remote-candidate') {
|
||||
allCandidates[stat.id] = stat.address;
|
||||
}
|
||||
});
|
||||
|
||||
// Get video codec from codecId
|
||||
if (videoCodecId) {
|
||||
const videoStat = allVideoCodecs[videoCodecId];
|
||||
const video: typeof StreamBadges.video = {
|
||||
codec: videoStat.mimeType.substring(6),
|
||||
};
|
||||
|
||||
if (video.codec === 'H264') {
|
||||
const match = /profile-level-id=([0-9a-f]{6})/.exec(videoStat.sdpFmtpLine);
|
||||
video.profile = match ? match[1] : null;
|
||||
}
|
||||
|
||||
StreamBadges.video = video;
|
||||
}
|
||||
|
||||
// Get audio codec from codecId
|
||||
if (audioCodecId) {
|
||||
const audioStat = allAudioCodecs[audioCodecId];
|
||||
StreamBadges.audio = {
|
||||
codec: audioStat.mimeType.substring(6),
|
||||
bitrate: audioStat.clockRate,
|
||||
}
|
||||
}
|
||||
|
||||
// Get server type
|
||||
if (candidateId) {
|
||||
console.log('candidate', candidateId, allCandidates);
|
||||
StreamBadges.ipv6 = allCandidates[candidateId].includes(':');
|
||||
}
|
||||
|
||||
if (getPref(Preferences.STATS_SHOW_WHEN_PLAYING)) {
|
||||
StreamStats.start();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static setupEvents() {
|
||||
window.addEventListener(BxEvent.STREAM_PLAYING, e => {
|
||||
const PREF_STATS_QUICK_GLANCE = getPref(Preferences.STATS_QUICK_GLANCE);
|
||||
const PREF_STATS_SHOW_WHEN_PLAYING = getPref(Preferences.STATS_SHOW_WHEN_PLAYING);
|
||||
|
||||
StreamStats.getServerStats();
|
||||
// Setup Stat's Quick Glance mode
|
||||
if (PREF_STATS_QUICK_GLANCE) {
|
||||
StreamStats.quickGlanceSetup();
|
||||
// Show stats bar
|
||||
!PREF_STATS_SHOW_WHEN_PLAYING && StreamStats.start(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
3226
src/modules/translation.ts
Normal file
3226
src/modules/translation.ts
Normal file
File diff suppressed because it is too large
Load Diff
45
src/types/index.d.ts
vendored
Normal file
45
src/types/index.d.ts
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
// Get type of an array's element
|
||||
type ArrayElement<ArrayType extends readonly unknown[]> = ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
|
||||
|
||||
interface Window {
|
||||
AppInterface: any;
|
||||
BX_FLAGS?: BxFlags;
|
||||
BX_CE: (elmName: string, props: {[index: string]: any}={}) => HTMLElement;
|
||||
BX_EXPOSED: any;
|
||||
}
|
||||
|
||||
interface NavigatorBattery extends Navigator {
|
||||
getBattery: () => Promise<{
|
||||
charging: boolean,
|
||||
level: float,
|
||||
}>,
|
||||
}
|
||||
|
||||
type RTCBasicStat = {
|
||||
address: string,
|
||||
bytesReceived: number,
|
||||
clockRate: number,
|
||||
codecId: string,
|
||||
framesDecoded: number,
|
||||
id: string,
|
||||
kind: string,
|
||||
mimeType: string,
|
||||
packetsReceived: number,
|
||||
profile: string,
|
||||
remoteCandidateId: string,
|
||||
sdpFmtpLine: string,
|
||||
state: string,
|
||||
timestamp: number,
|
||||
totalDecodeTime: number,
|
||||
type: string,
|
||||
}
|
||||
|
||||
type BxStates = {
|
||||
isPlaying: boolean;
|
||||
appContext: any | null;
|
||||
}
|
||||
|
||||
declare var window: Window & typeof globalThis;
|
||||
declare var AppInterface: any;
|
||||
declare var STREAM_WEBRTC: RTCPeerConnection;
|
||||
declare var States: BxStates;
|
1403
src/utils/css.ts
Normal file
1403
src/utils/css.ts
Normal file
File diff suppressed because it is too large
Load Diff
126
src/utils/html.ts
Normal file
126
src/utils/html.ts
Normal file
@ -0,0 +1,126 @@
|
||||
type BxButton = {
|
||||
style?: number | string;
|
||||
url?: string;
|
||||
classes?: string[];
|
||||
icon?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: EventListener;
|
||||
}
|
||||
|
||||
// Quickly create a tree of elements without having to use innerHTML
|
||||
function createElement<T=HTMLElement>(elmName: string, props: {[index: string]: any}={}, ..._: any): T {
|
||||
let $elm;
|
||||
const hasNs = 'xmlns' in props;
|
||||
|
||||
if (hasNs) {
|
||||
$elm = document.createElementNS(props.xmlns, elmName);
|
||||
delete props.xmlns;
|
||||
} else {
|
||||
$elm = document.createElement(elmName);
|
||||
}
|
||||
|
||||
for (const key in props) {
|
||||
if ($elm.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasNs) {
|
||||
$elm.setAttributeNS(null, key, props[key]);
|
||||
} else {
|
||||
$elm.setAttribute(key, props[key]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 2, size = arguments.length; i < size; i++) {
|
||||
const arg = arguments[i];
|
||||
const argType = typeof arg;
|
||||
|
||||
if (argType === 'string' || argType === 'number') {
|
||||
$elm.appendChild(document.createTextNode(arg));
|
||||
} else if (arg) {
|
||||
$elm.appendChild(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return $elm as T;
|
||||
}
|
||||
|
||||
export const CE = createElement;
|
||||
|
||||
// Credit: https://phosphoricons.com
|
||||
export const Icon = {
|
||||
STREAM_SETTINGS: '<g transform="matrix(.142357 0 0 .142357 -2.22021 -2.22164)" fill="none" stroke="#fff" stroke-width="16"><circle cx="128" cy="128" r="40"/><path d="M130.05 206.11h-4L94 224c-12.477-4.197-24.049-10.711-34.11-19.2l-.12-36c-.71-1.12-1.38-2.25-2-3.41L25.9 147.24a99.16 99.16 0 0 1 0-38.46l31.84-18.1c.65-1.15 1.32-2.29 2-3.41l.16-36C69.951 42.757 81.521 36.218 94 32l32 17.89h4L162 32c12.477 4.197 24.049 10.711 34.11 19.2l.12 36c.71 1.12 1.38 2.25 2 3.41l31.85 18.14a99.16 99.16 0 0 1 0 38.46l-31.84 18.1c-.65 1.15-1.32 2.29-2 3.41l-.16 36A104.59 104.59 0 0 1 162 224l-31.95-17.89z"/></g>',
|
||||
STREAM_STATS: '<path d="M1.181 24.55v-3.259c0-8.19 6.576-14.952 14.767-14.98H16c8.13 0 14.819 6.69 14.819 14.819v3.42c0 .625-.515 1.14-1.14 1.14H2.321c-.625 0-1.14-.515-1.14-1.14z"/><path d="M16 6.311v4.56M12.58 25.69l9.12-12.54m4.559 5.7h4.386m-29.266 0H5.74"/>',
|
||||
CONTROLLER: '<path d="M19.193 12.807h3.193m-13.836 0h4.257"/><path d="M10.678 10.678v4.257"/><path d="M13.061 19.193l-5.602 6.359c-.698.698-1.646 1.09-2.633 1.09-2.044 0-3.725-1.682-3.725-3.725a3.73 3.73 0 0 1 .056-.646l2.177-11.194a6.94 6.94 0 0 1 6.799-5.721h11.722c3.795 0 6.918 3.123 6.918 6.918s-3.123 6.918-6.918 6.918h-8.793z"/><path d="M18.939 19.193l5.602 6.359c.698.698 1.646 1.09 2.633 1.09 2.044 0 3.725-1.682 3.725-3.725a3.73 3.73 0 0 0-.056-.646l-2.177-11.194"/>',
|
||||
DISPLAY: '<path d="M1.238 21.119c0 1.928 1.565 3.493 3.493 3.493H27.27c1.928 0 3.493-1.565 3.493-3.493V5.961c0-1.928-1.565-3.493-3.493-3.493H4.731c-1.928 0-3.493 1.565-3.493 3.493v15.158zm19.683 8.413H11.08"/>',
|
||||
MOUSE: '<path d="M26.256 8.185c0-3.863-3.137-7-7-7h-6.512c-3.863 0-7 3.137-7 7v15.629c0 3.863 3.137 7 7 7h6.512c3.863 0 7-3.137 7-7V8.185z"/><path d="M16 13.721V6.883"/>',
|
||||
MOUSE_SETTINGS: '<g transform="matrix(1.10403 0 0 1.10403 -4.17656 -.560429)" fill="none" stroke="#fff"><g stroke-width="1.755"><path d="M24.49 16.255l.01-8.612A6.15 6.15 0 0 0 18.357 1.5h-5.714A6.15 6.15 0 0 0 6.5 7.643v13.715a6.15 6.15 0 0 0 6.143 6.143h5.714"/><path d="M15.5 12.501v-6"/></g><circle cx="48" cy="48" r="15" stroke-width="7.02" transform="matrix(.142357 0 0 .142357 17.667421 16.541885)"/><path d="M24.61 27.545h-.214l-1.711.955c-.666-.224-1.284-.572-1.821-1.025l-.006-1.922-.107-.182-1.701-.969c-.134-.678-.134-1.375 0-2.053l1.7-.966.107-.182.009-1.922c.537-.454 1.154-.803 1.82-1.029l1.708.955h.214l1.708-.955c.666.224 1.284.572 1.821 1.025l.006 1.922.107.182 1.7.968c.134.678.134 1.375 0 2.053l-1.7.966-.107.182-.009 1.922c-.536.455-1.154.804-1.819 1.029l-1.706-.955z" stroke-width=".999"/></g>',
|
||||
NEW: '<path d="M26.875 30.5H5.125c-.663 0-1.208-.545-1.208-1.208V2.708c0-.663.545-1.208 1.208-1.208h14.5l8.458 8.458v19.333c0 .663-.545 1.208-1.208 1.208z"/><path d="M19.625 1.5v8.458h8.458m-15.708 9.667h7.25"/><path d="M16 16v7.25"/>',
|
||||
COPY: '<path d="M1.498 6.772h23.73v23.73H1.498zm5.274-5.274h23.73v23.73"/>',
|
||||
TRASH: '<path d="M29.5 6.182h-27m9.818 7.363v9.818m7.364-9.818v9.818"/><path d="M27.045 6.182V29.5c0 .673-.554 1.227-1.227 1.227H6.182c-.673 0-1.227-.554-1.227-1.227V6.182m17.181 0V3.727a2.47 2.47 0 0 0-2.455-2.455h-7.364a2.47 2.47 0 0 0-2.455 2.455v2.455"/>',
|
||||
CURSOR_TEXT: '<path d="M16 7.3a5.83 5.83 0 0 1 5.8-5.8h2.9m0 29h-2.9a5.83 5.83 0 0 1-5.8-5.8"/><path d="M7.3 30.5h2.9a5.83 5.83 0 0 0 5.8-5.8V7.3a5.83 5.83 0 0 0-5.8-5.8H7.3"/><path d="M11.65 16h8.7"/>',
|
||||
QUESTION: '<g transform="matrix(.256867 0 0 .256867 -16.878964 -18.049342)"><circle cx="128" cy="180" r="12" fill="#fff"/><path d="M128 144v-8c17.67 0 32-12.54 32-28s-14.33-28-32-28-32 12.54-32 28v4" fill="none" stroke="#fff" stroke-width="16"/></g>',
|
||||
|
||||
REMOTE_PLAY: '<g transform="matrix(.492308 0 0 .581818 -14.7692 -11.6364)"><clipPath id="A"><path d="M30 20h65v55H30z"/></clipPath><g clip-path="url(#A)"><g transform="matrix(.395211 0 0 .334409 11.913 7.01124)"><g transform="matrix(.555556 0 0 .555556 57.8889 -20.2417)" fill="none" stroke="#fff" stroke-width="13.88"><path d="M200 140.564c-42.045-33.285-101.955-33.285-144 0M168 165c-23.783-17.3-56.217-17.3-80 0"/></g><g transform="matrix(-.555556 0 0 -.555556 200.111 262.393)"><g transform="matrix(1 0 0 1 0 11.5642)"><path d="M200 129c-17.342-13.728-37.723-21.795-58.636-24.198C111.574 101.378 80.703 109.444 56 129" fill="none" stroke="#fff" stroke-width="13.88"/></g><path d="M168 165c-23.783-17.3-56.217-17.3-80 0" fill="none" stroke="#fff" stroke-width="13.88"/></g><g transform="matrix(.75 0 0 .75 32 32)"><path d="M24 72h208v93.881H24z" fill="none" stroke="#fff" stroke-linejoin="miter" stroke-width="9.485"/><circle cx="188" cy="128" r="12" stroke-width="10" transform="matrix(.708333 0 0 .708333 71.8333 12.8333)"/><path d="M24.358 103.5h110" fill="none" stroke="#fff" stroke-linecap="butt" stroke-width="10.282"/></g></g></g></g>',
|
||||
|
||||
HAND_TAP: '<path d="M6.537 8.906c0-4.216 3.469-7.685 7.685-7.685s7.685 3.469 7.685 7.685M7.719 30.778l-4.333-7.389C3.133 22.944 3 22.44 3 21.928a2.97 2.97 0 0 1 2.956-2.956 2.96 2.96 0 0 1 2.55 1.461l2.761 4.433V8.906a2.97 2.97 0 0 1 2.956-2.956 2.97 2.97 0 0 1 2.956 2.956v8.276a2.97 2.97 0 0 1 2.956-2.956 2.97 2.97 0 0 1 2.956 2.956v2.365a2.97 2.97 0 0 1 2.956-2.956A2.97 2.97 0 0 1 29 19.547v5.32c0 3.547-1.182 5.911-1.182 5.911"/>',
|
||||
|
||||
SCREENSHOT_B64: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDMyIDMyIiBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMjguMzA4IDUuMDM4aC00LjI2NWwtMi4wOTctMy4xNDVhMS4yMyAxLjIzIDAgMCAwLTEuMDIzLS41NDhoLTkuODQ2YTEuMjMgMS4yMyAwIDAgMC0xLjAyMy41NDhMNy45NTYgNS4wMzhIMy42OTJBMy43MSAzLjcxIDAgMCAwIDAgOC43MzF2MTcuMjMxYTMuNzEgMy43MSAwIDAgMCAzLjY5MiAzLjY5MmgyNC42MTVBMy43MSAzLjcxIDAgMCAwIDMyIDI1Ljk2MlY4LjczMWEzLjcxIDMuNzEgMCAwIDAtMy42OTItMy42OTJ6bS02Ljc2OSAxMS42OTJjMCAzLjAzOS0yLjUgNS41MzgtNS41MzggNS41MzhzLTUuNTM4LTIuNS01LjUzOC01LjUzOCAyLjUtNS41MzggNS41MzgtNS41MzggNS41MzggMi41IDUuNTM4IDUuNTM4eiIvPjwvc3ZnPgo=',
|
||||
};
|
||||
|
||||
export const createSvgIcon = (icon: string, strokeWidth=2) => {
|
||||
const $svg = CE('svg', {
|
||||
'xmlns': 'http://www.w3.org/2000/svg',
|
||||
'fill': 'none',
|
||||
'stroke': '#fff',
|
||||
'fill-rule': 'evenodd',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'stroke-width': strokeWidth,
|
||||
});
|
||||
$svg.innerHTML = icon;
|
||||
$svg.setAttribute('viewBox', '0 0 32 32');
|
||||
|
||||
return $svg;
|
||||
};
|
||||
|
||||
export const ButtonStyle: {[index: string | number]: string | number} = {};
|
||||
ButtonStyle[ButtonStyle.PRIMARY = 1] = 'bx-primary';
|
||||
ButtonStyle[ButtonStyle.DANGER = 2] = 'bx-danger';
|
||||
ButtonStyle[ButtonStyle.GHOST = 4] = 'bx-ghost';
|
||||
ButtonStyle[ButtonStyle.FOCUSABLE = 8] = 'bx-focusable';
|
||||
ButtonStyle[ButtonStyle.FULL_WIDTH = 16] = 'bx-full-width';
|
||||
ButtonStyle[ButtonStyle.FULL_HEIGHT = 32] = 'bx-full-height';
|
||||
|
||||
const ButtonStyleIndices = Object.keys(ButtonStyle).splice(0, Object.keys(ButtonStyle).length / 2).map(i => parseInt(i));
|
||||
|
||||
export const createButton = <T=HTMLButtonElement>(options: BxButton): T => {
|
||||
let $btn;
|
||||
if (options.url) {
|
||||
$btn = CE('a', {'class': 'bx-button'}) as HTMLAnchorElement;
|
||||
$btn.href = options.url;
|
||||
$btn.target = '_blank';
|
||||
} else {
|
||||
$btn = CE('button', {'class': 'bx-button'}) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
const style = (options.style || 0) as number;
|
||||
style && ButtonStyleIndices.forEach(index => {
|
||||
(style & index) && $btn.classList.add(ButtonStyle[index] as string);
|
||||
});
|
||||
|
||||
options.classes && $btn.classList.add(...options.classes);
|
||||
|
||||
options.icon && $btn.appendChild(createSvgIcon(options.icon, 4));
|
||||
options.label && $btn.appendChild(CE('span', {}, options.label));
|
||||
options.title && $btn.setAttribute('title', options.title);
|
||||
options.disabled && (($btn as HTMLButtonElement).disabled = true);
|
||||
options.onClick && $btn.addEventListener('click', options.onClick);
|
||||
|
||||
return $btn as T;
|
||||
}
|
||||
|
||||
export const CTN = document.createTextNode.bind(document);
|
||||
window.BX_CE = createElement;
|
162
src/utils/local-db.ts
Normal file
162
src/utils/local-db.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { PrefKey, setPref } from "../modules/preferences";
|
||||
import { t } from "../modules/translation";
|
||||
|
||||
export class LocalDb {
|
||||
static #instance: LocalDb;
|
||||
static get INSTANCE() {
|
||||
if (!LocalDb.#instance) {
|
||||
LocalDb.#instance = new LocalDb();
|
||||
}
|
||||
|
||||
return LocalDb.#instance;
|
||||
}
|
||||
|
||||
static readonly DB_NAME = 'BetterXcloud';
|
||||
static readonly DB_VERSION = 1;
|
||||
static readonly TABLE_PRESETS = 'mkb_presets';
|
||||
|
||||
#DB: any;
|
||||
|
||||
#open() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.#DB) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const request = window.indexedDB.open(LocalDb.DB_NAME, LocalDb.DB_VERSION);
|
||||
request.onupgradeneeded = (e: IDBVersionChangeEvent) => {
|
||||
const db = (e.target! as any).result;
|
||||
|
||||
switch (e.oldVersion) {
|
||||
case 0: {
|
||||
const presets = db.createObjectStore(LocalDb.TABLE_PRESETS, {keyPath: 'id', autoIncrement: true});
|
||||
presets.createIndex('name_idx', 'name');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = e => {
|
||||
console.log(e);
|
||||
alert((e.target as any).error.message);
|
||||
reject && reject();
|
||||
};
|
||||
|
||||
request.onsuccess = e => {
|
||||
this.#DB = (e.target as any).result;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
#table(name: string, type: string): Promise<IDBObjectStore> {
|
||||
const transaction = this.#DB.transaction(name, type || 'readonly');
|
||||
const table = transaction.objectStore(name);
|
||||
|
||||
return new Promise(resolve => resolve(table));
|
||||
}
|
||||
|
||||
// Convert IndexDB method to Promise
|
||||
#call(method: any) {
|
||||
const table = arguments[1];
|
||||
return new Promise(resolve => {
|
||||
const request = method.call(table, ...Array.from(arguments).slice(2));
|
||||
request.onsuccess = (e: Event) => {
|
||||
resolve([table, (e.target as any).result]);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
#count(table: IDBObjectStore): Promise<[IDBObjectStore, number]> {
|
||||
// @ts-ignore
|
||||
return this.#call(table.count, ...arguments);
|
||||
}
|
||||
|
||||
#add(table: IDBObjectStore, data: any): Promise<string> {
|
||||
// @ts-ignore
|
||||
return this.#call(table.add, ...arguments);
|
||||
}
|
||||
|
||||
#put(table: IDBObjectStore, data: any): Promise<string> {
|
||||
// @ts-ignore
|
||||
return this.#call(table.put, ...arguments);
|
||||
}
|
||||
|
||||
#delete(table: IDBObjectStore, data: any): Promise<string> {
|
||||
// @ts-ignore
|
||||
return this.#call(table.delete, ...arguments);
|
||||
}
|
||||
|
||||
#get(table: IDBObjectStore, id: string): Promise<any> {
|
||||
// @ts-ignore
|
||||
return this.#call(table.get, ...arguments);
|
||||
}
|
||||
|
||||
#getAll(table: IDBObjectStore): Promise<[IDBObjectStore, any]> {
|
||||
// @ts-ignore
|
||||
return this.#call(table.getAll, ...arguments);
|
||||
}
|
||||
|
||||
newPreset(name: string, data: any) {
|
||||
return this.#open()
|
||||
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
|
||||
.then(table => this.#add(table, {name, data}))
|
||||
.then(([table, id]) => new Promise<string>(resolve => resolve(id)));
|
||||
}
|
||||
|
||||
updatePreset(preset: string) {
|
||||
return this.#open()
|
||||
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
|
||||
.then(table => this.#put(table, preset))
|
||||
.then(([table, id]) => new Promise(resolve => resolve(id)));
|
||||
}
|
||||
|
||||
deletePreset(id: string) {
|
||||
return this.#open()
|
||||
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
|
||||
.then(table => this.#delete(table, id))
|
||||
.then(([table, id]) => new Promise(resolve => resolve(id)));
|
||||
}
|
||||
|
||||
getPreset(id: string) {
|
||||
return this.#open()
|
||||
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
|
||||
.then(table => this.#get(table, id))
|
||||
.then(([table, preset]) => new Promise(resolve => resolve(preset)));
|
||||
}
|
||||
|
||||
getPresets() {
|
||||
return this.#open()
|
||||
.then(() => this.#table(LocalDb.TABLE_PRESETS, 'readwrite'))
|
||||
.then(table => this.#count(table))
|
||||
.then(([table, count]) => {
|
||||
if (count > 0) {
|
||||
return new Promise(resolve => {
|
||||
this.#getAll(table)
|
||||
.then(([table, items]) => {
|
||||
const presets = {};
|
||||
items.forEach((item: any) => (presets[item.id] = item));
|
||||
resolve(presets);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create "Default" preset when the table is empty
|
||||
const preset = {
|
||||
name: t('default'),
|
||||
data: MkbPreset.DEFAULT_PRESET,
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
this.#add(table, preset)
|
||||
.then(([table, id]) => {
|
||||
preset.id = id;
|
||||
setPref(PrefKey.MKB_DEFAULT_PRESET_ID, id);
|
||||
|
||||
resolve({[id]: preset});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
120
src/utils/titles-info.ts
Normal file
120
src/utils/titles-info.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { PrefKey } from "../modules/preferences";
|
||||
import { getPref } from "../modules/preferences";
|
||||
import { UserAgent } from "./user-agent";
|
||||
|
||||
type TitleInfo = {
|
||||
titleId?: string;
|
||||
xboxTitleId?: string;
|
||||
hasTouchSupport?: boolean;
|
||||
imageHero?: string;
|
||||
};
|
||||
|
||||
type ApiTitleInfo = {
|
||||
titleId: string;
|
||||
details: {
|
||||
xboxTitleId: string;
|
||||
productId: string;
|
||||
supportedInputTypes: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type ApiCatalogInfo = {
|
||||
StoreId: string;
|
||||
Image_Hero: {
|
||||
URL: string;
|
||||
};
|
||||
Image_Tile: {
|
||||
URL: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class TitlesInfo {
|
||||
static #INFO: {[index: string]: TitleInfo} = {};
|
||||
|
||||
static get(titleId: string) {
|
||||
return TitlesInfo.#INFO[titleId];
|
||||
}
|
||||
|
||||
static update(titleId: string, info: TitleInfo) {
|
||||
TitlesInfo.#INFO[titleId] = TitlesInfo.#INFO[titleId] || {};
|
||||
Object.assign(TitlesInfo.#INFO[titleId], info);
|
||||
}
|
||||
|
||||
static saveFromTitleInfo(titleInfo: ApiTitleInfo) {
|
||||
const details = titleInfo.details;
|
||||
const info: TitleInfo = {
|
||||
titleId: titleInfo.titleId,
|
||||
xboxTitleId: details.xboxTitleId,
|
||||
// Has more than one input type -> must have touch support
|
||||
hasTouchSupport: (details.supportedInputTypes.length > 1),
|
||||
};
|
||||
TitlesInfo.update(details.productId, info);
|
||||
}
|
||||
|
||||
static saveFromCatalogInfo(catalogInfo: ApiCatalogInfo) {
|
||||
const titleId = catalogInfo.StoreId;
|
||||
const imageHero = (catalogInfo.Image_Hero || catalogInfo.Image_Tile || {}).URL;
|
||||
TitlesInfo.update(titleId, {
|
||||
imageHero: imageHero,
|
||||
});
|
||||
}
|
||||
|
||||
static hasTouchSupport(titleId: string): boolean {
|
||||
return !!TitlesInfo.#INFO[titleId]?.hasTouchSupport;
|
||||
}
|
||||
|
||||
static requestCatalogInfo(titleId: string, callback: any) {
|
||||
const url = `https://catalog.gamepass.com/v3/products?market=${States.appContext.marketInfo.market}&language=${States.appContext.marketInfo.locale}&hydration=RemoteHighSapphire0`;
|
||||
const appVersion = document.querySelector('meta[name=gamepass-app-version]')!.getAttribute('content');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Ms-Cv': States.appContext.telemetryInfo.initialCv,
|
||||
'Calling-App-Name': 'Xbox Cloud Gaming Web',
|
||||
'Calling-App-Version': appVersion,
|
||||
} as any,
|
||||
body: JSON.stringify({
|
||||
Products: [titleId],
|
||||
}),
|
||||
}).then(resp => {
|
||||
callback && callback(TitlesInfo.get(titleId));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PreloadedState {
|
||||
static override() {
|
||||
Object.defineProperty(window, '__PRELOADED_STATE__', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
// Override User-Agent
|
||||
const userAgent = UserAgent.spoof();
|
||||
if (userAgent) {
|
||||
(this as any)._state.appContext.requestInfo.userAgent = userAgent;
|
||||
}
|
||||
|
||||
return (this as any)._state;
|
||||
},
|
||||
set: state => {
|
||||
(this as any)._state = state;
|
||||
States
|
||||
States.appContext = structuredClone(state.appContext);
|
||||
|
||||
// Get a list of touch-supported games
|
||||
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all') {
|
||||
let titles: {[index: string]: any} = {};
|
||||
try {
|
||||
titles = state.xcloud.titles.data.titles;
|
||||
} catch (e) {}
|
||||
|
||||
for (let id in titles) {
|
||||
TitlesInfo.saveFromTitleInfo(titles[id].data);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
84
src/utils/toast.ts
Normal file
84
src/utils/toast.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { CE } from "./html";
|
||||
|
||||
type ToastOptions = {
|
||||
instant?: boolean;
|
||||
}
|
||||
|
||||
export class Toast {
|
||||
static #$wrapper: HTMLElement;
|
||||
static #$msg: HTMLElement;
|
||||
static #$status: HTMLElement;
|
||||
static #stack: Array<[string, string, ToastOptions]> = [];
|
||||
static #isShowing = false;
|
||||
|
||||
static #timeout?: number | null;
|
||||
static #DURATION = 3000;
|
||||
|
||||
static show(msg: string, status: string, options: ToastOptions={}) {
|
||||
options = options || {};
|
||||
|
||||
const args = Array.from(arguments) as [string, string, ToastOptions];
|
||||
if (options.instant) {
|
||||
// Clear stack
|
||||
Toast.#stack = [args];
|
||||
Toast.#showNext();
|
||||
} else {
|
||||
Toast.#stack.push(args);
|
||||
!Toast.#isShowing && Toast.#showNext();
|
||||
}
|
||||
}
|
||||
|
||||
static #showNext() {
|
||||
if (!Toast.#stack.length) {
|
||||
Toast.#isShowing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.#isShowing = true;
|
||||
|
||||
Toast.#timeout && clearTimeout(Toast.#timeout);
|
||||
Toast.#timeout = setTimeout(Toast.#hide, Toast.#DURATION);
|
||||
|
||||
// Get values from item
|
||||
const [msg, status, _] = Toast.#stack.shift()!;
|
||||
|
||||
Toast.#$msg.textContent = msg;
|
||||
|
||||
if (status) {
|
||||
Toast.#$status.classList.remove('bx-gone');
|
||||
Toast.#$status.textContent = status;
|
||||
} else {
|
||||
Toast.#$status.classList.add('bx-gone');
|
||||
}
|
||||
|
||||
const classList = Toast.#$wrapper.classList;
|
||||
classList.remove('bx-offscreen', 'bx-hide');
|
||||
classList.add('bx-show');
|
||||
}
|
||||
|
||||
static #hide() {
|
||||
Toast.#timeout = null;
|
||||
|
||||
const classList = Toast.#$wrapper.classList;
|
||||
classList.remove('bx-show');
|
||||
classList.add('bx-hide');
|
||||
}
|
||||
|
||||
static setup() {
|
||||
Toast.#$wrapper = CE('div', {'class': 'bx-toast bx-offscreen'},
|
||||
Toast.#$msg = CE('span', {'class': 'bx-toast-msg'}),
|
||||
Toast.#$status = CE('span', {'class': 'bx-toast-status'}));
|
||||
|
||||
Toast.#$wrapper.addEventListener('transitionend', e => {
|
||||
const classList = Toast.#$wrapper.classList;
|
||||
if (classList.contains('bx-hide')) {
|
||||
classList.remove('bx-offscreen', 'bx-hide');
|
||||
classList.add('bx-offscreen');
|
||||
|
||||
Toast.#showNext();
|
||||
}
|
||||
});
|
||||
|
||||
document.documentElement.appendChild(Toast.#$wrapper);
|
||||
}
|
||||
}
|
66
src/utils/user-agent.ts
Normal file
66
src/utils/user-agent.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { PrefKey, Preferences, getPref } from "../modules/preferences";
|
||||
|
||||
export enum UserAgentProfile {
|
||||
EDGE_WINDOWS = 'edge-windows',
|
||||
SAFARI_MACOS = 'safari-macos',
|
||||
SMARTTV_TIZEN = 'smarttv-tizen',
|
||||
DEFAULT = 'default',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
export class UserAgent {
|
||||
static #USER_AGENTS = {
|
||||
[UserAgentProfile.EDGE_WINDOWS]: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.188',
|
||||
[UserAgentProfile.SAFARI_MACOS]: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.1',
|
||||
[UserAgentProfile.SMARTTV_TIZEN]: 'Mozilla/5.0 (SMART-TV; LINUX; Tizen 7.0) AppleWebKit/537.36 (KHTML, like Gecko) 94.0.4606.31/7.0 TV Safari/537.36',
|
||||
}
|
||||
|
||||
static getDefault(): string {
|
||||
return (window.navigator as any).orgUserAgent || window.navigator.userAgent;
|
||||
}
|
||||
|
||||
static get(profile: string): string {
|
||||
const defaultUserAgent = UserAgent.getDefault();
|
||||
if (profile === UserAgentProfile.CUSTOM) {
|
||||
return getPref(PrefKey.USER_AGENT_CUSTOM);
|
||||
}
|
||||
|
||||
// TODO: check type
|
||||
return (UserAgent.#USER_AGENTS as any)[profile] || defaultUserAgent;
|
||||
}
|
||||
|
||||
static isSafari(mobile=false) {
|
||||
const userAgent = (UserAgent.getDefault() || '').toLowerCase();
|
||||
let result = userAgent.includes('safari') && !userAgent.includes('chrom');
|
||||
|
||||
if (result && mobile) {
|
||||
result = userAgent.includes('mobile');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static spoof() {
|
||||
let newUserAgent;
|
||||
|
||||
const profile = getPref(PrefKey.USER_AGENT_PROFILE);
|
||||
if (profile === UserAgentProfile.DEFAULT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newUserAgent) {
|
||||
newUserAgent = UserAgent.get(profile);
|
||||
}
|
||||
|
||||
// Clear data of navigator.userAgentData, force xCloud to detect browser based on navigator.userAgent
|
||||
Object.defineProperty(window.navigator, 'userAgentData', {});
|
||||
|
||||
// Override navigator.userAgent
|
||||
(window.navigator as any).orgUserAgent = window.navigator.userAgent;
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
value: newUserAgent,
|
||||
});
|
||||
|
||||
return newUserAgent;
|
||||
}
|
||||
}
|
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"includes": [
|
||||
"./types"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Enable latest features
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"removeComments": false,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user