From 0febae28da472054d537031181f9451143c1eb6e Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sat, 20 Apr 2024 07:24:16 +0700 Subject: [PATCH] Initial commit --- .gitignore | 175 ++ README.md | 15 + package.json | 11 + src/index.ts | 102 + src/modules/bx-event.ts | 44 + src/modules/bx-exposed.ts | 26 + src/modules/bx-flags.ts | 25 + src/modules/dialog.ts | 98 + src/modules/mkb/definitions.ts | 98 + src/modules/mkb/key-helper.ts | 67 + src/modules/mkb/mkb-handler.ts | 467 +++++ src/modules/mkb/mkb-preset.ts | 163 ++ src/modules/mkb/mkb-remapper.ts | 531 +++++ src/modules/preferences.ts | 757 ++++++++ src/modules/settings.ts | 267 +++ src/modules/stream-badges.ts | 240 +++ src/modules/stream-stats.ts | 300 +++ src/modules/translation.ts | 3226 +++++++++++++++++++++++++++++++ src/types/index.d.ts | 45 + src/utils/css.ts | 1403 ++++++++++++++ src/utils/html.ts | 126 ++ src/utils/local-db.ts | 162 ++ src/utils/titles-info.ts | 120 ++ src/utils/toast.ts | 84 + src/utils/user-agent.ts | 66 + tsconfig.json | 30 + 26 files changed, 8648 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/modules/bx-event.ts create mode 100644 src/modules/bx-exposed.ts create mode 100644 src/modules/bx-flags.ts create mode 100644 src/modules/dialog.ts create mode 100644 src/modules/mkb/definitions.ts create mode 100644 src/modules/mkb/key-helper.ts create mode 100644 src/modules/mkb/mkb-handler.ts create mode 100644 src/modules/mkb/mkb-preset.ts create mode 100644 src/modules/mkb/mkb-remapper.ts create mode 100644 src/modules/preferences.ts create mode 100644 src/modules/settings.ts create mode 100644 src/modules/stream-badges.ts create mode 100644 src/modules/stream-stats.ts create mode 100644 src/modules/translation.ts create mode 100644 src/types/index.d.ts create mode 100644 src/utils/css.ts create mode 100644 src/utils/html.ts create mode 100644 src/utils/local-db.ts create mode 100644 src/utils/titles-info.ts create mode 100644 src/utils/toast.ts create mode 100644 src/utils/user-agent.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..07b6246 --- /dev/null +++ b/README.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..c41ae1c --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "better-xcloud", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c784982 --- /dev/null +++ b/src/index.ts @@ -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({}) diff --git a/src/modules/bx-event.ts b/src/modules/bx-event.ts new file mode 100644 index 0000000..7cb01a8 --- /dev/null +++ b/src/modules/bx-event.ts @@ -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); + } +} diff --git a/src/modules/bx-exposed.ts b/src/modules/bx-exposed.ts new file mode 100644 index 0000000..b5cfe86 --- /dev/null +++ b/src/modules/bx-exposed.ts @@ -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'); + } + }, +}; diff --git a/src/modules/bx-flags.ts b/src/modules/bx-flags.ts new file mode 100644 index 0000000..a205849 --- /dev/null +++ b/src/modules/bx-flags.ts @@ -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 } diff --git a/src/modules/dialog.ts b/src/modules/dialog.ts new file mode 100644 index 0000000..9af60ba --- /dev/null +++ b/src/modules/dialog.ts @@ -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'); + } +} diff --git a/src/modules/mkb/definitions.ts b/src/modules/mkb/definitions.ts new file mode 100644 index 0000000..e1c979e --- /dev/null +++ b/src/modules/mkb/definitions.ts @@ -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', +} diff --git a/src/modules/mkb/key-helper.ts b/src/modules/mkb/key-helper.ts new file mode 100644 index 0000000..25577ca --- /dev/null +++ b/src/modules/mkb/key-helper.ts @@ -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 + ); + } +} diff --git a/src/modules/mkb/mkb-handler.ts b/src/modules/mkb/mkb-handler.ts new file mode 100644 index 0000000..1db697a --- /dev/null +++ b/src/modules/mkb/mkb-handler.ts @@ -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('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(); + } + }); + } +} diff --git a/src/modules/mkb/mkb-preset.ts b/src/modules/mkb/mkb-preset.ts new file mode 100644 index 0000000..b8160c0 --- /dev/null +++ b/src/modules/mkb/mkb-preset.ts @@ -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; + } +} diff --git a/src/modules/mkb/mkb-remapper.ts b/src/modules/mkb/mkb-remapper.ts new file mode 100644 index 0000000..93453c0 --- /dev/null +++ b/src/modules/mkb/mkb-remapper.ts @@ -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('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; + } +} diff --git a/src/modules/preferences.ts b/src/modules/preferences.ts new file mode 100644 index 0000000..efd685c --- /dev/null +++ b/src/modules/preferences.ts @@ -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('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); diff --git a/src/modules/settings.ts b/src/modules/settings.ts new file mode 100644 index 0000000..25662ea --- /dev/null +++ b/src/modules/settings.ts @@ -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('select') as HTMLSelectElement; + for (let value in setting.options) { + const label = setting.options[value]; + + const $option = CE('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('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('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('option', {'value': i})); + } + } else { + for (let i = MIN + options.ticks!; i < MAX; i += options.ticks!) { + $markers.appendChild(CE('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; + } +} diff --git a/src/modules/stream-badges.ts b/src/modules/stream-badges.ts new file mode 100644 index 0000000..7a46fc0 --- /dev/null +++ b/src/modules/stream-badges.ts @@ -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) {} + }); + } +} diff --git a/src/modules/stream-stats.ts b/src/modules/stream-stats.ts new file mode 100644 index 0000000..37aed4d --- /dev/null +++ b/src/modules/stream-stats.ts @@ -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); + } + }); + } +} diff --git a/src/modules/translation.ts b/src/modules/translation.ts new file mode 100644 index 0000000..c2f1450 --- /dev/null +++ b/src/modules/translation.ts @@ -0,0 +1,3226 @@ +const Translations = { + enUS: -1, + + getLocale: () => { + const supportedLocales = [ + 'de-DE', + 'en-ID', + 'en-US', + 'es-ES', + 'fr-FR', + 'it-IT', + 'ja-JP', + 'ko-KR', + 'pl-PL', + 'pt-BR', + 'ru-RU', + 'tr-TR', + 'uk-UA', + 'vi-VN', + 'zh-CN', + ]; + + Translations.enUS = supportedLocales.indexOf('en-US'); + + let locale = localStorage.getItem('better_xcloud_locale'); + if (!locale) { + locale = window.navigator.language || 'en-US'; + if (supportedLocales.indexOf(locale) === -1) { + locale = 'en-US'; + } + localStorage.setItem('better_xcloud_locale', locale); + } + + return supportedLocales.indexOf(locale); + }, + + get: (key: string, values?: any): T => { + const texts = (Translations as any)[key] || alert(`Missing translation key: ${key}`); + const translation = texts[LOCALE] || texts[Translations.enUS]; + + return values ? translation(values) : translation; + }, + + "activate": [ + "Aktivieren", + "Aktifkan", + "Activate", + "Activo", + , + , + "設定する", + "활성화", + "Aktywuj", + "Ativar", + "Активировать", + "Etkinleştir", + "Активувати", + "Kích hoạt", + "启用", + ], + "activated": [ + "Aktiviert", + "Diaktifkan", + "Activated", + "Activado", + , + , + "設定中", + "활성화 됨", + "Aktywowane", + "Ativado", + "Активирован", + "Etkin", + "Активований", + "Đã kích hoạt", + "已启用", + ], + "active": [ + "Aktiv", + "Aktif", + "Active", + "Activo", + , + , + "有効", + "활성화", + "Aktywny", + "Ativo", + "Активный", + "Etkin", + "Активний", + "Hoạt động", + "已启用", + ], + "advanced": [ + "Erweitert", + "Lanjutan", + "Advanced", + "Avanzado", + "Options avancées", + "Avanzate", + "高度な設定", + "고급", + "Zaawansowane", + "Avançado", + "Продвинутые", + "Gelişmiş ayarlar", + "Розширені", + "Nâng cao", + "高级选项", + ], + "apply": [ + "Anwenden", + "Terapkan", + "Apply", + "Aplicar", + , + , + "適用", + , + "Zastosuj", + "Aplicar", + "Применить", + "Uygula", + "Застосувати", + "Áp dụng", + "应用", + ], + "audio": [ + "Audio", + "Audio", + "Audio", + "Audio", + "Audio", + "Audio", + "音声", + "오디오", + "Dźwięk", + "Áudio", + "Звук", + "Ses", + "Звук", + "Âm thanh", + "音频", + ], + "auto": [ + "Automatisch", + "Otomatis", + "Auto", + "Auto", + "Auto", + "Automatico", + "自動", + "자동", + "Automatyczne", + "Automático", + "Автоматически", + "Otomatik", + "Автоматично", + "Tự động", + "自动", + ], + "badge-audio": [ + "Audio", + "Audio", + "Audio", + "Audio", + "Audio", + "Audio", + "音声", + "오디오", + "Dźwięk", + "Áudio", + "Звук", + "Ses", + "Звук", + "Tiếng", + "音频", + ], + "badge-battery": [ + "Batterie", + "Baterai", + "Battery", + "Batería", + "Batterie", + "Batteria", + "バッテリー", + "배터리", + "Bateria", + "Bateria", + "Батарея", + "Pil", + "Батарея", + "Pin", + "电量", + ], + "badge-in": [ + "Empfangen", + "Masuk", + "In", + "Entrada", + "Dans", + "DL", + "IN", + "다운로드", + "Pobieranie", + "Recebidos", + "Входящие", + "Gelen", + "Завантаження", + "Nhận", + "下载", + ], + "badge-out": [ + "Gesendet", + "Keluar", + "Out", + "Salida", + "Sorti", + "UP", + "OUT", + "업로드", + "Wysyłanie", + "Enviados", + "Исходящие", + "Giden", + "Вивантаження", + "Gởi", + "上传", + ], + "badge-playtime": [ + "Spielzeit", + "Waktu bermain", + "Playtime", + "Tiempo jugado", + "Temps de jeu", + "In gioco da", + "プレイ時間", + "플레이한 시간", + "Czas gry", + "Tempo de jogo", + "Время в игре", + "Oynanış süresi", + "Час гри", + "Giờ chơi", + "游玩时间", + ], + "badge-server": [ + "Server", + "Server", + "Server", + "Servidor", + "Serveur", + "Server", + "サーバー", + "서버", + "Serwer", + "Servidor", + "Сервер", + "Sunucu", + "Сервер", + "Máy chủ", + "服务器", + ], + "badge-video": [ + "Video", + "Video", + "Video", + "Video", + "Vidéo", + "Video", + "映像", + "비디오", + "Obraz", + "Vídeo", + "Видео", + "Görüntü", + "Відео", + "Hình", + "视频", + ], + "bottom-left": [ + "Unten links", + "Kiri bawah", + "Bottom-left", + "Inferior izquierdo", + "En bas à gauche", + "In basso a sinistra", + "左下", + "좌측 하단", + "Lewy dolny róg", + "Inferior esquerdo", + "Левый нижний угол", + "Sol alt", + "Внизу ліворуч", + "Phía dưới bên trái", + "左下角", + ], + "bottom-right": [ + "Unten rechts", + "Kanan bawah", + "Bottom-right", + "Inferior derecha", + "Bas-droit", + "In basso a destra", + "右下", + "우측 하단", + "Prawy dolny róg", + "Inferior direito", + "Правый нижний угол", + "Sağ alt", + "Внизу праворуч", + "Phía dưới bên phải", + "右下角", + ], + "brightness": [ + "Helligkeit", + "Kecerahan", + "Brightness", + "Brillo", + "Luminosité", + "Luminosità", + "輝度", + "밝기", + "Jasność", + "Brilho", + "Яркость", + "Aydınlık", + "Яскравість", + "Độ sáng", + "亮度", + ], + "browser-unsupported-feature": [ + "Dein Browser unterstützt diese Funktion nicht", + "Browser anda tidak mendukung fitur ini", + "Your browser doesn't support this feature", + "Su navegador no soporta esta característica", + "Votre navigateur ne supporte pas cette fonctionnalité", + "Il tuo browser non supporta questa funzione", + "お使いのブラウザはこの機能をサポートしていません。", + "브라우저에서 이 기능을 지원하지 않습니다.", + "Twoja przeglądarka nie obsługuje tej funkcji", + "Seu navegador não suporta este recurso", + "Ваш браузер не поддерживает эту функцию", + "Web tarayıcınız bu özelliği desteklemiyor", + "Ваш браузер не підтримує цю функцію", + "Trình duyệt không hỗ trợ tính năng này", + "您的浏览器不支持此功能", + ], + "can-stream-xbox-360-games": [ + "Kann Xbox 360 Spiele streamen", + "Dapat melakukan stream game Xbox 360", + "Can stream Xbox 360 games", + "Puede transmitir juegos de Xbox 360", + , + "Puoi riprodurre i giochi Xbox 360", + "Xbox 360ゲームのストリーミング可能", + "Xbox 360 게임 스트림 가능", + "Można strumieniować gry Xbox 360", + "Pode transmitir jogos de Xbox 360", + "Позволяет транслировать Xbox 360 игры", + "Xbox 360 oyunlarına erişim sağlanabilir", + "Дозволяє транслювати ігри Xbox 360", + "Có thể stream các game Xbox 360", + "可以进行流式传输Xbox360游戏", + ], + "cancel": [ + "Abbrechen", + "Batal", + "Cancel", + "Cancelar", + , + "Cancella", + "キャンセル", + "취소", + "Anuluj", + "Cancelar", + "Отмена", + "Vazgeç", + "Скасувати", + "Hủy", + "取消", + ], + "cant-stream-xbox-360-games": [ + "Kann Xbox 360 Spiele nicht streamen", + "Tidak dapat melakukan stream game Xbox 360", + "Can't stream Xbox 360 games", + "No puede transmitir juegos de Xbox 360", + , + "Impossibile riprodurre i giochi Xbox 360", + "Xbox 360ゲームのストリーミング不可", + "Xbox 360 게임 스트림 불가", + "Nie można strumieniować gier Xbox 360", + "Não pode transmitir jogos de Xbox 360", + "Невозможно транслировать игры Xbox 360", + "Xbox 360 oyunlarına erişim sağlanamaz", + "Не дозволяє транслювати ігри Xbox 360", + "Không thể stream các game Xbox 360", + "不可以进行流式传输Xbox360游戏", + ], + "clarity": [ + "Klarheit", + "Kejernihan", + "Clarity", + "Claridad", + "Clarté", + "Nitidezza", + "明瞭度(クラリティ)", + "선명도", + "Ostrość", + "Nitidez", + "Чёткость", + "Netlik", + "Чіткість", + "Độ nét", + "清晰度", + ], + "clarity-boost-warning": [ + "Diese Einstellungen funktionieren nicht, wenn \"Clarity Boost\" aktiviert ist", + "Pengaturan ini tidak bekerja ketika mode \"Kejernihan\" aktif", + "These settings don't work when the Clarity Boost mode is ON", + "Estos ajustes no funcionan cuando el modo Clarity Boost está activado", + "Ces paramètres ne fonctionnent pas lorsque le mode Clarity Boost est activé", + "Queste impostazioni non funzionano quando la modalità Clarity Boost è attiva", + "クラリティブーストが有効の場合、映像設定は無効化されます。", + "이 설정들은 선명도 향상 기능이 켜져 있을 때는 동작하지 않습니다.", + "Te ustawienia nie będą działać, gdy tryb \"Clarity Boost\" jest włączony", + "Estas configurações não funcionam quando o modo \"Clarity Boost\" está ATIVADO", + "Эти настройки не работают, когда включен режим Clarity Boost", + "Netliği Artırma modu açıkken bu ayarlar ETKİSİZDİR", + "Ці налаштування не працюють, коли увімкнено режим \"Clarity Boost\"", + "Các tùy chỉnh này không hoạt động khi chế độ Clarity Boost đang được bật", + "这些设置在 Clarity Boost 清晰度增强 开启时不可用", + ], + "clear": [ + "Zurücksetzen", + "Bersihkan", + "Clear", + "Borrar", + , + , + "消去", + "비우기", + "Wyczyść", + "Limpar", + "Очистить", + "Temizle", + "Очистити", + "Xóa", + "清空", + ], + "close": [ + "Schließen", + "Tutup", + "Close", + "Cerrar", + "Fermer", + "Chiudi", + "閉じる", + "닫기", + "Zamknij", + "Fechar", + "Закрыть", + "Kapat", + "Закрити", + "Đóng", + "关闭", + ], + "combine-audio-video-streams": [ + "Audio- und Video-Streams kombinieren", + "Gabung audio & video stream", + "Combine audio & video streams", + "Combinar flujos de audio y vídeo", + , + , + "音声を映像ストリーミングと統合", + , + "Połącz strumienie audio i wideo", + "Combinar fluxos de áudio e vídeo", + "Объединить аудио и видео потоки", + "Ses ve görüntü akışını birleştir", + "Поєднайте аудіо та відео потоки", + "Hòa hợp nguồn của âm thanh và hình ảnh", + "合并视频音频流", + ], + "combine-audio-video-streams-summary": [ + "Könnte das Problem mit verzögertem Ton beheben", + "Mungkin memperbaiki masalah lag pada audio", + "May fix the laggy audio problem", + "Puede arreglar el problema de audio con retraso", + , + , + "音声の遅延を改善できる可能性があります", + , + "Może rozwiązać problem z zacinającym dźwiękiem", + "Pode corrigir o problema de áudio atrasado", + "Может исправить проблему подвисания звука", + "Sesteki gecikme sorununa çözüm olabilir", + "Може виправити проблему із затримкою звуку", + "Có thể sửa được lỗi trễ tiếng", + "有助于缓解音频延迟", + ], + "conditional-formatting": [ + "Zustandsabhängige Textfarbe", + "Format teks kondisional", + "Conditional formatting text color", + "Color condicional de formato de texto", + "Couleur du texte de mise en forme conditionnelle", + "Colore testo formattazione condizionale", + "状態に応じた文字色で表示", + "통계에 따라 글자 색 지정", + "Kolor tekstu zależny od wartości", + "Cor do texto de formatação condicional", + "Цвет текста в зависимости от условий", + "Metin renginin koşullu biçimlendirilmesi", + "Колір тексту в залежності від умов", + "Thay đổi màu chữ tùy theo giá trị", + "更改文本颜色", + ], + "confirm-delete-preset": [ + "Möchtest Du diese Voreinstellung löschen?", + "Apakah anda yakin ingin menghapus preset ini?", + "Do you want to delete this preset?", + "¿Desea eliminar este preajuste?", + "Voulez-vous supprimer ce préréglage?", + , + "このプリセットを削除しますか?", + "이 프리셋을 삭제하시겠습니까?", + "Czy na pewno chcesz usunąć ten szablon?", + "Você quer excluir esta predefinição?", + "Вы точно хотите удалить этот шаблон?", + "Bu hazır ayarı silmek istiyor musunuz?", + "Ви бажаєте видалити цей пресет?", + "Bạn có muốn xoá thiết lập sẵn này không?", + "您想要删除此预设吗?", + ], + "confirm-reload-stream": [ + "Möchtest Du den Stream aktualisieren?", + "Apakah anda ingin memuat ulang stream?", + "Do you want to refresh the stream?", + "¿Quieres actualizar el stream?\n", + "Voulez-vous actualiser le stream ?", + "Vuoi aggiornare lo stream?", + "ストリーミングをリフレッシュしますか?", + "스트리밍을 재시작할까요?", + "Czy chcesz odświeżyć transmisję?", + "Você deseja atualizar a transmissão?", + "Вы хотите перезапустить поток?", + "Yayını yeniden başlatmak istiyor musunuz?", + "Бажаєте оновити трансляцію?", + "Bạn có muốn kết nối lại stream không?", + "您想要刷新吗?", + ], + "connected": [ + "Verbunden", + "Tersambung", + "Connected", + "Conectado", + , + , + "接続済み", + , + "Połączony", + "Conectado", + "Подключен", + "Bağlı", + "Під’єднано", + "Đã kết nối", + "已连接", + ], + "console-connect": [ + "Verbinden", + "Sambungkan", + "Connect", + "Conectar", + , + "Connetti", + "本体に接続", + "콘솔 연결", + "Połącz", + "Conectar", + "Подключиться", + "Bağlan", + "Під’єднатися", + "Kết nối", + "连接", + ], + "contrast": [ + "Kontrast", + "Kontras", + "Contrast", + "Contraste", + "Contraste", + "Contrasto", + "コントラスト", + "대비", + "Kontrast", + "Contraste", + "Контрастность", + "Karşıtlık", + "Контрастність", + "Độ tương phản", + "对比度", + ], + "controller": [ + "Controller", + "Kontroler", + "Controller", + "Joystick", + "Contrôle", + "Controller", + "コントローラー", + "컨트롤러", + "Kontroler", + "Controle", + "Контроллер", + "Oyun Kumandası", + "Контролер", + "Bộ điều khiển", + "手柄", + ], + "controller-shortcuts": [ + "Controller-Shortcuts", + "Pintasan kontroler", + "Controller shortcuts", + "Habilitar atajos del Joystick", + , + "Abilita scrociatorie da controller", + "コントローラーショートカット", + , + "Skróty kontrolera", + "Atalhos do controle", + "Горячие клавиши контроллера", + "Oyun kumandası kısayolları", + "Ярлики контролера", + "Các phím tắt tay cầm", + "手柄快捷键", + ], + "controller-vibration": [ + "Vibration des Controllers", + "Getaran kontroler", + "Controller vibration", + "Vibración del mando", + , + , + "コントローラーの振動", + "컨트롤러 진동", + "Wibracje kontrolera", + "Vibração do controle", + "Вибрация контроллера", + "Oyun kumandası titreşimi", + "Вібрація контролера", + "Rung bộ điều khiển", + "控制器振动", + ], + "copy": [ + "Kopieren", + "Salin", + "Copy", + "Copiar", + , + , + "コピー", + "복사", + "Kopiuj", + "Copiar", + "Скопировать", + "Kopyala", + "Копіювати", + "Sao chép", + "复制", + ], + "custom": [ + "Benutzerdefiniert", + "Kustom", + "Custom", + "Personalizado", + "Personnalisée", + "Personalizzato", + "カスタム", + "사용자 지정", + "Niestandardowe", + "Personalizado", + "Вручную", + "Özel", + "Користувацькі", + "Tùy chỉnh", + "自定义", + ], + "deadzone-counterweight": [ + "Deadzone Gegengewicht", + "Pengimbang deadzone", + "Deadzone counterweight", + "Contrapeso de la zona muerta", + , + , + "デッドゾーンのカウンターウエイト", + , + "Przeciwwaga martwej strefy", + "Contrapeso de zona morta", + "Противодействие мертвой зоне игры", + "Ölü alan denge ağırlığı", + "Противага Deadzone", + "Đối trọng vùng chết", + "死区补偿", + ], + "default": [ + "Standard", + "Bawaan", + "Default", + "Por defecto", + "Par défaut", + "Predefinito", + "デフォルト", + "기본값", + "Domyślny", + "Padrão", + "По умолчанию", + "Varsayılan", + "За замовчуванням", + "Mặc định", + "默认", + ], + "delete": [ + "Löschen", + "Hapus", + "Delete", + "Borrar", + , + "Elimina", + "削除", + "삭제", + "Usuń", + "Deletar", + "Удалить", + "Sil", + "Видалити", + "Xóa", + "删除", + ], + "device-unsupported-touch": [ + "Dein Gerät hat keine Touch-Unterstützung", + "Perangkat anda tidak mendukung layar sentuh", + "Your device doesn't have touch support", + "Tu dispositivo no tiene soporte táctil", + "Votre appareil n'a pas de support tactile", + "Il tuo dispositivo non ha uno schermo touch", + "お使いのデバイスはタッチ機能をサポートしていません。", + "브라우저에서 터치를 지원하지 않습니다.", + "Twoje urządzenie nie obsługuję tej funkcji", + "Seu dispositivo não possui suporte de toque", + "Ваше устройство не поддерживает сенсорное управление", + "Cihazınızda dokunmatik ekran özelliği yoktur", + "Ваш пристрій не має підтримки сенсорного керування", + "Thiết bị này không hỗ trợ cảm ứng", + "您的设备不支持触摸", + ], + "device-vibration": [ + "Vibration des Geräts", + "Getaran perangkat", + "Device vibration", + "Vibración del dispositivo", + , + , + "デバイスの振動", + "기기 진동", + "Wibracje urządzenia", + "Vibração do dispositivo", + "Вибрация устройства", + "Cihaz titreşimi", + "Вібрація пристрою", + "Rung thiết bị", + "设备振动", + ], + "device-vibration-not-using-gamepad": [ + "An, wenn kein Gamepad verbunden", + "Aktif ketika tidak menggunakan gamepad", + "On when not using gamepad", + "Activado cuando no se utiliza el mando", + , + , + "ゲームパッド未使用時にオン", + "게임패드를 사용하지 않을 때", + "Włączone, gdy nie używasz kontrolera", + "Ativar quando não estiver usando o dispositivo", + "Включить когда не используется геймпад", + "Oyun kumandası bağlanmadan titreşim", + "Увімкнена, коли не використовується геймпад", + "Bật khi không dùng tay cầm", + "当不使用游戏手柄时", + ], + "disable": [ + "Deaktiviert", + "Mati", + "Disable", + "Deshabilitar", + "Désactiver", + "Disabilita", + "無効", + "비활성화", + "Wyłącz", + "Desabilitar", + "Отключить", + "Devre dışı bırak", + "Вимкнути", + "Vô hiệu hóa", + "禁用", + ], + "disable-post-stream-feedback-dialog": [ + "Feedback-Dialog beim Beenden deaktivieren", + "Matikan umpan balik dialog stream", + "Disable post-stream feedback dialog", + "Desactivar diálogo de retroalimentación post-stream", + "Désactiver la boîte de dialogue de commentaires post-stream", + "Disabilita la finestra di feedback al termine dello stream", + "ストリーミング終了後のフィードバック画面を非表示", + "스트림 후 피드백 다이얼 비활성화", + "Wyłącz okno opinii po zakończeniu transmisji", + "Desativar o diálogo de comentários pós-transmissão", + "Отключить диалог обратной связи после стрима", + "Yayın sonrası geribildirim ekranını kapat", + "Відключити діалогове вікно зворотного зв’язку після трансляції", + "Tắt hộp thoại góp ý sau khi chơi xong", + "禁用反馈问卷", + ], + "disable-social-features": [ + "Soziale Funktionen deaktivieren", + "Matikan fitur social", + "Disable social features", + "Desactivar características sociales", + "Désactiver les fonctionnalités sociales", + "Disabilita le funzioni social", + "ソーシャル機能を無効", + "소셜 기능 비활성화", + "Wyłącz funkcje społecznościowe", + "Desativar recursos sociais", + "Отключить социальные функции", + "Sosyal özellikleri kapat", + "Вимкнути соціальні функції", + "Khóa các tính năng xã hội", + "禁用社交功能", + ], + "disable-xcloud-analytics": [ + "xCloud-Datenanalyse deaktivieren", + "Matikan analisis xCloud", + "Disable xCloud analytics", + "Desactivar análisis de xCloud", + "Désactiver les analyses xCloud", + "Disabilita l'analitica di xCloud", + "xCloudアナリティクスを無効", + "xCloud 통계 비활성화", + "Wyłącz analitykę xCloud", + "Desativar telemetria do xCloud", + "Отключить аналитику xCloud", + "xCloud'un veri toplamasını devre dışı bırak", + "Вимкнути аналітику xCloud", + "Khóa phân tích thông tin của xCloud", + "关闭 xCloud 遥测数据统计", + ], + "disabled": [ + "Deaktiviert", + "Dinonaktifkan", + "Disabled", + "Desactivado", + , + , + "無効", + "비활성화됨", + "Wyłączony", + "Desativado", + "Отключено", + "Kapalı", + "Вимкнено", + "Đã tắt", + "禁用", + ], + "disconnected": [ + "Getrennt", + "Terputus", + "Disconnected", + "Desconectado", + , + , + "切断", + , + "Rozłączony", + "Desconectado", + "Отключен", + "Bağlı değil", + "Від'єднано", + "Đã ngắt kết nối", + "已断开连接", + ], + "edit": [ + "Bearbeiten", + "Edit", + "Edit", + "Editar", + , + "Modifica", + "編集", + "편집", + "Edytuj", + "Editar", + "Редактировать", + "Düzenle", + "Редагувати", + "Sửa", + "编辑", + ], + "enable-controller-shortcuts": [ + "Controller-Shortcuts aktivieren", + "Nyalakan pintas kontroler", + "Enable controller shortcuts", + "Habilitar accesos directos del Joystick", + "Activer les raccourcis du contrôle", + "Consenti scorciatoie da controller", + "コントローラーショートカットを有効化", + "컨트롤러 숏컷 활성화", + "Włącz skróty kontrolera", + "Ativar atalhos do controle", + "Включить быстрые клавиши контроллера", + "Oyun kumandası kısayollarını aç", + "Увімкнути ярлики контролера", + "Bật tính năng phím tắt cho bộ điều khiển", + "启用手柄快捷方式", + ], + "enable-local-co-op-support": [ + "Lokale Koop-Unterstützung aktivieren", + "Nyalakan dukungan mode lokal co-op", + "Enable local co-op support", + "Habilitar soporte co-op local", + , + , + "ローカルマルチプレイのサポートを有効化", + , + "Włącz lokalny co-op", + "Habilitar o suporte a co-op local", + "Включить поддержку локальной кооперативной игры", + "Yerel çok oyuncu desteğini aktive et", + "Увімкнути локальну co-op підтримку", + "Kích hoạt tính năng chơi chung cục bộ", + "启用本地多人联机", + ], + "enable-local-co-op-support-note": [ + "Funktioniert nur, wenn das Spiel kein anderes Profil benötigt", + "Hanya berfungsi saat permainan tidak membutuhkan profil berbeda", + "Only works if the game doesn't require a different profile", + "Solo funciona si el juego no requiere un perfil diferente", + , + , + "別アカウントでのサインインを必要としないゲームのみ動作します", + , + "Działa tylko wtedy, gdy gra nie wymaga innego profilu", + "Só funciona se o jogo não exigir um perfil diferente", + "Работает только в том случае, если игра не требует другого профиля", + "Bu seçenek ancak oyun ayrı profillere giriş yapılmasını istemiyorsa etki eder", + "Працює, лише якщо для гри не потрібен інший профіль", + "Chỉ hoạt động nếu game không yêu cầu thêm tài khoản khác", + "仅在当前游戏不要求切换账户时才能使用", + ], + "enable-mic-on-startup": [ + "Mikrofon bei Spielstart aktivieren", + "Nyalakan mikrofon saat permainan diluncurkan", + "Enable microphone on game launch", + "Activar micrófono al iniciar el juego", + "Activer le microphone lors du lancement du jeu", + "Abilita il microfono all'avvio del gioco", + "ゲーム起動時にマイクを有効化", + "게임 시작 시 마이크 활성화", + "Włącz mikrofon przy uruchomieniu gry", + "Ativar microfone ao iniciar um jogo", + "Автоматически включать микрофон при запуске игры", + "Oyun başlarken mikrofonu aç", + "Увімкнути мікрофон при запуску гри", + "Bật mic lúc vào game", + "游戏启动时打开麦克风", + ], + "enable-mkb": [ + "Controller mit Maus & Tastatur emulieren", + "Tirukan kontroler menggunakan Mouse & Keyboard", + "Emulate controller with Mouse & Keyboard", + "Emular mandos con teclado y ratón", + , + "Abilita il supporto per mouse e tastiera", + "マウス&キーボード操作をコントローラー化", + "마우스 & 키보드 활성화", + "Emuluj kontroler za pomocą myszy i klawiatury", + "Emular controlador com mouse e teclado", + "Эмулировать контроллер с помощью мыши и клавиатуры", + "Klavye ve fareyle oyun kumandasını taklit et", + "Емуляція контролера за допомогою миші та клавіатури", + "Giả lập tay cầm bằng Chuột và Bàn phím", + "使用键鼠模拟手柄输入", + ], + "enable-quick-glance-mode": [ + "\"Kurzer Blick\"-Modus aktivieren", + "Aktifkan mode \"Quick Glance\"", + "Enable \"Quick Glance\" mode", + "Activar modo \"Vista rápida\"", + "Activer le mode \"Aperçu rapide\"", + "Abilita la modalità Quick Glance", + "クイック確認モードを有効化", + "\"퀵 글랜스\" 모드 활성화", + "Włącz tryb \"Quick Glance\"", + "Ativar modo \"Relance\"", + "Включить режим «Быстрый взгляд»", + "\"Seri Bakış\" modunu aç", + "Увімкнути режим \"Quick Glance\"", + "Bật chế độ \"Xem nhanh\"", + "仅在打开设置时显示统计信息", + ], + "enable-remote-play-feature": [ + "\"Remote Play\" Funktion aktivieren", + "Nyalakan fitur \"Remote Play\"", + "Enable the \"Remote Play\" feature", + "Activar la función \"Reproducción remota\"", + , + "Abilitare la funzione \"Riproduzione remota\"", + "リモートプレイ機能を有効化", + "\"리모트 플레이\" 기능 활성화", + "Włącz funkcję \"Gra zdalna\"", + "Ativar o recurso \"Reprodução Remota\"", + "Включить функцию «Удаленная игра»", + "\"Uzaktan Oynama\" özelliğini aktive et", + "Увімкнути функцію \"Remote Play\"", + "Bật tính năng \"Chơi Từ Xa\"", + "启用\"Remote Play\"主机串流", + ], + "enable-volume-control": [ + "Lautstärkeregelung aktivieren", + "Nyalakan fitur kontrol volume", + "Enable volume control feature", + "Habilitar la función de control de volumen", + "Activer la fonction de contrôle du volume", + "Abilità controlli volume", + "音量調節機能を有効化", + "음량 조절 기능 활성화", + "Włącz funkcję kontroli głośności", + "Ativar recurso de controle de volume", + "Включить управление громкостью", + "Ses düzeyini yönetmeyi etkinleştir", + "Увімкнути функцію керування гучністю", + "Bật tính năng điều khiển âm lượng", + "启用音量控制", + ], + "enabled": [ + "Aktiviert", + "Diaktifkan", + "Enabled", + "Activado", + , + , + "有効", + "활성화됨", + "Włączony", + "Ativado", + "Включено", + "Açık", + "Увімкнено", + "Đã bật", + "启用", + ], + "experimental": [ + "Experimentell", + "Eksperimental", + "Experimental", + "Experimental", + , + , + "実験的機能", + , + "Eksperymentalne", + "Experimental", + "Экспериментально", + "Deneme aşamasında", + "Експериментальне", + "Thử nghiệm", + "实验性功能", + ], + "export": [ + "Exportieren", + "Ekspor", + "Export", + "Exportar", + , + , + "エクスポート(書出し)", + "내보내기", + "Eksportuj", + "Exportar", + "Экспортировать", + "Dışa aktar", + "Експорт", + "Xuất", + "导出", + ], + "fast": [ + "Schnell", + "Cepat", + "Fast", + "Rápido", + , + "Veloce", + "高速", + "빠름", + "Szybko", + "Rápido", + "Быстрый", + "Hızlı", + "Швидкий", + "Nhanh", + "快速", + ], + "fortnite-allow-stw-mode": [ + "Erlaubt das Spielen im \"STW\"-Modus auf Mobilgeräten", + "Aktikan mode STW", + "Allows playing STW mode on mobile", + "Permitir jugar al modo STW en el móvil", + , + , + "モバイル版で「世界を救え」をプレイできるようになります", + , + "Zezwól na granie w tryb STW na urządzeniu mobilnym", + "Permitir a reprodução do modo STW no celular", + "Позволяет играть в режиме STW на мобильных устройствах", + "Mobil cihazda Fortnite: Dünyayı Kurtar modunu etkinleştir", + "Дозволити відтворення режиму STW на мобільному пристрої", + "Cho phép chơi chế độ STW trên điện thoại", + "允许游玩Save the World模式", + ], + "fortnite-force-console-version": [ + "Fortnite: Erzwinge Konsolenversion", + "Fortnite: Paksa versi konsol", + "Fortnite: force console version", + "Fortnite: forzar versión de consola", + , + "Fortnite: Foza la versione console", + "Fortnite: 強制的にコンソール版を起動する", + , + "Fortnite: wymuś wersję konsolową", + "Fortnite: forçar versão para console", + "Fortnite: форсированная консольная версия", + "Fortnite'ın konsol sürümünü aç", + "Fortnite: примусова консольна версія", + "Fortnite: bắt buộc phiên bản console", + "Fortnite: 强制使用主机版客户端", + ], + "getting-consoles-list": [ + "Rufe Liste der Konsolen ab...", + "Mendapatkan daftar konsol...", + "Getting the list of consoles...", + "Obteniendo la lista de consolas...", + , + "Ottenere la lista delle consoles...", + "本体のリストを取得中...", + "콘솔 목록 불러오는 중...", + "Pobieranie listy konsoli...", + "Obtendo a lista de consoles...", + "Получение списка консолей...", + "Konsol listesine erişiliyor...", + "Отримання списку консолей...", + "Đang lấy danh sách các console...", + "正在获取控制台列表...", + ], + "help": [ + "Hilfe", + "Bantuan", + "Help", + "Ayuda", + , + , + "ヘルプ", + , + "Pomoc", + "Ajuda", + "Справка", + "Yardım", + "Довідка", + "Trợ giúp", + "帮助", + ], + "hide-idle-cursor": [ + "Mauszeiger bei Inaktivität ausblenden", + "Sembunyikan kursor mouse saat tidak digunakan", + "Hide mouse cursor on idle", + "Ocultar el cursor del ratón al estar inactivo", + "Masquer le curseur de la souris", + "Nascondi il cursore previa inattività", + "マウスカーソルを3秒間動かしていない場合に非表示", + "대기 상태에서 마우스 커서 숨기기", + "Ukryj kursor myszy podczas bezczynności", + "Ocultar o cursor do mouse quando ocioso", + "Скрыть курсор мыши при бездействии", + "Boştayken fare imlecini gizle", + "Приховати курсор при очікуванні", + "Ẩn con trỏ chuột khi không di chuyển", + "空闲时隐藏鼠标", + ], + "hide-scrollbar": [ + "Scrollbalken der Webseite ausblenden", + "Sembunyikan bilah gulir halaman", + "Hide web page's scrollbar", + "Oculta la barra de desplazamiento de la página", + , + , + "Webページのスクロールバーを隠す", + , + "Ukryj pasek przewijania strony", + "Ocultar a barra de rolagem da página", + "Скрыть полосу прокрутки страницы", + "Yandaki kaydırma çubuğunu gizle", + "Приховати смугу прокрутки вебсторінок", + "Ẩn thanh cuộn của trang web", + "隐藏浏览器滚动条", + ], + "hide-system-menu-icon": [ + "Symbol des System-Menüs ausblenden", + "Sembunyikan ikon menu sistem", + "Hide System menu's icon", + "Ocultar el icono del menú del sistema", + "Masquer l'icône du menu système", + "Nascondi icona del menu a tendina", + "システムメニューのアイコンを非表示", + "시스템 메뉴 아이콘 숨기기", + "Ukryj ikonę menu systemu", + "Ocultar ícone do menu do Sistema", + "Скрыть значок системного меню", + "Sistem menüsü simgesini gizle", + "Приховати іконку системного меню", + "Ẩn biểu tượng của menu Hệ thống", + "隐藏系统菜单图标", + ], + "horizontal-sensitivity": [ + "Horizontale Empfindlichkeit", + "Sensitifitas horizontal", + "Horizontal sensitivity", + "Sensibilidad horizontal", + , + , + "左右方向の感度", + , + "Czułość pozioma", + "Sensibilidade horizontal", + "Горизонтальная чувствительность", + "Yatay hassasiyet", + "Горизонтальна чутливість", + "Độ nhạy ngang", + "水平灵敏度", + ], + "import": [ + "Importieren", + "Impor", + "Import", + "Importar", + , + , + "インポート(読込み)", + "가져오기", + "Importuj", + "Importar", + "Импортировать", + "İçeri aktar", + "Імпорт", + "Nhập", + "导入", + ], + "install-android": [ + "\"Better xCloud\" App für Android installieren", + "Pasang aplikasi Better xCloud untuk Android", + "Install Better xCloud app for Android", + "Instale la aplicación Better xCloud para Android", + , + , + "Android用のBetter xCloudをインストール", + , + "Zainstaluj aplikację Better xCloud na Androida", + "Instalar o aplicativo Better xCloud para Android", + "Установите приложение Better xCloud для Android", + "Better xCloud'un Android uygulamasını indir", + "Встановити додаток Better xCloud для Android", + "Cài đặt ứng dụng Better xCloud cho Android", + "安装Better xCloud安卓客户端", + ], + "keyboard-shortcuts": [ + "Tastatur-Shortcuts", + "Pintasan keyboard", + "Keyboard shortcuts", + "Atajos del teclado", + , + , + "キーボードショートカット", + , + "Skróty klawiszowe", + "Atalhos do teclado", + "Горячие клавиши", + "Klavye kısayolları", + "Комбінації клавіш", + "Các phím tắt bàn phím", + "键盘快捷键", + ], + "language": [ + "Sprache", + "Bahasa", + "Language", + "Idioma", + "Langue", + "Lingua", + "言語", + "언어", + "Język", + "Idioma", + "Язык", + "Dil", + "Мова", + "Ngôn ngữ", + "切换语言", + ], + "large": [ + "Groß", + "Besar", + "Large", + "Grande", + "Grande", + "Grande", + "大", + "크게", + "Duży", + "Grande", + "Большой", + "Büyük", + "Великий", + "Lớn", + "大", + ], + "layout": [ + "Layout", + "Tata letak", + "Layout", + "Diseño", + , + "Layout", + "レイアウト", + "레이아웃", + "Układ", + "Layout", + "Расположение", + "Arayüz Görünümü", + "Розмітка", + "Bố cục", + "布局", + ], + "left-stick": [ + "Linker Stick", + "Stik kiri", + "Left stick", + "Joystick izquierdo", + , + , + "左スティック", + "왼쪽 스틱", + "Lewy drążek analogowy", + "Direcional analógico esquerdo", + "Левый стик", + "Sol analog çubuk", + "Лівий стік", + "Analog trái", + "左摇杆", + ], + "loading-screen": [ + "Ladebildschirm", + "Pemuatan layar", + "Loading screen", + "Pantalla de carga", + "Écran de chargement", + "Schermata di caricamento", + "ロード画面", + "로딩 화면", + "Ekran wczytywania", + "Tela de carregamento", + "Экран загрузки", + "Yükleme ekranı", + "Екран завантаження", + "Màn hình chờ", + "载入画面", + ], + "local-co-op": [ + "Lokales Koop", + "Lokal co-op", + "Local co-op", + "Co-op local", + , + , + "ローカルマルチプレイ", + , + "Lokalna kooperacja", + "Co-op local", + "Локальная кооперативная игра", + "Yerel çoklu oyunculu", + "Локальний co-op", + "Chơi chung cục bộ", + "本地多人联机", + ], + "map-mouse-to": [ + "Maus binden an", + "Petakan mouse ke", + "Map mouse to", + "Mapear ratón a", + , + , + "マウスの割り当て", + , + "Przypisz myszkę do", + "Mapear o mouse para", + "Наведите мышку на", + "Fareyi ata", + "Прив'язати мишу до", + "Gán chuột với", + "将鼠标映射到", + ], + "may-not-work-properly": [ + "Funktioniert evtl. nicht fehlerfrei!", + "Mungkin tidak berfungsi dengan baik!", + "May not work properly!", + "¡Puede que no funcione correctamente!", + , + "Potrebbe non funzionare correttamente!", + "正常に動作しない場合があります!", + "제대로 작동하지 않을 수 있음!", + "Może nie działać poprawnie!", + "Pode não funcionar corretamente!", + "Может работать некорректно!", + "Düzgün çalışmayabilir!", + "Може працювати некоректно!", + "Có thể không hoạt động!", + "可能无法正常工作!", + ], + "menu-stream-settings": [ + "Stream Einstellungen", + "Pengaturan stream", + "Stream settings", + "Ajustes del stream", + "Réglages Stream", + "Impostazioni dello stream", + "ストリーミング設定", + "스트리밍 설정", + "Ustawienia strumienia", + "Ajustes de transmissão", + "Настройки потоковой передачи", + "Yayın ayarları", + "Налаштування трансляції", + "Cấu hình stream", + "串流设置", + ], + "menu-stream-stats": [ + "Stream Statistiken", + "Statistik stream", + "Stream stats", + "Estadísticas del stream", + "Statistiques du stream", + "Statistiche dello stream", + "ストリーミング統計情報", + "통계", + "Statystyki strumienia", + "Estatísticas da transmissão", + "Статистика стрима", + "Yayın durumu", + "Статистика трансляції", + "Thông số stream", + "串流统计数据", + ], + "microphone": [ + "Mikrofon", + "Mikrofon", + "Microphone", + "Micrófono", + "Microphone", + "Microfono", + "マイク", + "마이크", + "Mikrofon", + "Microfone", + "Микрофон", + "Mikrofon", + "Мікрофон", + "Micro", + "麦克风", + ], + "mkb-adjust-ingame-settings": [ + "Vielleicht müssen auch Empfindlichkeit & Deadzone in den Spieleinstellungen angepasst werden", + "Anda mungkin butuh untuk menyesuaikan pengaturan sensitivitas & deadzone dalam permainan", + "You may also need to adjust the in-game sensitivity & deadzone settings", + "También puede que necesites ajustar la sensibilidad del juego y la configuración de la zona muerta", + , + "Potrebbe anche essere necessario regolare le impostazioni della sensibilità e deadzone del gioco", + "ゲーム内の設定で感度とデッドゾーンの調整が必要な場合があります", + , + "Może być również konieczne dostosowanie czułości w grze i ustawienia 'martwej strefy' urządzenia", + "Você talvez também precise ajustar as configurações de sensibilidade e zona morta no jogo", + "Также может потребоваться изменить настройки чувствительности и мертвой зоны в игре", + "Bu seçenek etkinken bile oyun içi seçeneklerden hassasiyet ve ölü bölge ayarlarını düzeltmeniz gerekebilir", + "Можливо, вам також доведеться регулювати чутливість і deadzone у параметрах гри", + "Có thể bạn cần phải điều chỉnh các thông số độ nhạy và điểm chết trong game", + "您可能还需要调整游戏内的灵敏度和死区设置", + ], + "mkb-click-to-activate": [ + "Klicken zum Aktivieren", + "Klik untuk mengaktifkan", + "Click to activate", + "Haz clic para activar", + , + "Fare clic per attivare", + "マウスクリックで開始", + , + "Kliknij, aby aktywować", + "Clique para ativar", + "Нажмите, чтобы активировать", + "Etkinleştirmek için tıklayın", + "Натисніть, щоб активувати", + "Nhấn vào để kích hoạt", + "单击以启用", + ], + "mkb-disclaimer": [ + "Das Nutzen dieser Funktion beim Online-Spielen könnte als Betrug angesehen werden", + "Mengaktifkan fitur ini saat bermain online akan dianggap curang", + "Using this feature when playing online could be viewed as cheating", + "Usar esta función al jugar en línea podría ser visto como trampas", + , + "L'utilizzo di questa funzione quando si gioca online potrebbe essere considerato un baro", + "オンラインプレイでこの機能を使用すると不正行為と判定される可能性があります", + , + "Używanie tej funkcji podczas grania online może być postrzegane jako oszukiwanie", + "Usar esta função em jogos online pode ser considerado como uma forma de trapaça", + "Использование этой функции при игре онлайн может рассматриваться как читерство", + "Bu özellik çevrimiçi oyunlarda sizi hile yapıyormuşsunuz gibi gösterebilir", + "Використання цієї функції під час гри онлайн може розглядатися як шахрайство", + "Sử dụng chức năng này khi chơi trực tuyến có thể bị xem là gian lận", + "游玩在线游戏时,使用此功能可能被视为作弊。", + ], + "mouse-and-keyboard": [ + "Maus & Tastatur", + "Mouse & Keyboard", + "Mouse & Keyboard", + "Ratón y teclado", + , + "Mouse e tastiera", + "マウス&キーボード", + "마우스 & 키보드", + "Mysz i klawiatura", + "Mouse e Teclado", + "Мышь и клавиатура", + "Klavye ve Fare", + "Миша та клавіатура", + "Chuột và Bàn phím", + "鼠标和键盘", + ], + "muted": [ + "Stumm", + "Bisukan", + "Muted", + "Silenciado", + , + "Microfono disattivato", + "ミュート", + "음소거", + "Wyciszony", + "Mudo", + "Выкл микрофон", + "Kapalı", + "Без звуку", + "Đã tắt âm", + "静音", + ], + "name": [ + "Name", + "Nama", + "Name", + "Nombre", + , + , + "名前", + "이름", + "Nazwa", + "Nome", + "Имя", + "İsim", + "Назва", + "Tên", + "名称", + ], + "new": [ + "Neu", + "Baru", + "New", + "Nuevo", + , + , + "新しい", + "새로 만들기", + "Nowy", + "Novo", + "Создать", + "Yeni", + "Новий", + "Tạo mới", + "新建", + ], + "no-consoles-found": [ + "Keine Konsolen gefunden", + "Tidak ditemukan konsol", + "No consoles found", + "No se encontraron consolas", + , + "Nessuna console trovata", + "本体が見つかりません", + "콘솔을 찾을 수 없음", + "Nie znaleziono konsoli", + "Nenhum console encontrado", + "Консолей не найдено", + "Konsol bulunamadı", + "Не знайдено консолі", + "Không tìm thấy console nào", + "未找到主机", + ], + "normal": [ + "Mittel", + "Normal", + "Normal", + "Normal", + "Normal", + "Normale", + "標準", + "보통", + "Normalny", + "Normal", + "Средний", + "Normal", + "Нормальний", + "Thường", + "中", + ], + "off": [ + "Aus", + "Mati", + "Off", + "Apagado", + "Désactivé", + "Off", + "オフ", + "꺼짐", + "Wyłączone", + "Desligado", + "Выключен", + "Kapalı", + "Вимкнено", + "Tắt", + "关", + ], + "on": [ + "An", + "Hidup", + "On", + "Activado", + , + "Attivo", + "オン", + "켜짐", + "Włącz", + "Ativado", + "Вкл", + "Açık", + "Увімкнено", + "Bật", + "开启", + ], + "only-supports-some-games": [ + "Unterstützt nur einige Spiele", + "Hanya mendukung beberapa permainan", + "Only supports some games", + "Sólo soporta algunos juegos", + , + "Supporta solo alcuni giochi", + "一部のゲームのみサポート", + "몇몇 게임만 지원", + "Wspiera tylko niektóre gry", + "Suporta apenas alguns jogos", + "Поддерживает только некоторые игры", + "Yalnızca belli oyunlar destekleniyor", + "Підтримує лише деякі ігри", + "Chỉ hỗ trợ một vài game", + "仅支持一些游戏", + ], + "opacity": [ + "Deckkraft", + "Opasitas", + "Opacity", + "Opacidad", + "Opacité", + "Opacità", + "透過度", + "불투명도", + "Przezroczystość", + "Opacidade", + "Непрозрачность", + "Saydamsızlık", + "Непрозорість", + "Độ mờ", + "透明度", + ], + "other": [ + "Sonstiges", + "Lainnya", + "Other", + "Otro", + "Autres", + "Altro", + "その他", + "기타", + "Inne", + "Outros", + "Прочее", + "Diğer", + "Інше", + "Khác", + "其他", + ], + "playing": [ + "Spielt", + "Sedang memainkan", + "Playing", + "Jugando", + , + "Installa l'applicazione Better xCloud per Android", + "プレイ中", + "플레이 중", + "W grze", + "Jogando", + "Играет", + "Şu anda oyunda", + "Гра триває", + "Đang chơi", + "游戏中", + ], + "position": [ + "Position", + "Posisi", + "Position", + "Posición", + "Position", + "Posizione", + "位置", + "위치", + "Pozycja", + "Posição", + "Расположение", + "Konum", + "Позиція", + "Vị trí", + "位置", + ], + "powered-off": [ + "Ausgeschaltet", + "Dimatikan", + "Powered off", + "Desactivado", + , + "Spento", + "本体オフ", + "전원 꺼짐", + "Zasilanie wyłączone", + "Desligado", + "Выключено", + "Kapalı", + "Вимкнений", + "Đã tắt nguồn", + "关机", + ], + "powered-on": [ + "Eingeschaltet", + "Menyala", + "Powered on", + "Activado", + , + "Acceso", + "本体オン", + "전원 켜짐", + "Zasilanie włączone", + "Ligado", + "Включено", + "Açık", + "Увімкнений", + "Đang bật nguồn", + "开机", + ], + "prefer-ipv6-server": [ + "IPv6-Server bevorzugen", + "Utamakan Server IPv6", + "Prefer IPv6 server", + "Servidor IPv6 preferido", + "Préférer le serveur IPv6", + "Preferisci server IPv6", + "IPv6 サーバーを優先", + "IPv6 서버 우선", + "Preferuj serwer IPv6", + "Preferir servidor IPv6", + "Предпочитать IPv6 сервер", + "IPv6 sunucusunu tercih et", + "Віддавати перевагу IPv6", + "Ưu tiên máy chủ IPv6", + "优先使用 IPv6 服务器", + ], + "preferred-game-language": [ + "Bevorzugte Spielsprache", + "Bahasa Permainan yang diutamakan", + "Preferred game's language", + "Idioma preferencial del juego", + "Langue préférée du jeu", + "Lingua del gioco preferita", + "ゲームの優先言語設定", + "선호하는 게임 언어", + "Preferowany język gry", + "Idioma preferencial do jogo", + "Предпочитаемый язык игры", + "Oyunda tercih edilen dil", + "Бажана мова гри", + "Ngôn ngữ game ưu tiên", + "首选游戏语言", + ], + "preset": [ + "Voreinstellung", + "Preset", + "Preset", + "Preajuste", + , + , + "プリセット", + "프리셋", + "Szablon", + "Predefinição", + "Шаблон", + "Hazır ayar", + "Пресет", + "Thiết lập sẵn", + "预设", + ], + "press-esc-to-cancel": [ + "Zum Abbrechen \"Esc\" drücken", + "Tekan Esc untuk batal", + "Press Esc to cancel", + "Presione Esc para cancelar", + , + , + "Escを押してキャンセル", + "ESC를 눌러 취소", + "Naciśnij Esc, aby anulować", + "Pressione Esc para cancelar", + "Нажмите Esc для отмены", + "İptal etmek için Esc'ye basın", + "Натисніть Esc, щоб скасувати", + "Nhấn Esc để bỏ qua", + "按下ESC键以取消", + ], + "press-key-to-toggle-mkb": [ + (e: any) => `${e.key}: Maus- und Tastaturunterstützung an-/ausschalten`, + (e: any) => `Tekan ${e.key} untuk mengaktifkan fitur Mouse dan Keyboard`, + (e: any) => `Press ${e.key} to toggle the Mouse and Keyboard feature`, + (e: any) => `Pulsa ${e.key} para activar la función de ratón y teclado`, + , + , + (e: any) => `${e.key} キーでマウスとキーボードの機能を切り替える`, + (e: any) => `${e.key} 키를 눌러 마우스와 키보드 기능을 활성화 하십시오`, + (e: any) => `Naciśnij ${e.key}, aby przełączyć funkcję myszy i klawiatury`, + (e: any) => `Pressione ${e.key} para ativar/desativar a função de Mouse e Teclado`, + (e: any) => `Нажмите ${e.key} для переключения функции мыши и клавиатуры`, + (e: any) => `Klavye ve fare özelliğini açmak için ${e.key} tuşuna basın`, + (e: any) => `Натисніть ${e.key}, щоб увімкнути або вимкнути функцію миші та клавіатури`, + (e: any) => `Nhấn ${e.key} để bật/tắt tính năng Chuột và Bàn phím`, + (e: any) => `按下 ${e.key} 切换键鼠模式`, + ], + "press-to-bind": [ + "Zum Festlegen Taste drücken oder mit der Maus klicken...", + "Tekan tombol atau gunakan mouse untuk mengaitkan...", + "Press a key or do a mouse click to bind...", + "Presione una tecla o haga un clic del ratón para enlazar...", + , + , + "キーを押すかマウスをクリックして割り当て...", + "정지하려면 아무키나 마우스를 클릭해주세요...", + "Naciśnij klawisz lub kliknij myszą, aby przypisać...", + "Pressione uma tecla ou clique do mouse para vincular...", + "Нажмите клавишу или щелкните мышкой, чтобы связать...", + "Klavyedeki bir tuşa basarak veya fareyle tıklayarak tuş ataması yapın...", + "Натисніть клавішу або кнопку миші, щоб прив'язати...", + "Nhấn nút hoặc nhấn chuột để gán...", + "按相应按键或鼠标键来绑定", + ], + "prompt-preset-name": [ + "Voreinstellung Name:", + "Nama preset:", + "Preset's name:", + "Nombre del preajuste:", + , + , + "プリセット名:", + "프리셋 이름:", + "Nazwa szablonu:", + "Nome da predefinição:", + "Имя шаблона:", + "Hazır ayar adı:", + "Назва пресету:", + "Tên của mẫu sẵn:", + "预设名称:", + ], + "ratio": [ + "Seitenverhältnis", + "Rasio", + "Ratio", + "Relación de aspecto", + "Ratio", + "Rapporto", + "比率", + "화면 비율", + "Współczynnik proporcji", + "Proporção", + "Соотношение сторон", + "Görüntü oranı", + "Співвідношення сторін", + "Tỉ lệ", + "宽高比", + ], + "reduce-animations": [ + "Animationen reduzieren", + "Kurangi animasi antarmuka", + "Reduce UI animations", + "Reduce las animaciones de la interfaz", + "Réduire les animations dans l’interface", + "Animazioni ridottte", + "UIアニメーションを減らす", + "애니메이션 감소", + "Ogranicz animacje interfejsu", + "Reduzir animações da interface", + "Убрать анимации интерфейса", + "Arayüz animasyonlarını azalt", + "Зменшити анімацію інтерфейсу", + "Giảm hiệu ứng chuyển động", + "减少UI动画", + ], + "region": [ + "Region", + "Wilayah", + "Region", + "Región", + "Région", + "Regione", + "地域", + "지역", + "Region", + "Região", + "Регион", + "Bölge", + "Регіон", + "Khu vực", + "地区", + ], + "remote-play": [ + "Remote Play", + "Remote Play", + "Remote Play", + "Reproducción remota", + , + "Riproduzione Remota", + "リモートプレイ", + "리모트 플레이", + "Gra zdalna", + "Reprodução remota", + "Удаленная игра", + "Uzaktan Bağlanma", + "Віддалена гра", + "Chơi Từ Xa", + "远程串流", + ], + "rename": [ + "Umbenennen", + "Ubah nama", + "Rename", + "Renombrar", + , + , + "名前変更", + "이름 바꾸기", + "Zmień nazwę", + "Renomear", + "Переименовать", + "Ad değiştir", + "Перейменувати", + "Sửa tên", + "重命名", + ], + "right-click-to-unbind": [ + "Rechtsklick auf Taste: Zuordnung aufheben", + "Klik kanan pada tombol untuk menghapus", + "Right-click on a key to unbind it", + "Clic derecho en una tecla para desvincularla", + , + , + "右クリックで割り当て解除", + "할당 해제하려면 키를 오른쪽 클릭하세요", + "Kliknij prawym przyciskiem myszy na klawisz, aby anulować przypisanie", + "Clique com o botão direito em uma tecla para desvinculá-la", + "Щелкните правой кнопкой мыши по кнопке, чтобы отвязать её", + "Tuş atamasını kaldırmak için fareyle sağ tık yapın", + "Натисніть правою кнопкою миші, щоб відв'язати", + "Nhấn chuột phải lên một phím để gỡ nó", + "右键解除绑定", + ], + "right-stick": [ + "Rechter Stick", + "Stik kanan", + "Right stick", + "Joystick derecho", + , + , + "右スティック", + "오른쪽 스틱", + "Prawy drążek analogowy", + "Direcional analógico direito", + "Правый стик", + "Sağ analog çubuk", + "Правий стік", + "Analog phải", + "右摇杆", + ], + "rocket-always-hide": [ + "Immer ausblenden", + "Selalu sembunyikan", + "Always hide", + "Ocultar siempre", + "Toujours masquer", + "Nascondi sempre", + "常に非表示", + "항상 숨기기", + "Zawsze ukrywaj", + "Sempre ocultar", + "Всегда скрывать", + "Her zaman gizle", + "Ховати завжди", + "Luôn ẩn", + "始终隐藏", + ], + "rocket-always-show": [ + "Immer anzeigen", + "Selalu tampilkan", + "Always show", + "Mostrar siempre", + "Toujours afficher", + "Mostra sempre", + "常に表示", + "항상 표시", + "Zawsze pokazuj", + "Sempre mostrar", + "Всегда показывать", + "Her zaman göster", + "Показувати завжди", + "Luôn hiển thị", + "始终显示", + ], + "rocket-animation": [ + "Raketen Animation", + "Animasi roket", + "Rocket animation", + "Animación del cohete", + "Animation de la fusée", + "Razzo animato", + "ロケットのアニメーション", + "로켓 애니메이션", + "Animacja rakiety", + "Animação do foguete", + "Анимация ракеты", + "Roket animasyonu", + "Анімація ракети", + "Phi thuyền", + "火箭动画", + ], + "rocket-hide-queue": [ + "Bei Warteschlange ausblenden", + "Sembunyikan ketika mengantri", + "Hide when queuing", + "Ocultar al hacer cola", + "Masquer lors de la file d'attente", + "Nascondi durante la coda", + "待機中は非表示", + "대기 중에는 숨기기", + "Ukryj podczas czekania w kolejce", + "Ocultar quando estiver na fila", + "Скрыть, когда есть очередь", + "Sıradayken gizle", + "Не показувати у черзі", + "Ẩn khi xếp hàng chờ", + "排队时隐藏", + ], + "safari-failed-message": [ + "Ausführen von \"Better xCloud\" fehlgeschlagen. Versuche es erneut, bitte warten...", + "Gagal menjalankan Better xCloud. Mencoba ulang, Mohon tunggu...", + "Failed to run Better xCloud. Retrying, please wait...", + "No se pudo ejecutar Better xCloud. Reintentando, por favor espera...", + "Impossible d'exécuter Better xCloud. Nouvelle tentative, veuillez patienter...", + "Si è verificato un errore durante l'esecuzione di Better xCloud. Nuovo tentativo, attendere...", + "Better xCloud の実行に失敗しました。再試行中...", + "Better xCloud 시작에 실패했습니다. 재시도중이니 잠시만 기다려 주세요.", + "Nie udało się uruchomić Better xCloud. Ponawiam próbę...", + "Falha ao executar o Better xCloud. Tentando novamente, aguarde...", + "Не удалось запустить Better xCloud. Идет перезапуск, пожалуйста, подождите...", + "Better xCloud çalıştırılamadı. Yeniden deneniyor...", + "Не вдалий старт Better xCloud. Повторна спроба, будь ласка, зачекайте...", + "Không thể chạy Better xCloud. Đang thử lại, vui lòng chờ...", + "插件无法运行。正在重试,请稍候...", + ], + "saturation": [ + "Sättigung", + "Saturasi", + "Saturation", + "Saturación", + "Saturation", + "Saturazione", + "彩度", + "채도", + "Nasycenie", + "Saturação", + "Насыщенность", + "Renk doygunluğu", + "Насиченість", + "Độ bão hòa", + "饱和度", + ], + "save": [ + "Speichern", + "Simpan", + "Save", + "Guardar", + , + , + "保存", + "저장", + "Zapisz", + "Salvar", + "Сохранить", + "Kaydet", + "Зберегти", + "Lưu", + "保存", + ], + "screenshot-apply-filters": [ + "Videofilter auf Screenshots anwenden", + "Terapkan filter video pada screenshot", + "Applies video filters to screenshots", + "Aplica filtros de vídeo a las capturas de pantalla", + , + , + "スクリーンショットにビデオフィルターを適用", + , + "Stosuje filtry wideo do zrzutów ekranu", + "Aplicar filtros de vídeo às capturas de tela", + "Применяет фильтры видео к скриншотам", + "Görsel filtreleri ekran görüntülerine de uygular", + "Застосовує відеофільтри до знімків екрана", + "Áp dụng hiệu ứng video vào ảnh chụp màn hình", + "为截图添加滤镜", + ], + "screenshot-button-position": [ + "Position des Screenshot-Buttons", + "Posisi tombol Screenshot", + "Screenshot button's position", + "Posición del botón de captura de pantalla", + "Position du bouton de capture d'écran", + "Posizione del pulsante screenshot", + "スクリーンショットボタンの位置", + "스크린샷 버튼 위치", + "Pozycja przycisku zrzutu ekranu", + "Posição do botão de captura de tela", + "Расположение кнопки скриншота", + "Ekran görüntüsü düğmesi konumu", + "Позиція кнопки знімка екрана", + "Vị trí của nút Chụp màn hình", + "截图按钮位置", + ], + "separate-touch-controller": [ + "Trenne Touch-Controller & Controller #1", + "Pisahkan Kontrol sentuh & Kontroler #1", + "Separate Touch controller & Controller #1", + "Separar controlador táctil y controlador #1", + , + , + "タッチコントローラーとコントローラー#1を分ける", + , + "Oddziel Kontroler dotykowy i Kontroler #1", + "Separar o Controle por Toque e o Controle #1", + "Раздельный сенсорный контроллер и контроллер #1", + "Dokunmatik kumandayı ve birincil kumandayı ayrı tut", + "Відокремити Сенсорний контролер та Контролер #1", + "Tách biệt Bộ điều khiển cảm ứng và Tay cầm #1", + "虚拟摇杆和手柄分别控制不同角色", + ], + "separate-touch-controller-note": [ + "Touch-Controller ist Spieler 1, Controller #1 ist Spieler 2", + "Kontrol sentuh adalah Player 1, Kontroler #1 adalah Player 2", + "Touch controller is Player 1, Controller #1 is Player 2", + "El controlador táctil es Jugador 1, Controlador #1 es Jugador 2", + , + , + "タッチコントローラーがプレイヤー1、コントローラー#1がプレイヤー2に割り当てられます", + , + "Kontroler dotykowy to Gracz 1, Kontroler #1 to Gracz 2", + "O Controle por Toque é o Jogador 1, o Controle #1 é o Jogador 2", + "Сенсорный контроллер — игрок 1, контроллер #1 — игрок 2", + "Dokunmaktik kumanda birinci oyuncu, birincil kumanda ikinci oyuncu", + "Сенсорний контролер це Гравець 1, Контролер #1 це Гравець 2", + "Bộ điều khiển cảm ứng là Người chơi 1, Tay cầm #1 là Người chơi 2", + "虚拟摇杆为玩家1,手柄#1为玩家2", + ], + "server": [ + "Server", + "Server", + "Server", + "Servidor", + "Serveur", + "Server", + "サーバー", + "서버", + "Serwer", + "Servidor", + "Сервер", + "Sunucu", + "Сервер", + "Máy chủ", + "服务器", + ], + "settings-reload": [ + "Seite neu laden und Änderungen anwenden", + "Muat ulang untuk menerapkan", + "Reload page to reflect changes", + "Actualice la página para aplicar los cambios", + "Recharger la page pour bénéficier des changements", + "Applica e ricarica la pagina", + "ページを更新をして設定変更を適用", + "적용 및 페이지 새로고침", + "Odśwież stronę, aby zastosować zmiany", + "Recarregue a página para aplicar as alterações", + "Перезагрузить страницу, чтобы применить изменения", + "Kaydetmek için sayfayı yenile", + "Перезавантажте сторінку, щоб застосувати зміни", + "Tải lại trang để áp dụng các thay đổi", + "重新加载页面以应用更改", + ], + "settings-reloading": [ + "Wird neu geladen...", + "Memuat ulang...", + "Reloading...", + "Recargando...", + "Actualisation...", + "Ricaricamento...", + "更新中...", + "새로고침하는 중...", + "Ponowne ładowanie...", + "Recarregando...", + "Перезагрузка...", + "Sayfa yenileniyor...", + "Перезавантаження...", + "Đang tải lại...", + "正在重新加载...", + ], + "shortcut-keys": [ + "Shortcut-Tasten", + "Tombol pintasan", + "Shortcut keys", + "Teclas de atajo", + , + , + "ショートカットキー", + , + "Skróty klawiszowe", + "Teclas de atalho", + "Горячие клавиши", + "Kısayol tuşları", + "Клавіші швидкого доступу", + "Các phím tắt", + "快捷键", + ], + "show-game-art": [ + "Poster des Spiels anzeigen", + "Tampilkan sampul permainan", + "Show game art", + "Mostrar imagen del juego", + "Afficher la couverture du jeu", + "Mostra immagine del gioco", + "ゲームアートを表示", + "게임 아트 표시", + "Pokaż okładkę gry", + "Mostrar arte do jogo", + "Показывать игровую обложку", + "Oyun resmini göster", + "Показувати ігровий арт", + "Hiển thị ảnh game", + "显示游戏封面", + ], + "show-stats-on-startup": [ + "Statistiken beim Start des Spiels anzeigen", + "Tampilkan statistik ketika permainan dimulai", + "Show stats when starting the game", + "Mostrar estadísticas al iniciar el juego", + "Afficher les statistiques au démarrage de la partie", + "Mostra le statistiche quando si avvia la partita", + "ゲーム開始時に統計情報を表示", + "게임 시작 시 통계 보여주기", + "Pokaż statystyki podczas uruchamiania gry", + "Mostrar estatísticas ao iniciar o jogo", + "Показывать статистику при запуске игры", + "Oyun başlatırken yayın durumunu göster", + "Показувати статистику при запуску гри", + "Hiển thị thông số khi vào game", + "开始游戏时显示统计信息", + ], + "show-wait-time": [ + "Geschätzte Wartezeit anzeigen", + "Tampilkan waktu antrian", + "Show the estimated wait time", + "Mostrar el tiempo de espera estimado", + "Afficher le temps d'attente estimé", + "Mostra una stima del tempo di attesa", + "推定待機時間を表示", + "예상 대기 시간 표시", + "Pokaż szacowany czas oczekiwania", + "Mostrar o tempo de espera estimado", + "Показать предполагаемое время до запуска", + "Tahminî bekleme süresini göster", + "Показувати орієнтовний час очікування", + "Hiển thị thời gian chờ dự kiến", + "显示预计等待时间", + ], + "simplify-stream-menu": [ + "Stream-Menü vereinfachen", + "Sederhanakan menu Stream", + "Simplify Stream's menu", + "Simplificar el menú del stream", + "Simplifier le menu Stream", + "Semplifica il menu della trasmissione", + "ストリーミングメニューのラベルを非表示", + "메뉴 간단히 보기", + "Uprość menu strumienia", + "Simplificar menu de transmissão", + "Упростить меню потока", + "Yayın menüsünü basitleştir", + "Спростити меню трансляції", + "Đơn giản hóa menu của Stream", + "简化菜单", + ], + "skip-splash-video": [ + "Xbox-Logo bei Spielstart überspringen", + "Lewati video splash Xbox", + "Skip Xbox splash video", + "Saltar vídeo de presentación de Xbox", + "Ignorer la vidéo de démarrage Xbox", + "Salta il logo Xbox iniziale", + "Xboxの起動画面をスキップ", + "Xbox 스플래시 건너뛰기", + "Pomiń wstępne intro Xbox", + "Pular introdução do Xbox", + "Пропустить видео с заставкой Xbox", + "Xbox açılış ekranını atla", + "Пропустити заставку Xbox", + "Bỏ qua video Xbox", + "跳过 Xbox 启动动画", + ], + "slow": [ + "Langsam", + "Lambat", + "Slow", + "Lento", + , + "Lento", + "低速", + "느림", + "Wolno", + "Lento", + "Медленный", + "Yavaş", + "Повільний", + "Chậm", + "慢速", + ], + "small": [ + "Klein", + "Kecil", + "Small", + "Pequeño", + "Petite", + "Piccolo", + "小", + "작게", + "Mały", + "Pequeno", + "Маленький", + "Küçük", + "Маленький", + "Nhỏ", + "小", + ], + "smart-tv": [ + "Smart TV", + "Smart TV", + "Smart TV", + "Smart TV", + , + "Smart TV", + "スマートTV", + "스마트 TV", + "Smart TV", + "Smart TV", + "Smart TV", + "Akıllı TV", + "Smart TV", + "TV thông minh", + "智能电视", + ], + "sound": [ + "Ton", + "Suara", + "Sound", + "Sonido", + , + "Suoni", + "サウンド", + "소리", + "Dźwięk", + "Som", + "Звук", + "Ses", + "Звук", + "Âm thanh", + "声音", + ], + "standby": [ + "Standby", + "Siaga", + "Standby", + "Modo de espera", + , + "Sospendi", + "スタンバイ", + "대기", + "Stan czuwania", + "Suspenso", + "Режим ожидания", + "Beklemede", + "Режим очікування", + "Đang ở chế độ chờ", + "待机", + ], + "stat-bitrate": [ + "Bitrate", + "Bitrate", + "Bitrate", + "Tasa de bits", + "Bitrate", + "Bitrate", + "ビットレート", + "비트레이트", + "Bitrate", + "Bitrate", + "Скорость соединения", + "Bit hızı", + "Бітрейт", + "Bitrate", + "码率", + ], + "stat-decode-time": [ + "Dekodierzeit", + "Waktu dekode", + "Decode time", + "Tiempo de decodificación", + "Décodage", + "Decodifica", + "デコード時間", + "디코딩 시간", + "Czas dekodowania", + "Tempo de decodificação", + "Время декодирования", + "Kod çözme süresi", + "Час декодування", + "Thời gian giải mã", + "解码时间", + ], + "stat-fps": [ + "Framerate", + "FPS", + "FPS", + "FPS", + "FPS", + "FPS", + "FPS", + "FPS", + "FPS", + "FPS", + "Кадр/сек", + "FPS", + "Кадрів на секунду", + "FPS", + "帧率", + ], + "stat-frames-lost": [ + "Verlorene Frames", + "Bingkai terbuang", + "Frames lost", + "Pérdida de fotogramas", + "Images perdues", + "Perdita di fotogrammi", + "フレームロス", + "프레임 손실", + "Utracone klatki", + "Quadros perdidos", + "Потери кадров", + "Kare kaybı", + "Кадрів втрачено", + "Số khung hình bị mất", + "丢帧", + ], + "stat-packets-lost": [ + "Paketverluste", + "Paket hilang", + "Packets lost", + "Pérdida de paquetes", + "Perte paquets", + "Perdita di pacchetti", + "パケットロス", + "패킷 손실", + "Utracone pakiety", + "Pacotes perdidos", + "Потери пакетов", + "Paket kaybı", + "Пакетів втрачено", + "Số gói tin bị mất", + "丢包", + ], + "stat-ping": [ + "Ping", + "Ping", + "Ping", + "Latencia", + "Ping", + "Ping", + "Ping", + "지연 시간", + "Ping", + "Ping", + "Задержка соединения", + "Gecikme", + "Затримка", + "Ping", + "延迟", + ], + "stats": [ + "Statistiken", + "Statistik", + "Stats", + "Estadísticas", + "Stats", + "Statistiche", + "統計情報", + "통계", + "Statystyki", + "Estatísticas", + "Статистика", + "Durum", + "Статистика", + "Các thông số", + "统计信息", + ], + "stick-decay-minimum": [ + "Stick Abklingzeit Minimum", + "Minimum pelepasan stik", + "Stick decay minimum", + "Disminuir mínimamente el analógico", + , + , + "スティックの減衰の最小値", + , + "Minimalne opóźnienie drążka", + "Tempo mínimo de redefinição do analógico", + "Минимальная перезарядка стика", + "Çubuğun ortalanma süresi minimumu", + "Мінімальне згасання стіка", + "Độ suy giảm tối thiểu của cần điều khiển", + "最小摇杆回中延迟", + ], + "stick-decay-strength": [ + "Stick Abklingzeit Geschwindigkeit", + "Kekuatan pelepasan stik", + "Stick decay strength", + "Intensidad de decaimiento del analógico", + , + , + "スティックの減衰の強さ", + , + "Siła opóźnienia drążka", + "Velocidade de redefinição do analógico", + "Скорость перезарядки стика", + "Çubuğun ortalanma gücü", + "Сила згасання стіка", + "Sức mạnh độ suy giảm của cần điều khiển", + "摇杆回中强度", + ], + "stream": [ + "Stream", + "Stream", + "Stream", + "Stream", + "Stream", + "Stream", + "ストリーミング", + "스트리밍", + "Stream", + "Transmissão", + "Видеопоток", + "Yayın", + "Трансляція", + "Stream", + "串流", + ], + "stretch": [ + "Strecken", + "Rentangkan", + "Stretch", + "Estirado", + "Étirer", + "Riempi", + "引き伸ばし", + "채우기", + "Rozciągnij", + "Esticar", + "Растянуть", + "Genişlet", + "Розтягнути", + "Kéo giãn", + "拉伸", + ], + "support-better-xcloud": [ + "\"Better xCloud\" unterstützen", + "Dukung Better xCloud", + "Support Better xCloud", + "Apoyar a Better xCloud", + , + , + "Better xCloudをサポート", + , + "Wesprzyj Better xCloud", + "Apoie o Better xCloud", + "Поддержать Better xCloud", + "Better xCloud'a destek ver", + "Підтримати Better xCloud", + "Ủng hộ Better xCloud", + "赞助本插件", + ], + "swap-buttons": [ + "Tasten tauschen", + "Tukar tombol", + "Swap buttons", + "Intercambiar botones", + , + "Inverti i pulsanti", + "ボタン入れ替え", + "버튼 바꾸기", + "Zamień przyciski", + "Trocar botões", + "Поменять кнопки", + "Düğme düzenini ters çevir", + "Поміняти кнопки місцями", + "Hoán đổi nút", + "交换按钮", + ], + "target-resolution": [ + "Festgelegte Auflösung", + "Resolusi", + "Target resolution", + "Calidad de imagen", + "Résolution cible", + "Risoluzione prevista", + "ターゲット解像度", + "목표 해상도", + "Rozdzielczość docelowa", + "Resolução padrão", + "Целевое разрешение", + "Tercih edilen çözünürlük", + "Цільова роздільна здатність", + "Độ phân giải", + "目标分辨率", + ], + "tc-all-games": [ + "Alle Spiele", + "Semua permainan", + "All games", + "Todos los juegos", + "Tous les jeux", + "Tutti i giochi", + "全てのゲームで有効", + "모든 게임", + "Wszystkie gry", + "Todos os jogos", + "Все игры", + "Tüm oyunlar", + "Всі ігри", + "Tất cả các game", + "所有游戏", + ], + "tc-all-white": [ + "Komplett weiß", + "Putih", + "All white", + "Todo blanco", + "Tout blanc", + "Tutti bianchi", + "オールホワイト", + "모두 하얗게", + "Wszystkie białe", + "Todo branco", + "Полностью белые", + "Hepsi beyaz", + "Все біле", + "Trắng hoàn toàn", + "白色", + ], + "tc-auto-off": [ + "Aus, wenn Controller gefunden", + "Mati saat kontroler terhubung", + "Off when controller found", + "Desactivar cuando se encuentra el controlador", + , + , + "コントローラー接続時に無効化", + , + "Wyłącz, gdy kontroler zostanie znaleziony", + "Desligar toque quando o controle estiver conectado", + "Выключить, когда контроллер найден", + "Başka bir kumanda bağlandığında kapat", + "Вимкнено, коли контролер знайдено", + "Tắt khi sử dụng tay cầm", + "手柄连接时隐藏虚拟摇杆", + ], + "tc-availability": [ + "Verfügbarkeit", + "Ketersediaan", + "Availability", + "Disponibilidad", + "Disponibilité", + "Disponibilità", + "強制的に有効化", + "사용 여부", + "Dostępność", + "Disponibilidade", + "В каких играх включить", + "Uygunluk durumu", + "Доступність", + "Khả dụng", + "启用", + ], + "tc-custom-layout-style": [ + "Angepasstes Layout Button Stil", + "Gaya tata letak tombol kustom", + "Custom layout's button style", + "Estilo de botones de diseño personalizado", + "Style personnalisé des boutons", + "Layout dei tasti personalizzato", + "カスタムレイアウト", + "커스텀 레이아웃의 버튼 스타일", + "Niestandardowy układ przycisków", + "Estilo de botão do layout personalizado", + "Пользовательский стиль кнопок", + "Özelleştirilmiş düğme düzeninin biçimi", + "Користувацький стиль кнопок", + "Màu của bố cục tùy chọn", + "特殊游戏按钮样式", + ], + "tc-muted-colors": [ + "Matte Farben", + "Warna redup", + "Muted colors", + "Colores apagados", + "Couleurs adoucies", + "Riduci intensità colori", + "ミュートカラー", + "저채도 색상", + "Stonowane kolory", + "Cores opacas", + "Приглушенные цвета", + "Yumuşak renkler", + "Приглушені кольори", + "Màu câm", + "低饱和度", + ], + "tc-standard-layout-style": [ + "Standard Layout Button Stil", + "Gaya tata letak tombol standar", + "Standard layout's button style", + "Estilo de botones de diseño estándar", + "Style standard des boutons", + "Layout dei tasti standard", + "標準レイアウト", + "표준 레이아웃의 버튼 스타일", + "Standardowy układ przycisków", + "Estilo de botão do layout padrão", + "Стандартный стиль кнопок", + "Varsayılan düğme düzeninin biçimi", + "Стандартний стиль кнопок", + "Màu của bố cục tiêu chuẩn", + "通用按钮样式", + ], + "text-size": [ + "Textgröße", + "Ukuran teks", + "Text size", + "Tamano del texto", + "Taille du texte", + "Dimensione del testo", + "文字サイズ", + "글자 크기", + "Rozmiar tekstu", + "Tamanho do texto", + "Размер текста", + "Metin boyutu", + "Розмір тексту", + "Cỡ chữ", + "文字大小", + ], + "top-center": [ + "Oben zentriert", + "Tengah atas", + "Top-center", + "Superior centrado", + "En haut au centre", + "In alto al centro", + "上", + "중앙 상단", + "Wyśrodkowany na górze", + "Superior centralizado", + "Сверху", + "Orta üst", + "Зверху по центру", + "Chính giữa phía trên", + "顶部居中", + ], + "top-left": [ + "Oben links", + "Kiri atas", + "Top-left", + "Superior izquierdo", + "Haut-gauche", + "In alto a sinistra", + "左上", + "좌측 상단", + "Lewy górny róg", + "Superior esquerdo", + "Левый верхний угол", + "Sol üst", + "Зверху ліворуч", + "Phía trên bên trái", + "左上角", + ], + "top-right": [ + "Oben rechts", + "Kanan atas", + "Top-right", + "Superior derecho", + "En haut à droite", + "In alto a destra", + "右上", + "우측 상단", + "Prawy górny róg", + "Superior direito", + "Справа", + "Sağ üst", + "Зверху праворуч", + "Phía trên bên phải", + "右上角", + ], + "touch-control-layout": [ + "Touch-Steuerungslayout", + "Tata letak kontrol sentuhan", + "Touch control layout", + "Diseño de control táctil", + , + "Controller Touch", + "タッチコントロールレイアウト", + , + "Układ sterowania dotykowego", + "Layout do controle por toque", + "Расположение сенсорных кнопок", + "Dokunmatik kontrol şeması", + "Розташування сенсорного керування", + "Bố cục điều khiển cảm ứng", + "触摸控制布局", + ], + "touch-controller": [ + "Touch-Controller", + "Kontrol sentuhan", + "Touch controller", + "Controles táctiles", + "Commandes tactiles", + "Controller Touch", + "タッチコントローラー", + "터치 컨트롤", + "Sterowanie dotykiem", + "Controle de toque", + "Сенсорные кнопки", + "Dokunmatik oyun kumandası", + "Сенсорне керування", + "Bộ điều khiển cảm ứng", + "虚拟摇杆", + ], + "transparent-background": [ + "Transparenter Hintergrund", + "Latar belakang transparan", + "Transparent background", + "Fondo transparente", + "Fond transparent", + "Sfondo trasparente", + "背景の透過", + "투명 배경", + "Przezroczyste tło", + "Fundo transparente", + "Прозрачный фон", + "Saydam arka plan", + "Прозоре тло", + "Trong suốt màu nền", + "透明背景", + ], + "ui": [ + "Benutzeroberfläche", + "Antarmuka pengguna", + "UI", + "Interfaz de usuario", + "Interface utilisateur", + "Interfaccia", + "UI", + "UI", + "Interfejs", + "Interface", + "Интерфейс", + "Kullanıcı arayüzü", + "Інтерфейс користувача", + "Giao diện", + "UI", + ], + "unknown": [ + "Unbekannt", + "Tidak diketahui", + "Unknown", + "Desconocido", + , + "Sconosciuto", + "不明", + "알 수 없음", + "Nieznane", + "Desconhecido", + "Неизвестный", + "Bilinmiyor", + "Невідомий", + "Không rõ", + "未知", + ], + "unlimited": [ + "Unbegrenzt", + "Tak terbatas", + "Unlimited", + "Ilimitado", + , + "Illimitato", + "無制限", + "제한없음", + "Bez ograniczeń", + "Ilimitado", + "Неограничено", + "Limitsiz", + "Необмежено", + "Không giới hạn", + "无限制", + ], + "unmuted": [ + "Ton an", + "Bunyikan", + "Unmuted", + "Activar sonido", + , + "Microfono attivato", + "ミュート解除", + "음소거 해제", + "Wyciszenie wyłączone", + "Sem Mudo", + "Вкл микрофон", + "Açık", + "Увімкнути звук", + "Đã mở âm", + "已取消静音", + ], + "use-mouse-absolute-position": [ + "Absolute Position der Maus verwenden", + "Gunakan posisi mouse mutlak", + "Use mouse's absolute position", + "Usar la posición absoluta del ratón", + , + , + "マウスの絶対座標を使用", + "마우스 절대위치 사용", + "Użyj pozycji bezwzględnej myszy", + "Usar posição absoluta do mouse", + "Использовать абсолютное положение мыши", + "Farenin mutlak pozisyonunu baz al", + "Використовувати абсолютне положення миші", + "Sử dụng vị trí tuyệt đối của chuột", + "使用鼠标的绝对位置", + ], + "user-agent-profile": [ + "User-Agent Profil", + "Profil User-Agent", + "User-Agent profile", + "Perfil del agente de usuario", + "Profil de l'agent utilisateur", + "User-Agent", + "ユーザーエージェントプロファイル", + "사용자 에이전트 프로파일", + "Profil User-Agent", + "Perfil do User-Agent", + "Профиль устройства", + "Kullanıcı aracısı profili", + "Профіль User-Agent", + "User-Agent", + "浏览器UA伪装", + ], + "vertical-sensitivity": [ + "Vertikale Empfindlichkeit", + "Sensitivitas vertikal", + "Vertical sensitivity", + "Sensibilidad Vertical", + , + "Sensibilità Verticale", + "上下方向の感度", + , + "Czułość pionowa", + "Sensibilidade vertical", + "Вертикальная чувствительность", + "Dikey hassasiyet", + "Вертикальна чутливість", + "Độ ngạy dọc", + "垂直灵敏度", + ], + "vibration-intensity": [ + "Vibrationsstärke", + "Intensitas getaran", + "Vibration intensity", + "Intensidad de la vibración", + , + , + "振動の強さ", + "진동 세기", + "Siła wibracji", + "Intensidade da vibração", + "Сила вибрации", + "Titreşim gücü", + "Інтенсивність вібрації", + "Cường độ rung", + "振动强度", + ], + "vibration-status": [ + "Vibration", + "Getaran", + "Vibration", + "Vibración", + , + , + "振動", + , + "Wibracje", + "Vibração", + "Вибрация", + "Titreşim", + "Вібрація", + "Rung", + "手柄震动", + ], + "video": [ + "Video", + "Video", + "Video", + "Video", + "Vidéo", + "Video", + "映像", + "비디오", + "Obraz", + "Vídeo", + "Видео", + "Görüntü", + "Відео", + "Hình ảnh", + "视频", + ], + "visual-quality": [ + "Bildqualität", + "Kualitas visual", + "Visual quality", + "Calidad visual", + "Qualité visuelle", + "Profilo codec preferito", + "画質", + "시각적 품질", + "Jakość grafiki", + "Qualidade visual", + "Качество видеопотока", + "Görüntü kalitesi", + "Візуальна якість", + "Chất lượng hình ảnh", + "画质", + ], + "visual-quality-high": [ + "Hoch", + "Tinggi", + "High", + "Alto", + "Élevée", + "Alta", + "高", + "높음", + "Wysoka", + "Alto", + "Высокое", + "Yüksek", + "Високий", + "Cao", + "高", + ], + "visual-quality-low": [ + "Niedrig", + "Rendah", + "Low", + "Bajo", + "Basse", + "Bassa", + "低", + "낮음", + "Niska", + "Baixo", + "Низкое", + "Düşük", + "Низький", + "Thấp", + "低", + ], + "visual-quality-normal": [ + "Mittel", + "Normal", + "Normal", + "Normal", + "Normal", + "Normale", + "中", + "보통", + "Normalna", + "Normal", + "Среднее", + "Normal", + "Нормальний", + "Thường", + "中", + ], + "volume": [ + "Lautstärke", + "Volume", + "Volume", + "Volumen", + "Volume", + "Volume", + "音量", + "음량", + "Głośność", + "Volume", + "Громкость", + "Ses düzeyi", + "Гучність", + "Âm lượng", + "音量", + ], + "wait-time-countdown": [ + "Countdown", + "Hitung mundur", + "Countdown", + "Cuenta Regresiva", + "Compte à rebours", + "Countdown", + "カウントダウン", + "카운트다운", + "Pozostały czas oczekiwania", + "Contagem regressiva", + "Время до запуска", + "Geri sayım", + "Зворотній відлік", + "Đếm ngược", + "倒计时", + ], + "wait-time-estimated": [ + "Geschätzte Endzeit", + "Perkiraan waktu", + "Estimated finish time", + "Tiempo estimado de finalización", + "Temps estimé avant la fin", + "Tempo residuo stimato", + "推定完了時間", + "예상 완료 시간", + "Szacowany czas zakończenia", + "Tempo estimado para a conclusão", + "Примерное время запуска", + "Tahminî bitiş süresi", + "Орієнтовний час завершення", + "Thời gian hoàn thành dự kiến", + "预计等待时间", + ], +} + +let LOCALE = Translations.getLocale(); +export const t = Translations.get; diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 0000000..1aa73d6 --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,45 @@ +// Get type of an array's element +type ArrayElement = 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; diff --git a/src/utils/css.ts b/src/utils/css.ts new file mode 100644 index 0000000..68c8777 --- /dev/null +++ b/src/utils/css.ts @@ -0,0 +1,1403 @@ +import { CE, Icon } from "./html"; +import { Preferences, getPref } from "../modules/preferences"; + + +export function addCss() { + let css = ` +:root { + --bx-title-font: Bahnschrift, Arial, Helvetica, sans-serif; + --bx-title-font-semibold: Bahnschrift Semibold, Arial, Helvetica, sans-serif; + --bx-normal-font: "Segoe UI", Arial, Helvetica, sans-serif; + --bx-monospaced-font: Consolas, "Courier New", Courier, monospace; + --bx-promptfont-font: promptfont; + + --bx-button-height: 36px; + + --bx-default-button-color: #2d3036; + --bx-default-button-hover-color: #515863; + --bx-default-button-disabled-color: #8e8e8e; + + --bx-primary-button-color: #008746; + --bx-primary-button-hover-color: #04b358; + --bx-primary-button-disabled-color: #448262; + + --bx-danger-button-color: #c10404; + --bx-danger-button-hover-color: #e61d1d; + --bx-danger-button-disabled-color: #a26c6c; + + --bx-toast-z-index: 9999; + --bx-reload-button-z-index: 9200; + --bx-dialog-z-index: 9101; + --bx-dialog-overlay-z-index: 9100; + --bx-remote-play-popup-z-index: 9090; + --bx-stats-bar-z-index: 9001; + --bx-stream-settings-z-index: 9000; + --bx-mkb-pointer-lock-msg-z-index: 8999; + --bx-screenshot-z-index: 8888; + --bx-touch-controller-bar-z-index: 5555; + --bx-wait-time-box-z-index: 100; +} + +@font-face { + font-family: 'promptfont'; + src: url('https://redphx.github.io/better-xcloud/fonts/promptfont.otf'); +} + +/* Fix Stream menu buttons not hiding */ +div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module__hiddenContainer]) { + opacity: 0; + pointer-events: none !important; + position: absolute; + top: -9999px; + left: -9999px; +} + +/* Remove the "Cloud Gaming" text in header when the screen is too small */ +@media screen and (max-width: 600px) { + header a[href="/play"] { + display: none; + } +} + +a.bx-button { + display: inline-block; +} + +.bx-button { + background-color: var(--bx-default-button-color); + user-select: none; + -webkit-user-select: none; + color: #fff; + font-family: var(--bx-title-font-semibold); + font-size: 14px; + border: none; + font-weight: 400; + height: var(--bx-button-height); + border-radius: 4px; + padding: 0 8px; + text-transform: uppercase; + cursor: pointer; + overflow: hidden; +} + +.bx-button:focus { + outline: none !important; +} + +.bx-button:hover, .bx-button.bx-focusable:focus { + background-color: var(--bx-default-button-hover-color); +} + +.bx-button:disabled { + cursor: default; + background-color: var(--bx-default-button-disabled-color); +} + +.bx-button.bx-ghost { + background-color: transparent; +} + +.bx-button.bx-ghost:hover, .bx-button.bx-ghost.bx-focusable:focus { + background-color: var(--bx-default-button-hover-color); +} + +.bx-button.bx-primary { + background-color: var(--bx-primary-button-color); +} + +.bx-button.bx-primary:hover, .bx-button.bx-primary.bx-focusable:focus { + background-color: var(--bx-primary-button-hover-color); +} + +.bx-button.bx-primary:disabled { + background-color: var(--bx-primary-button-disabled-color); +} + +.bx-button.bx-danger { + background-color: var(--bx-danger-button-color); +} + +.bx-button.bx-danger:hover, .bx-button.bx-danger.bx-focusable:focus { + background-color: var(--bx-danger-button-hover-color); +} + +.bx-button.bx-danger:disabled { + background-color: var(--bx-danger-button-disabled-color); +} + +.bx-button svg { + display: inline-block; + width: 16px; + height: var(--bx-button-height); +} + +.bx-button svg:not(:only-child) { + margin-right: 4px; +} + +.bx-button span { + display: inline-block; + height: calc(var(--bx-button-height) - 2px); + line-height: var(--bx-button-height); + vertical-align: middle; + color: #fff; + overflow: hidden; + white-space: nowrap; +} + +a.bx-button.bx-full-width { + text-align: center; +} + +.bx-header-remote-play-button { + height: auto; + margin-right: 8px !important; +} + +.bx-header-remote-play-button svg { + width: 24px; + height: 46px; +} + +.bx-header-settings-button { + line-height: 30px; + font-size: 14px; + text-transform: uppercase; +} + +.bx-header-settings-button[data-update-available]::before { + content: '🌟' !important; + line-height: var(--bx-button-height); + display: inline-block; + margin-left: 4px; +} + +.bx-button.bx-focusable, .bx-header-settings-button { + position: relative; +} + +.bx-button.bx-focusable::after { + border: 2px solid transparent; + border-radius: 4px; +} + +.bx-button.bx-focusable:focus::after { + content: ''; + border-color: white; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.bx-settings-reload-button-wrapper { + z-index: var(--bx-reload-button-z-index); + position: fixed; + bottom: 0; + left: 0; + right: 0; + text-align: center; + background: #000000cf; + padding: 10px; +} + +.bx-settings-reload-button-wrapper button { + max-width: 450px; + margin: 0 !important; +} + +.bx-settings-container { + background-color: #151515; + user-select: none; + -webkit-user-select: none; + color: #fff; + font-family: var(--bx-normal-font); +} + +.bx-full-width { + width: 100% !important; +} + +.bx-full-height { + height: 100% !important; +} + +.bx-no-scroll { + overflow: hidden !important; +} + +.bx-gone { + display: none !important; +} + +.bx-offscreen { + position: absolute !important; + top: -9999px !important; + left: -9999px !important; + visibility: hidden !important; +} + +.bx-hidden { + visibility: hidden !important; +} + +.bx-no-margin { + margin: 0 !important; +} + +.bx-no-padding { + padding: 0 !important; +} + +.bx-settings-wrapper { + width: 450px; + margin: auto; + padding: 12px 6px; +} + +@media screen and (max-width: 450px) { + .bx-settings-wrapper { + width: 100%; + } +} + +.bx-settings-wrapper *:focus { + outline: none !important; +} + +.bx-settings-wrapper .bx-settings-title-wrapper { + display: flex; + margin-bottom: 10px; + align-items: center; +} + +.bx-settings-wrapper a.bx-settings-title { + font-family: var(--bx-title-font); + font-size: 1.4rem; + text-decoration: none; + font-weight: bold; + display: block; + color: #5dc21e; + flex: 1; +} + +.bx-settings-group-label { + font-weight: bold; + display: block; + font-size: 1.1rem; +} + +@media (hover: hover) { + .bx-settings-wrapper a.bx-settings-title:hover { + color: #83f73a; + } +} + +.bx-settings-wrapper a.bx-settings-title:focus { + color: #83f73a; +} + +.bx-settings-wrapper a.bx-settings-update { + display: block; + color: #ff834b; + text-decoration: none; + margin-bottom: px; + text-align: center; + background: #222; + border-radius: 4px; + padding: 4px; +} + +@media (hover: hover) { + .bx-settings-wrapper a.bx-settings-update:hover { + color: #ff9869; + text-decoration: underline; + } +} + +.bx-settings-wrapper a.bx-settings-update:focus { + color: #ff9869; + text-decoration: underline; +} + +.bx-settings-row { + display: flex; + margin-bottom: 8px; + padding: 2px 4px; +} + +.bx-settings-row label { + flex: 1; + align-self: center; + margin-bottom: 0; + padding-left: 10px; +} + +.bx-settings-group-label b, .bx-settings-row label b { + display: block; + font-size: 12px; + font-style: italic; + font-weight: normal; + color: #828282; +} + +.bx-settings-group-label b { + margin-bottom: 8px; +} + +@media not (hover: hover) { + .bx-settings-row:focus-within { + background-color: #242424; + } +} + +.bx-settings-row input { + align-self: center; + accent-color: var(--bx-primary-button-color); +} + +.bx-settings-row select:disabled { + -webkit-appearance: none; + background: transparent; + text-align-last: right; + border: none; + color: #fff; +} + +.bx-settings-wrapper .bx-button.bx-primary { + margin-top: 8px; +} + +.bx-settings-app-version { + margin-top: 10px; + text-align: center; + color: #747474; + font-size: 12px; +} + +.bx-donation-link { + display: block; + text-align: center; + text-decoration: none; + height: 20px; + line-height: 20px; + font-size: 14px; + margin-top: 10px; + color: #5dc21e; +} + +.bx-donation-link:hover { + color: #6dd72b; +} + +.bx-settings-custom-user-agent { + display: block; + width: 100%; +} + +div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] { + overflow: visible; +} + +.bx-badges { + position: absolute; + margin-left: 0px; + user-select: none; + -webkit-user-select: none; +} + +.bx-badge { + border: none; + display: inline-block; + line-height: 24px; + color: #fff; + font-family: var(--bx-title-font-semibold); + font-size: 14px; + font-weight: 400; + margin: 0 8px 8px 0; + box-shadow: 0px 0px 6px #000; + border-radius: 4px; +} + +.bx-badge-name { + background-color: #2d3036; + display: inline-block; + padding: 2px 8px; + border-radius: 4px 0 0 4px; + text-transform: uppercase; +} + +.bx-badge-value { + background-color: grey; + display: inline-block; + padding: 2px 8px; + border-radius: 0 4px 4px 0; +} + +.bx-badge-battery[data-charging=true] span:first-of-type::after { + content: ' ⚡️'; +} + +.bx-screenshot-button { + display: none; + opacity: 0; + position: fixed; + bottom: 0; + box-sizing: border-box; + width: 60px; + height: 90px; + padding: 16px 16px 46px 16px; + background-size: cover; + background-repeat: no-repeat; + background-origin: content-box; + filter: drop-shadow(0 0 2px #000000B0); + transition: opacity 0.1s ease-in-out 0s, padding 0.1s ease-in 0s; + z-index: var(--bx-screenshot-z-index); + + /* Credit: https://phosphoricons.com */ + background-image: url(${Icon.SCREENSHOT_B64}); +} + +.bx-screenshot-button[data-showing=true] { + opacity: 0.9; +} + +.bx-screenshot-button[data-capturing=true] { + padding: 8px 8px 38px 8px; +} + +.bx-screenshot-canvas { + display: none; +} + +.bx-stats-bar { + display: block; + user-select: none; + -webkit-user-select: none; + position: fixed; + top: 0; + background-color: #000; + color: #fff; + font-family: var(--bx-monospaced-font); + font-size: 0.9rem; + padding-left: 8px; + z-index: var(--bx-stats-bar-z-index); + text-wrap: nowrap; +} + +.bx-stats-bar > div { + display: none; + margin-right: 8px; + border-right: 1px solid #fff; + padding-right: 8px; +} + +.bx-stats-bar[data-stats*="[fps]"] > .bx-stat-fps, +.bx-stats-bar[data-stats*="[ping]"] > .bx-stat-ping, +.bx-stats-bar[data-stats*="[btr]"] > .bx-stat-btr, +.bx-stats-bar[data-stats*="[dt]"] > .bx-stat-dt, +.bx-stats-bar[data-stats*="[pl]"] > .bx-stat-pl, +.bx-stats-bar[data-stats*="[fl]"] > .bx-stat-fl { + display: inline-block; +} + +.bx-stats-bar[data-stats$="[fps]"] > .bx-stat-fps, +.bx-stats-bar[data-stats$="[ping]"] > .bx-stat-ping, +.bx-stats-bar[data-stats$="[btr]"] > .bx-stat-btr, +.bx-stats-bar[data-stats$="[dt]"] > .bx-stat-dt, +.bx-stats-bar[data-stats$="[pl]"] > .bx-stat-pl, +.bx-stats-bar[data-stats$="[fl]"] > .bx-stat-fl { + margin-right: 0; + border-right: none; +} + +.bx-stats-bar::before { + display: none; + content: '👀'; + vertical-align: middle; + margin-right: 8px; +} + +.bx-stats-bar[data-display=glancing]::before { + display: inline-block; +} + +.bx-stats-bar[data-position=top-left] { + left: 0; + border-radius: 0 0 4px 0; +} + +.bx-stats-bar[data-position=top-right] { + right: 0; + border-radius: 0 0 0 4px; +} + +.bx-stats-bar[data-position=top-center] { + transform: translate(-50%, 0); + left: 50%; + border-radius: 0 0 4px 4px; +} + +.bx-stats-bar[data-transparent=true] { + background: none; + filter: drop-shadow(1px 0 0 #000000f0) drop-shadow(-1px 0 0 #000000f0) drop-shadow(0 1px 0 #000000f0) drop-shadow(0 -1px 0 #000000f0); +} + +.bx-stats-bar label { + margin: 0 8px 0 0; + font-family: var(--bx-title-font); + font-size: inherit; + font-weight: bold; + vertical-align: middle; + cursor: help; +} + +.bx-stats-bar span { + min-width: 60px; + display: inline-block; + text-align: right; + vertical-align: middle; +} + +.bx-stats-bar span[data-grade=good] { + color: #6bffff; +} + +.bx-stats-bar span[data-grade=ok] { + color: #fff16b; +} + +.bx-stats-bar span[data-grade=bad] { + color: #ff5f5f; +} + +.bx-stats-bar span:first-of-type { + min-width: 22px; +} + +.bx-dialog-overlay { + position: fixed; + inset: 0; + z-index: var(--bx-dialog-overlay-z-index); + background: black; + opacity: 50%; +} + +.bx-dialog { + display: flex; + flex-flow: column; + max-height: 90vh; + position: fixed; + top: 50%; + left: 50%; + margin-right: -50%; + transform: translate(-50%, -50%); + min-width: 420px; + padding: 20px; + border-radius: 8px; + z-index: var(--bx-dialog-z-index); + background: #1a1b1e; + color: #fff; + font-weight: 400; + font-size: 16px; + font-family: var(--bx-normal-font); + box-shadow: 0 0 6px #000; + user-select: none; + -webkit-user-select: none; +} + +.bx-dialog *:focus { + outline: none !important; +} + +@media screen and (max-width: 450px) { + .bx-dialog { + min-width: 100%; + } +} + +.bx-dialog h2 { + display: flex; + margin-bottom: 12px; +} + +.bx-dialog h2 b { + flex: 1; + color: #fff; + display: block; + font-family: var(--bx-title-font); + font-size: 26px; + font-weight: 400; + line-height: var(--bx-button-height); +} + +.bx-dialog.bx-binding-dialog h2 b { + font-family: var(--bx-promptfont-font) !important; +} + +.bx-dialog > div { + overflow: auto; + padding: 2px 0; +} + +.bx-dialog > button { + padding: 8px 32px; + margin: 10px auto 0; + border: none; + border-radius: 4px; + display: block; + background-color: #2d3036; + text-align: center; + color: white; + text-transform: uppercase; + font-family: var(--bx-title-font); + font-weight: 400; + line-height: 18px; + font-size: 14px; +} + +@media (hover: hover) { + .bx-dialog > button:hover { + background-color: #515863; + } +} + +.bx-dialog > button:focus { + background-color: #515863; +} + +.bx-stats-settings-dialog > div > div { + display: flex; + margin-bottom: 8px; + padding: 2px 4px; +} + +.bx-stats-settings-dialog label { + flex: 1; + margin-bottom: 0; + align-self: center; +} + +.bx-quick-settings-bar { + display: flex; + position: fixed; + z-index: var(--bx-stream-settings-z-index); + opacity: 0.98; + user-select: none; + -webkit-user-select: none; +} + +.bx-quick-settings-tabs { + position: fixed; + top: 0; + right: 420px; + display: flex; + flex-direction: column; + border-radius: 0 0 0 8px; + box-shadow: 0px 0px 6px #000; + overflow: clip; +} + +.bx-quick-settings-tabs svg { + width: 32px; + height: 32px; + padding: 10px; + box-sizing: content-box; + background: #131313; + cursor: pointer; + border-left: 4px solid #1e1e1e; +} + +.bx-quick-settings-tabs svg.bx-active { + background: #222; + border-color: #008746; +} + +.bx-quick-settings-tabs svg:not(.bx-active):hover { + background: #2f2f2f; + border-color: #484848; +} + +.bx-quick-settings-tab-contents { + flex-direction: column; + position: fixed; + right: 0; + top: 0; + bottom: 0; + padding: 14px 14px 0; + width: 420px; + background: #1a1b1e; + color: #fff; + font-weight: 400; + font-size: 16px; + font-family: var(--bx-title-font); + text-align: center; + box-shadow: 0px 0px 6px #000; + overflow: overlay; +} + +.bx-quick-settings-tab-contents > div[data-group=mkb] { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.bx-quick-settings-tab-contents *:focus { + outline: none !important; +} + +.bx-quick-settings-row { + display: flex; + border-bottom: 1px solid #40404080; + margin-bottom: 16px; + padding-bottom: 16px; +} + +.bx-quick-settings-row label { + font-size: 16px; + display: block; + text-align: left; + flex: 1; + align-self: center; + margin-bottom: 0 !important; +} + +.bx-quick-settings-row input { + accent-color: var(--bx-primary-button-color); +} + +.bx-quick-settings-tab-contents h2 { + margin-bottom: 8px; + display: flex; + align-item: center; +} + +.bx-quick-settings-tab-contents h2 span { + display: inline-block; + font-size: 24px; + font-weight: bold; + text-transform: uppercase; + text-align: left; + flex: 1; + height: var(--bx-button-height); + line-height: calc(var(--bx-button-height) + 4px); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.bx-quick-settings-tab-contents input[type="range"] { + display: block; + margin: 12px auto 2px; + width: 180px; + color: #959595 !important; +} + +.bx-quick-settings-bar-note { + display: block; + text-align: center; + font-size: 12px; + font-weight: lighter; + font-style: italic; + padding-top: 16px; +} + +.bx-toast { + user-select: none; + -webkit-user-select: none; + position: fixed; + left: 50%; + top: 24px; + transform: translate(-50%, 0); + background: #000000; + border-radius: 16px; + color: white; + z-index: var(--bx-toast-z-index); + font-family: var(--bx-normal-font); + border: 2px solid #fff; + display: flex; + align-items: center; + opacity: 0; + overflow: clip; + transition: opacity 0.2s ease-in; +} + +.bx-toast.bx-show { + opacity: 0.85; +} + +.bx-toast.bx-hide { + opacity: 0; +} + +.bx-toast-msg { + font-size: 14px; + display: inline-block; + padding: 12px 16px; + white-space: pre; +} + +.bx-toast-status { + font-weight: bold; + font-size: 14px; + text-transform: uppercase; + display: inline-block; + background: #515863; + padding: 12px 16px; + color: #fff; + white-space: pre; +} + +.bx-number-stepper span { + display: inline-block; + width: 40px; + font-family: var(--bx-monospaced-font); + font-size: 14px; +} + +.bx-number-stepper button { + border: none; + width: 24px; + height: 24px; + margin: 0 4px; + line-height: 24px; + background-color: var(--bx-default-button-color); + color: #fff; + border-radius: 4px; + font-weight: bold; + font-size: 14px; + font-family: var(--bx-monospaced-font); + color: #fff; +} + +@media (hover: hover) { + .bx-number-stepper button:hover { + background-color: var(--bx-default-button-hover-color); + } +} + +.bx-number-stepper button:active { + background-color: var(--bx-default-button-hover-color); +} + +.bx-number-stepper input[type=range]:disabled, .bx-number-stepper button:disabled { + display: none; +} + +.bx-number-stepper button:disabled + span { + font-family: var(--bx-title-font); +} + +.bx-mkb-settings { + display: flex; + flex-direction: column; + flex: 1; + padding-bottom: 10px; + overflow: hidden; +} + +.bx-mkb-settings select:disabled { + -webkit-appearance: none; + background: transparent; + text-align-last: right; + text-align: right; + border: none; + color: #fff; +} + +.bx-quick-settings-row select:disabled { + -webkit-appearance: none; + background: transparent; + text-align-last: right; + border: none; +} + +.bx-mkb-pointer-lock-msg { + display: flex; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + position: fixed; + left: 50%; + top: 50%; + transform: translateX(-50%) translateY(-50%); + margin: auto; + background: #000000e5; + z-index: var(--bx-mkb-pointer-lock-msg-z-index); + color: #fff; + text-align: center; + font-weight: 400; + font-family: "Segoe UI", Arial, Helvetica, sans-serif; + font-size: 1.3rem; + padding: 12px; + border-radius: 8px; + align-items: center; + box-shadow: 0 0 6px #000; +} + +.bx-mkb-pointer-lock-msg:hover { + background: #151515; +} + +.bx-mkb-pointer-lock-msg button { + margin-right: 12px; + height: 60px; +} + +.bx-mkb-pointer-lock-msg svg { + width: 32px; +} + +.bx-mkb-pointer-lock-msg div { + display: flex; + flex-direction: column; + text-align: left; +} + +.bx-mkb-pointer-lock-msg p { + margin: 0; +} + +.bx-mkb-pointer-lock-msg p:first-child { + font-size: 22px; + margin-bottom: 8px; +} + +.bx-mkb-pointer-lock-msg p:last-child { + font-size: 14px; + font-style: italic; +} + +.bx-mkb-preset-tools { + display: flex; + margin-bottom: 12px; +} + +.bx-mkb-preset-tools select { + flex: 1; +} + +.bx-mkb-preset-tools button { + margin-left: 6px; +} + +.bx-mkb-settings-rows { + flex: 1; + overflow: scroll; +} + +.bx-mkb-key-row { + display: flex; + margin-bottom: 10px; + align-items: center; +} + +.bx-mkb-key-row label { + margin-bottom: 0; + font-family: var(--bx-promptfont-font); + font-size: 26px; + text-align: center; + width: 26px; + height: 32px; + line-height: 32px; +} + +.bx-mkb-key-row button { + flex: 1; + height: 32px; + line-height: 32px; + margin: 0 0 0 10px; + background: transparent; + border: none; + color: white; + border-radius: 0; + border-left: 1px solid #373737; +} + +.bx-mkb-key-row button:hover { + background: transparent; + cursor: default; +} + +.bx-mkb-settings.bx-editing .bx-mkb-key-row button { + background: #393939; + border-radius: 4px; + border: none; +} + +.bx-mkb-settings.bx-editing .bx-mkb-key-row button:hover { + background: #333; + cursor: pointer; +} + +.bx-mkb-action-buttons > div { + text-align: right; + display: none; +} + +.bx-mkb-action-buttons button { + margin-left: 8px; +} + +.bx-mkb-settings:not(.bx-editing) .bx-mkb-action-buttons > div:first-child { + display: block; +} + +.bx-mkb-settings.bx-editing .bx-mkb-action-buttons > div:last-child { + display: block; +} + +.bx-mkb-note { + display: block; + margin: 16px 0 10px; + font-size: 12px; +} + +.bx-mkb-note:first-of-type { + margin-top: 0; +} + + +.bx-stream-menu-button-on { + fill: #000 !important; + background-color: #2d2d2d !important; + color: #000 !important; +} + +#bx-touch-controller-bar { + display: none; + opacity: 0; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 6vh; + z-index: var(--bx-touch-controller-bar-z-index); +} + +#bx-touch-controller-bar[data-showing=true] { + display: block; +} + +.bx-wait-time-box { + position: fixed; + top: 0; + right: 0; + background-color: #000000cc; + color: #fff; + z-index: var(--bx-wait-time-box-z-index); + padding: 12px; + border-radius: 0 0 0 8px; +} + +.bx-wait-time-box label { + display: block; + text-transform: uppercase; + text-align: right; + font-size: 12px; + font-weight: bold; + margin: 0; +} + +.bx-wait-time-box span { + display: block; + font-family: var(--bx-monospaced-font); + text-align: right; + font-size: 16px; + margin-bottom: 10px; +} + +.bx-wait-time-box span:last-of-type { + margin-bottom: 0; +} + +/* REMOTE PLAY */ + +.bx-container { + width: 480px; + margin: 0 auto; +} + +#bxUi { + margin-top: 14px; +} + +.bx-remote-play-popup { + width: 100%; + max-width: 1920px; + margin: auto; + position: relative; + height: 0.1px; + overflow: visible; + z-index: var(--bx-remote-play-popup-z-index); +} + +.bx-remote-play-container { + position: absolute; + right: 10px; + top: 0; + background: #1a1b1e; + border-radius: 10px; + width: 420px; + max-width: calc(100vw - 20px); + margin: 0 0 0 auto; + padding: 20px; + box-shadow: #00000080 0px 0px 12px 0px; +} + +@media (min-width:480px) and (min-height:calc(480px + 1px)) { + .bx-remote-play-container { + right: calc(env(safe-area-inset-right, 0px) + 32px) + } +} +@media (min-width:768px) and (min-height:calc(480px + 1px)) { + .bx-remote-play-container { + right: calc(env(safe-area-inset-right, 0px) + 48px) + } +} +@media (min-width:1920px) and (min-height:calc(480px + 1px)) { + .bx-remote-play-container { + right: calc(env(safe-area-inset-right, 0px) + 80px) + } +} + +.bx-remote-play-container > .bx-button { + display: table; + margin: 0 0 0 auto; +} + +.bx-remote-play-settings { + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #2d2d2d; +} + +.bx-remote-play-settings > div { + display: flex; +} + +.bx-remote-play-settings label { + flex: 1; +} + +.bx-remote-play-settings label p { + margin: 4px 0 0; + padding: 0; + color: #888; + font-size: 12px; +} + +.bx-remote-play-settings span { + font-weight: bold; + font-size: 18px; + display: block; + margin-bottom: 8px; + text-align: center; +} + +.bx-remote-play-resolution { + display: block; +} + +.bx-remote-play-resolution input[type="radio"] { + accent-color: var(--bx-primary-button-color); + margin-right: 6px; +} + +.bx-remote-play-resolution input[type="radio"]:focus { + accent-color: var(--bx-primary-button-hover-color); +} + +.bx-remote-play-device-wrapper { + display: flex; + margin-bottom: 12px; +} + +.bx-remote-play-device-wrapper:last-child { + margin-bottom: 2px; +} + +.bx-remote-play-device-info { + flex: 1; + padding: 4px 0; +} + +.bx-remote-play-device-name { + font-size: 20px; + font-weight: bold; + display: inline-block; + vertical-align: middle; +} + +.bx-remote-play-console-type { + font-size: 12px; + background: #004c87; + color: #fff; + display: inline-block; + border-radius: 14px; + padding: 2px 10px; + margin-left: 8px; + vertical-align: middle; +} + +.bx-remote-play-power-state { + color: #888; + font-size: 14px; +} + +.bx-remote-play-connect-button { + min-height: 100%; + margin: 4px 0; +} + +/* ----------- */ + +/* Hide UI elements */ +#headerArea, #uhfSkipToMain, .uhf-footer { + display: none; +} + +div[class*=NotFocusedDialog] { + position: absolute !important; + top: -9999px !important; + left: -9999px !important; + width: 0px !important; + height: 0px !important; +} + +#game-stream video:not([src]) { + visibility: hidden; +} +`; + + // Hide "Play with friends" section + if (getPref(Preferences.BLOCK_SOCIAL_FEATURES)) { + css += ` +div[class^=HomePage-module__bottomSpacing]:has(button[class*=SocialEmptyCard]), +button[class*=SocialEmptyCard] { + display: none; +} +`; + } + + // Reduce animations + if (getPref(Preferences.REDUCE_ANIMATIONS)) { + css += ` +div[class*=GameCard-module__gameTitleInnerWrapper], +div[class*=GameCard-module__card], +div[class*=ScrollArrows-module] { + transition: none !important; +} +`; + } + + // Hide the top-left dots icon while playing + if (getPref(Preferences.HIDE_DOTS_ICON)) { + css += ` +div[class*=Grip-module__container] { + visibility: hidden; +} + +@media (hover: hover) { + button[class*=GripHandle-module__container]:hover div[class*=Grip-module__container] { + visibility: visible; + } +} + +button[class*=GripHandle-module__container][aria-expanded=true] div[class*=Grip-module__container] { + visibility: visible; +} + +button[class*=GripHandle-module__container][aria-expanded=false] { + background-color: transparent !important; +} + +div[class*=StreamHUD-module__buttonsContainer] { + padding: 0px !important; +} +`; + } + + // Simplify Stream's menu + css += ` +div[class*=StreamMenu-module__menu] { + min-width: 100vw !important; +} +`; + if (getPref(Preferences.STREAM_SIMPLIFY_MENU)) { + css += ` +div[class*=Menu-module__scrollable] { + --bxStreamMenuItemSize: 80px; + --streamMenuItemSize: calc(var(--bxStreamMenuItemSize) + 40px) !important; +} + +.bx-badges { + top: calc(var(--streamMenuItemSize) - 20px); +} + +body[data-media-type=tv] .bx-badges { + top: calc(var(--streamMenuItemSize) - 10px) !important; +} + +button[class*=MenuItem-module__container] { + min-width: auto !important; + min-height: auto !important; + width: var(--bxStreamMenuItemSize) !important; + height: var(--bxStreamMenuItemSize) !important; +} + +div[class*=MenuItem-module__label] { + display: none !important; +} + +svg[class*=MenuItem-module__icon] { + width: 36px; + height: 100% !important; + padding: 0 !important; + margin: 0 !important; +} +`; + } else { + css += ` +body[data-media-type=tv] .bx-badges { + top: calc(var(--streamMenuItemSize) + 30px); +} + +body:not([data-media-type=tv]) .bx-badges { + top: calc(var(--streamMenuItemSize) + 20px); +} + +body:not([data-media-type=tv]) button[class*=MenuItem-module__container] { + min-width: auto !important; + width: 100px !important; +} + +body:not([data-media-type=tv]) button[class*=MenuItem-module__container]:nth-child(n+2) { + margin-left: 10px !important; +} + +body:not([data-media-type=tv]) div[class*=MenuItem-module__label] { + margin-left: 8px !important; + margin-right: 8px !important; +} +`; + } + + // Hide scrollbar + if (getPref(Preferences.UI_SCROLLBAR_HIDE)) { + css += ` +html { + scrollbar-width: none; +} + +body::-webkit-scrollbar { + display: none; +} +`; + } + + const $style = CE('style', {}, css); + document.documentElement.appendChild($style); +} diff --git a/src/utils/html.ts b/src/utils/html.ts new file mode 100644 index 0000000..c115f65 --- /dev/null +++ b/src/utils/html.ts @@ -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(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: '', + STREAM_STATS: '', + CONTROLLER: '', + DISPLAY: '', + MOUSE: '', + MOUSE_SETTINGS: '', + NEW: '', + COPY: '', + TRASH: '', + CURSOR_TEXT: '', + QUESTION: '', + + REMOTE_PLAY: '', + + HAND_TAP: '', + + 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 = (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; diff --git a/src/utils/local-db.ts b/src/utils/local-db.ts new file mode 100644 index 0000000..9c4889c --- /dev/null +++ b/src/utils/local-db.ts @@ -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((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 { + 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 { + // @ts-ignore + return this.#call(table.add, ...arguments); + } + + #put(table: IDBObjectStore, data: any): Promise { + // @ts-ignore + return this.#call(table.put, ...arguments); + } + + #delete(table: IDBObjectStore, data: any): Promise { + // @ts-ignore + return this.#call(table.delete, ...arguments); + } + + #get(table: IDBObjectStore, id: string): Promise { + // @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(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}); + }); + }); + }); + } +} diff --git a/src/utils/titles-info.ts b/src/utils/titles-info.ts new file mode 100644 index 0000000..8cdbe72 --- /dev/null +++ b/src/utils/titles-info.ts @@ -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); + } + } + } + }); + } +} diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 0000000..0100474 --- /dev/null +++ b/src/utils/toast.ts @@ -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); + } +} diff --git a/src/utils/user-agent.ts b/src/utils/user-agent.ts new file mode 100644 index 0000000..3081edb --- /dev/null +++ b/src/utils/user-agent.ts @@ -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; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2095cdd --- /dev/null +++ b/tsconfig.json @@ -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 + } +}