From eb8490a79863199bae46682ad718d3b49fdf07d6 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sat, 8 Jun 2024 17:04:49 +0700 Subject: [PATCH] Add native MKB support for Android app --- src/assets/css/button.styl | 4 + src/assets/css/global-settings.styl | 1 - src/assets/css/mkb.styl | 48 ++-- src/assets/svg/native-mkb.svg | 10 + src/assets/svg/virtual-controller.svg | 11 + src/index.ts | 32 ++- src/modules/controller-shortcut.ts | 4 +- src/modules/mkb/base-mkb-handler.ts | 23 ++ src/modules/mkb/mkb-handler.ts | 392 ++++++++++++++++---------- src/modules/mkb/mkb-preset.ts | 8 +- src/modules/mkb/mkb-remapper.ts | 8 +- src/modules/mkb/native-mkb-handler.ts | 319 +++++++++++++++++++++ src/modules/mkb/pointer-client.ts | 44 +-- src/modules/patcher.ts | 92 +++++- src/modules/stream/stream-badges.ts | 2 +- src/modules/stream/stream-ui.ts | 44 +-- src/modules/ui/global-settings.ts | 4 +- src/modules/ui/guide-menu.ts | 80 ++++++ src/modules/ui/ui.ts | 54 +++- src/types/index.d.ts | 14 +- src/utils/bx-event.ts | 9 +- src/utils/bx-exposed.ts | 11 +- src/utils/bx-icon.ts | 8 +- src/utils/css.ts | 9 - src/utils/gamepad.ts | 4 +- src/utils/html.ts | 3 +- src/utils/monkey-patches.ts | 41 +++ src/utils/network.ts | 10 +- src/utils/preferences.ts | 70 ++++- src/utils/translation.ts | 42 +-- 30 files changed, 1054 insertions(+), 347 deletions(-) create mode 100644 src/assets/svg/native-mkb.svg create mode 100644 src/assets/svg/virtual-controller.svg create mode 100644 src/modules/mkb/base-mkb-handler.ts create mode 100644 src/modules/mkb/native-mkb-handler.ts create mode 100644 src/modules/ui/guide-menu.ts diff --git a/src/assets/css/button.styl b/src/assets/css/button.styl index e22de2c..260cc5a 100644 --- a/src/assets/css/button.styl +++ b/src/assets/css/button.styl @@ -59,6 +59,10 @@ } } + &.bx-tall { + height: calc(var(--bx-button-height) * 1.5) !important; + } + svg { display: inline-block; width: 16px; diff --git a/src/assets/css/global-settings.styl b/src/assets/css/global-settings.styl index 032b2da..eb6f2c9 100644 --- a/src/assets/css/global-settings.styl +++ b/src/assets/css/global-settings.styl @@ -1,6 +1,5 @@ .bx-settings-reload-button { margin-top: 10px; - height: calc(var(--bx-button-height) * 1.5); } .bx-settings-container { diff --git a/src/assets/css/mkb.styl b/src/assets/css/mkb.styl index e031c82..7c9b16a 100644 --- a/src/assets/css/mkb.styl +++ b/src/assets/css/mkb.styl @@ -16,7 +16,6 @@ } .bx-mkb-pointer-lock-msg { - cursor: pointer; user-select: none; -webkit-user-select: none; position: fixed; @@ -24,7 +23,7 @@ top: 50%; transform: translateX(-50%) translateY(-50%); margin: auto; - background: #000000b3; + background: #151515; z-index: var(--bx-mkb-pointer-lock-msg-z-index); color: #fff; text-align: center; @@ -35,9 +34,11 @@ border-radius: 8px; align-items: center; box-shadow: 0 0 6px #000; + min-width: 220px; + opacity: 0.9; &:hover { - background: #151515; + opacity: 1; } > div:first-of-type { @@ -51,33 +52,46 @@ &:first-child { font-size: 22px; - margin-bottom: 8px; + margin-bottom: 4px; + font-weight: bold; } &:last-child { - font-size: 14px; + font-size: 12px; font-style: italic; } } > div:last-of-type { - display: flex; - flex-flow: row; margin-top: 10px; - button { - flex: 1; - - &:first-of-type { - margin-right: 5px; - } - - &:last-of-type { - margin-left: 5px; + &[data-type='native'] { + button { + &:first-of-type { + margin-bottom: 8px; + } } } - button + &[data-type='virtual'] { + div { + display: flex; + flex-flow: row; + margin-top: 8px; + + button { + flex: 1; + + &:first-of-type { + margin-right: 5px; + } + + &:last-of-type { + margin-left: 5px; + } + } + } + } } } diff --git a/src/assets/svg/native-mkb.svg b/src/assets/svg/native-mkb.svg new file mode 100644 index 0000000..1fb6bd1 --- /dev/null +++ b/src/assets/svg/native-mkb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/svg/virtual-controller.svg b/src/assets/svg/virtual-controller.svg new file mode 100644 index 0000000..13d6446 --- /dev/null +++ b/src/assets/svg/virtual-controller.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/index.ts b/src/index.ts index 01486ed..47ad83e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import "@utils/global"; -import { BxEvent, XcloudGuideWhere } from "@utils/bx-event"; +import { BxEvent } from "@utils/bx-event"; import { BX_FLAGS } from "@utils/bx-flags"; import { BxExposed } from "@utils/bx-exposed"; import { t } from "@utils/translation"; import { interceptHttpRequests } from "@utils/network"; import { CE } from "@utils/html"; import { showGamepadToast } from "@utils/gamepad"; -import { MkbHandler } from "@modules/mkb/mkb-handler"; +import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler"; import { StreamBadges } from "@modules/stream/stream-badges"; import { StreamStats } from "@modules/stream/stream-stats"; import { addCss } from "@utils/css"; @@ -23,12 +23,14 @@ import { RemotePlay } from "@modules/remote-play"; import { onHistoryChanged, patchHistoryMethod } from "@utils/history"; import { VibrationManager } from "@modules/vibration-manager"; import { overridePreloadState } from "@utils/preload-state"; -import { patchAudioContext, patchCanvasContext, patchMeControl, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches"; +import { patchAudioContext, patchCanvasContext, patchMeControl, patchPointerLockApi, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches"; import { AppInterface, STATES } from "@utils/global"; -import { injectStreamMenuButtons, setupStreamUiEvents } from "@modules/stream/stream-ui"; +import { injectStreamMenuButtons } from "@modules/stream/stream-ui"; import { BxLogger } from "@utils/bx-logger"; import { GameBar } from "./modules/game-bar/game-bar"; import { Screenshot } from "./utils/screenshot"; +import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler"; +import { GuideMenu, GuideMenuTab } from "./modules/ui/guide-menu"; // Handle login page @@ -166,17 +168,19 @@ window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => { BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); }); -window.addEventListener(BxEvent.STREAM_STOPPED, e => { +function unload() { if (!STATES.isPlaying) { return; } + // Stop MKB listeners + EmulatedMkbHandler.getInstance().destroy(); + NativeMkbHandler.getInstance().destroy(); + STATES.isPlaying = false; STATES.currentStream = {}; window.BX_EXPOSED.shouldShowSensorControls = false; - - // Stop MKB listeners - getPref(PrefKey.MKB_ENABLED) && MkbHandler.INSTANCE.destroy(); + window.BX_EXPOSED.stopTakRendering = false; const $streamSettingsDialog = document.querySelector('.bx-stream-settings-dialog'); if ($streamSettingsDialog) { @@ -190,6 +194,11 @@ window.addEventListener(BxEvent.STREAM_STOPPED, e => { MouseCursorHider.stop(); TouchController.reset(); GameBar.getInstance().disable(); +} + +window.addEventListener(BxEvent.STREAM_STOPPED, unload); +window.addEventListener('pagehide', e => { + BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); }); window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => { @@ -218,7 +227,7 @@ function observeRootDialog($root: HTMLElement) { for (index = 0; ($elm = $elm?.previousElementSibling); index++); if (index === 0) { - BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_SHOWN, {where: XcloudGuideWhere.HOME}); + BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME}); } } } @@ -263,6 +272,7 @@ function main() { interceptHttpRequests(); patchVideoApi(); patchCanvasContext(); + AppInterface && patchPointerLockApi(); getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && patchAudioContext(); getPref(PrefKey.BLOCK_TRACKING) && patchMeControl(); @@ -281,10 +291,10 @@ function main() { (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance(); BX_FLAGS.PreloadUi && setupStreamUi(); - setupStreamUiEvents(); + GuideMenu.observe(); StreamBadges.setupEvents(); StreamStats.setupEvents(); - MkbHandler.setupEvents(); + EmulatedMkbHandler.setupEvents(); Patcher.init(); diff --git a/src/modules/controller-shortcut.ts b/src/modules/controller-shortcut.ts index 5d2bed8..faf1920 100644 --- a/src/modules/controller-shortcut.ts +++ b/src/modules/controller-shortcut.ts @@ -3,7 +3,7 @@ import { GamepadKey } from "./mkb/definitions"; import { PrompFont } from "@utils/prompt-font"; import { CE } from "@utils/html"; import { t } from "@utils/translation"; -import { MkbHandler } from "./mkb/mkb-handler"; +import { EmulatedMkbHandler } from "./mkb/mkb-handler"; import { StreamStats } from "./stream/stream-stats"; import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone"; import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui"; @@ -172,7 +172,7 @@ export class ControllerShortcut { } // Ignore emulated gamepad - if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) { + if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) { continue; } diff --git a/src/modules/mkb/base-mkb-handler.ts b/src/modules/mkb/base-mkb-handler.ts new file mode 100644 index 0000000..382a9ce --- /dev/null +++ b/src/modules/mkb/base-mkb-handler.ts @@ -0,0 +1,23 @@ +export abstract class MouseDataProvider { + protected mkbHandler: MkbHandler; + constructor(handler: MkbHandler) { + this.mkbHandler = handler; + } + + abstract init(): void; + abstract start(): void; + abstract stop(): void; + abstract destroy(): void; +} + +export abstract class MkbHandler { + abstract init(): void; + abstract start(): void; + abstract stop(): void; + abstract destroy(): void; + abstract handleMouseMove(data: MkbMouseMove): void; + abstract handleMouseClick(data: MkbMouseClick): void; + abstract handleMouseWheel(data: MkbMouseWheel): boolean; + abstract waitForMouseData(enabled: boolean): void; + abstract isEnabled(): boolean; +} diff --git a/src/modules/mkb/mkb-handler.ts b/src/modules/mkb/mkb-handler.ts index 21b55bc..dc08754 100644 --- a/src/modules/mkb/mkb-handler.ts +++ b/src/modules/mkb/mkb-handler.ts @@ -1,5 +1,5 @@ import { MkbPreset } from "./mkb-preset"; -import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo } from "./definitions"; +import { GamepadKey, MkbPresetKey, GamepadStick, MouseMapTo, WheelCode } from "./definitions"; import { createButton, ButtonStyle, CE } from "@utils/html"; import { BxEvent } from "@utils/bx-event"; import { PrefKey, getPref } from "@utils/preferences"; @@ -12,25 +12,19 @@ import { showStreamSettings } from "@modules/stream/stream-ui"; import { AppInterface, STATES } from "@utils/global"; import { UserAgent } from "@utils/user-agent"; import { BxLogger } from "@utils/bx-logger"; -import { BxIcon } from "@utils/bx-icon"; import { PointerClient } from "./pointer-client"; +import { NativeMkbHandler } from "./native-mkb-handler"; +import { MkbHandler, MouseDataProvider } from "./base-mkb-handler"; const LOG_TAG = 'MkbHandler'; - -abstract class MouseDataProvider { - protected mkbHandler: MkbHandler; - constructor(handler: MkbHandler) { - this.mkbHandler = handler; - } - - abstract init(): void; - abstract start(): void; - abstract stop(): void; - abstract destroy(): void; - abstract toggle(enabled: boolean): void; +const PointerToMouseButton = { + 1: 0, + 2: 2, + 4: 1, } + class WebSocketMouseDataProvider extends MouseDataProvider { #pointerClient: PointerClient | undefined #connected = false @@ -57,36 +51,22 @@ class WebSocketMouseDataProvider extends MouseDataProvider { destroy(): void { this.#connected && this.#pointerClient?.stop(); } - - toggle(enabled: boolean): void { - if (!this.#connected) { - enabled = false; - } - - enabled ? this.mkbHandler.start() : this.mkbHandler.stop(); - this.mkbHandler.waitForMouseData(!enabled); - } } class PointerLockMouseDataProvider extends MouseDataProvider { - init(): void { - document.addEventListener('pointerlockchange', this.#onPointerLockChange); - document.addEventListener('pointerlockerror', this.#onPointerLockError); - } + init(): void {} start(): void { - if (!document.pointerLockElement) { - document.body.requestPointerLock(); - } - window.addEventListener('mousemove', this.#onMouseMoveEvent); window.addEventListener('mousedown', this.#onMouseEvent); window.addEventListener('mouseup', this.#onMouseEvent); - window.addEventListener('wheel', this.#onWheelEvent); + window.addEventListener('wheel', this.#onWheelEvent, {passive: false}); window.addEventListener('contextmenu', this.#disableContextMenu); } stop(): void { + document.pointerLockElement && document.exitPointerLock(); + window.removeEventListener('mousemove', this.#onMouseMoveEvent); window.removeEventListener('mousedown', this.#onMouseEvent); window.removeEventListener('mouseup', this.#onMouseEvent); @@ -94,32 +74,7 @@ class PointerLockMouseDataProvider extends MouseDataProvider { window.removeEventListener('contextmenu', this.#disableContextMenu); } - destroy(): void { - document.removeEventListener('pointerlockchange', this.#onPointerLockChange); - document.removeEventListener('pointerlockerror', this.#onPointerLockError); - } - - toggle(enabled: boolean): void { - enabled ? document.pointerLockElement && this.mkbHandler.start() : this.mkbHandler.stop(); - - if (enabled) { - !document.pointerLockElement && this.mkbHandler.waitForMouseData(true); - } else { - this.mkbHandler.waitForMouseData(false); - document.pointerLockElement && document.exitPointerLock(); - } - } - - #onPointerLockChange = () => { - if (this.mkbHandler.isEnabled() && !document.pointerLockElement) { - this.mkbHandler.stop(); - } - } - - #onPointerLockError = (e: Event) => { - console.log(e); - this.stop(); - } + destroy(): void {} #onMouseMoveEvent = (e: MouseEvent) => { this.mkbHandler.handleMouseMove({ @@ -132,10 +87,9 @@ class PointerLockMouseDataProvider extends MouseDataProvider { e.preventDefault(); const isMouseDown = e.type === 'mousedown'; - const key = KeyHelper.getKeyFromEvent(e); const data: MkbMouseClick = { - key: key, - pressed: isMouseDown + mouseButton: e.button, + pressed: isMouseDown, }; this.mkbHandler.handleMouseClick(data); @@ -147,7 +101,12 @@ class PointerLockMouseDataProvider extends MouseDataProvider { return; } - if (this.mkbHandler.handleMouseWheel({key})) { + const data: MkbMouseWheel = { + vertical: e.deltaY, + horizontal: e.deltaX, + }; + + if (this.mkbHandler.handleMouseWheel(data)) { e.preventDefault(); } } @@ -159,14 +118,14 @@ class PointerLockMouseDataProvider extends MouseDataProvider { 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(); +export class EmulatedMkbHandler extends MkbHandler { + static #instance: EmulatedMkbHandler; + public static getInstance(): EmulatedMkbHandler { + if (!EmulatedMkbHandler.#instance) { + EmulatedMkbHandler.#instance = new EmulatedMkbHandler(); } - return MkbHandler.#instance; + return EmulatedMkbHandler.#instance; } #CURRENT_PRESET_DATA = MkbPreset.convert(MkbPreset.DEFAULT_PRESET); @@ -178,7 +137,7 @@ export class MkbHandler { static VIRTUAL_GAMEPAD_ID = 'Xbox 360 Controller'; #VIRTUAL_GAMEPAD = { - id: MkbHandler.VIRTUAL_GAMEPAD_ID, + id: EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID, index: 3, connected: false, hapticActuators: null, @@ -203,6 +162,8 @@ export class MkbHandler { #$message?: HTMLElement; + #escKeyDownTime: number = -1; + #STICK_MAP: {[key in GamepadKey]?: [GamepadKey[], number, number]}; #LEFT_STICK_X: GamepadKey[] = []; #LEFT_STICK_Y: GamepadKey[] = []; @@ -210,6 +171,8 @@ export class MkbHandler { #RIGHT_STICK_Y: GamepadKey[] = []; constructor() { + super(); + this.#STICK_MAP = { [GamepadKey.LS_LEFT]: [this.#LEFT_STICK_X, 0, -1], [GamepadKey.LS_RIGHT]: [this.#LEFT_STICK_X, 0, 1], @@ -307,20 +270,34 @@ export class MkbHandler { const isKeyDown = e.type === 'keydown'; // Toggle MKB feature - if (isKeyDown) { - if (e.code === 'F8') { + if (e.code === 'F8') { + if (!isKeyDown) { e.preventDefault(); this.toggle(); - return; - } else if (e.code === 'Escape') { - e.preventDefault(); - this.#enabled && this.stop(); - return; } - if (!this.#isPolling) { - return; + return; + } + + // Hijack the Esc button + if (e.code === 'Escape') { + e.preventDefault(); + + // Hold the Esc for 1 second to disable MKB + if (this.#enabled && isKeyDown) { + if (this.#escKeyDownTime === -1) { + this.#escKeyDownTime = performance.now(); + } else if (performance.now() - this.#escKeyDownTime >= 1000) { + this.stop(); + } + } else { + this.#escKeyDownTime = -1; } + return; + } + + if (!this.#isPolling) { + return; } const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[e.code || e.key]!; @@ -347,11 +324,24 @@ export class MkbHandler { } handleMouseClick = (data: MkbMouseClick) => { - if (!data || !data.key) { + let mouseButton; + if (typeof data.mouseButton !== 'undefined') { + mouseButton = data.mouseButton; + } else if (typeof data.pointerButton !== 'undefined') { + mouseButton = PointerToMouseButton[data.pointerButton as keyof typeof PointerToMouseButton]; + } + + const keyCode = 'Mouse' + mouseButton; + const key = { + code: keyCode, + name: KeyHelper.codeToKeyName(keyCode), + }; + + if (!key.name) { return; } - const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[data.key.code]!; + const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code]!; if (typeof buttonIndex === 'undefined') { return; } @@ -379,9 +369,9 @@ export class MkbHandler { 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; + } else if (length > EmulatedMkbHandler.MAXIMUM_STICK_RANGE) { + x *= EmulatedMkbHandler.MAXIMUM_STICK_RANGE / length; + y *= EmulatedMkbHandler.MAXIMUM_STICK_RANGE / length; } const analog = mouseMapTo === MouseMapTo.LS ? GamepadStick.LEFT : GamepadStick.RIGHT; @@ -389,16 +379,32 @@ export class MkbHandler { } handleMouseWheel = (data: MkbMouseWheel): boolean => { - if (!data || !data.key) { + let code = ''; + if (data.vertical < 0) { + code = WheelCode.SCROLL_UP; + } else if (data.vertical > 0) { + code = WheelCode.SCROLL_DOWN; + } else if (data.horizontal < 0) { + code = WheelCode.SCROLL_LEFT; + } else if (data.horizontal > 0) { + code = WheelCode.SCROLL_RIGHT; + } + + if (!code) { return false; } - const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[data.key.code]!; + const key = { + code: code, + name: KeyHelper.codeToKeyName(code), + }; + + const buttonIndex = this.#CURRENT_PRESET_DATA.mapping[key.code]!; if (typeof buttonIndex === 'undefined') { return false; } - if (this.#prevWheelCode === null || this.#prevWheelCode === data.key.code) { + if (this.#prevWheelCode === null || this.#prevWheelCode === key.code) { this.#wheelStoppedTimeout && clearTimeout(this.#wheelStoppedTimeout); this.#pressButton(buttonIndex, true); } @@ -418,8 +424,11 @@ export class MkbHandler { this.#enabled = !this.#enabled; } - Toast.show(t('mouse-and-keyboard'), t(this.#enabled ? 'enabled' : 'disabled'), {instant: true}); - this.#mouseDataProvider?.toggle(this.#enabled); + if (this.#enabled) { + document.body.requestPointerLock(); + } else { + document.pointerLockElement && document.exitPointerLock(); + } } #getCurrentPreset = (): Promise => { @@ -455,9 +464,97 @@ export class MkbHandler { } } + #onDialogShown = () => { + document.pointerLockElement && document.exitPointerLock(); + } + + #initMessage = () => { + if (!this.#$message) { + this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg bx-gone'}, + CE('div', {}, + CE('p', {}, t('virtual-controller')), + CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})), + ), + + CE('div', {'data-type': 'virtual'}, + createButton({ + style: ButtonStyle.PRIMARY | ButtonStyle.TALL | ButtonStyle.FULL_WIDTH, + label: t('activate'), + onClick: ((e: Event) => { + e.preventDefault(); + e.stopPropagation(); + + this.toggle(true); + }).bind(this), + }), + + CE('div', {}, + createButton({ + label: t('ignore'), + style: ButtonStyle.GHOST, + onClick: e => { + e.preventDefault(); + e.stopPropagation(); + + this.toggle(false); + this.waitForMouseData(false); + }, + }), + + createButton({ + label: t('edit'), + onClick: e => { + e.preventDefault(); + e.stopPropagation(); + + showStreamSettings('mkb'); + }, + }), + ), + ), + ); + } + + if (!this.#$message.isConnected) { + document.documentElement.appendChild(this.#$message); + } + } + + #onPointerLockChange = () => { + if (document.pointerLockElement) { + this.start(); + } else { + this.stop(); + } + } + + #onPointerLockError = (e: Event) => { + console.log(e); + this.stop(); + } + + #onPointerLockRequested = () => { + this.start(); + } + + #onPointerLockExited = () => { + this.#mouseDataProvider?.stop(); + } + + handleEvent(event: Event) { + switch (event.type) { + case BxEvent.POINTER_LOCK_REQUESTED: + this.#onPointerLockRequested(); + break; + case BxEvent.POINTER_LOCK_EXITED: + this.#onPointerLockExited(); + break; + } + } + init = () => { this.refreshPresetData(); - this.#enabled = true; + this.#enabled = false; if (AppInterface) { this.#mouseDataProvider = new WebSocketMouseDataProvider(this); @@ -467,48 +564,29 @@ export class MkbHandler { this.#mouseDataProvider.init(); window.addEventListener('keydown', this.#onKeyboardEvent); - - if (!this.#$message) { - this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg'}, - CE('div', {}, - CE('p', {}, t('mkb-click-to-activate')), - CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})), - ), - - CE('div', {}, - createButton({ - icon: BxIcon.MOUSE_SETTINGS, - label: t('edit'), - style: ButtonStyle.PRIMARY, - onClick: e => { - e.preventDefault(); - e.stopPropagation(); - - showStreamSettings('mkb'); - }, - }), - - createButton({ - label: t('disable'), - onClick: e => { - e.preventDefault(); - e.stopPropagation(); - - this.toggle(false); - this.waitForMouseData(false); - }, - }), - ), - ); - - this.#$message.addEventListener('click', this.start.bind(this)); - document.documentElement.appendChild(this.#$message); - } + window.addEventListener('keyup', this.#onKeyboardEvent); window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged); + window.addEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this.#onDialogShown); - this.#$message.classList.add('bx-gone'); - this.waitForMouseData(true); + if (AppInterface) { + // Android app doesn't support PointerLock API so we need to use a different method + window.addEventListener(BxEvent.POINTER_LOCK_REQUESTED, this); + window.addEventListener(BxEvent.POINTER_LOCK_EXITED, this); + } else { + document.addEventListener('pointerlockchange', this.#onPointerLockChange); + document.addEventListener('pointerlockerror', this.#onPointerLockError); + } + + this.#initMessage(); + this.#$message?.classList.add('bx-gone'); + + if (AppInterface) { + Toast.show(t('press-key-to-toggle-mkb', {key: `F8`}), t('virtual-controller'), {html: true}); + this.waitForMouseData(false); + } else { + this.waitForMouseData(true); + } } destroy = () => { @@ -520,6 +598,18 @@ export class MkbHandler { document.pointerLockElement && document.exitPointerLock(); window.removeEventListener('keydown', this.#onKeyboardEvent); + window.removeEventListener('keyup', this.#onKeyboardEvent); + + if (AppInterface) { + window.removeEventListener(BxEvent.POINTER_LOCK_REQUESTED, this); + window.removeEventListener(BxEvent.POINTER_LOCK_EXITED, this); + } else { + document.removeEventListener('pointerlockchange', this.#onPointerLockChange); + document.removeEventListener('pointerlockerror', this.#onPointerLockError); + } + + window.removeEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this.#onPollingModeChanged); + window.removeEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this.#onDialogShown); this.#mouseDataProvider?.destroy(); @@ -529,17 +619,17 @@ export class MkbHandler { start = () => { if (!this.#enabled) { this.#enabled = true; - Toast.show(t('mouse-and-keyboard'), t('enabled'), {instant: true}); + Toast.show(t('virtual-controller'), t('enabled'), {instant: true}); } this.#isPolling = true; + this.#escKeyDownTime = -1; this.#resetGamepad(); window.navigator.getGamepads = this.#patchedGetGamepads; this.waitForMouseData(false); - window.addEventListener('keyup', this.#onKeyboardEvent); this.#mouseDataProvider?.start(); // Dispatch "gamepadconnected" event @@ -550,36 +640,48 @@ export class MkbHandler { BxEvent.dispatch(window, 'gamepadconnected', { gamepad: virtualGamepad, }); + + window.BX_EXPOSED.stopTakRendering = true; + + Toast.show(t('virtual-controller'), t('enabled'), {instant: true}); } stop = () => { + this.#enabled = false; this.#isPolling = false; - - // Dispatch "gamepaddisconnected" event - this.#resetGamepad(); + this.#escKeyDownTime = -1; const virtualGamepad = this.#getVirtualGamepad(); - virtualGamepad.connected = false; - virtualGamepad.timestamp = performance.now(); + if (virtualGamepad.connected) { + // Dispatch "gamepaddisconnected" event + this.#resetGamepad(); - BxEvent.dispatch(window, 'gamepaddisconnected', { - gamepad: virtualGamepad, - }); + virtualGamepad.connected = false; + virtualGamepad.timestamp = performance.now(); - window.navigator.getGamepads = this.#nativeGetGamepads; + BxEvent.dispatch(window, 'gamepaddisconnected', { + gamepad: virtualGamepad, + }); - window.removeEventListener('keyup', this.#onKeyboardEvent); + window.navigator.getGamepads = this.#nativeGetGamepads; + } this.waitForMouseData(true); this.#mouseDataProvider?.stop(); + + // Toast.show(t('virtual-controller'), t('disabled'), {instant: true}); } static setupEvents() { - getPref(PrefKey.MKB_ENABLED) && (AppInterface || !UserAgent.isMobile()) && window.addEventListener(BxEvent.STREAM_PLAYING, () => { - // Enable MKB - if (!STATES.currentStream.titleInfo?.details.hasMkbSupport) { - BxLogger.info(LOG_TAG, 'Emulate MKB'); - MkbHandler.INSTANCE.init(); + window.addEventListener(BxEvent.STREAM_PLAYING, () => { + if (STATES.currentStream.titleInfo?.details.hasMkbSupport) { + // Enable native MKB in Android app + if (AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on') { + AppInterface && NativeMkbHandler.getInstance().init(); + } + } else if (getPref(PrefKey.MKB_ENABLED) && (AppInterface || !UserAgent.isMobile())) { + BxLogger.info(LOG_TAG, 'Emulate MKB'); + EmulatedMkbHandler.getInstance().init(); } }); } diff --git a/src/modules/mkb/mkb-preset.ts b/src/modules/mkb/mkb-preset.ts index 5e0ad13..dfb0572 100644 --- a/src/modules/mkb/mkb-preset.ts +++ b/src/modules/mkb/mkb-preset.ts @@ -1,7 +1,7 @@ import { t } from "@utils/translation"; import { SettingElementType } from "@utils/settings"; import { GamepadKey, MouseButtonCode, MouseMapTo, MkbPresetKey } from "./definitions"; -import { MkbHandler } from "./mkb-handler"; +import { EmulatedMkbHandler } from "./mkb-handler"; import type { MkbPresetData, MkbConvertedPresetData } from "@/types/mkb"; import type { PreferenceSettings } from "@/types/preferences"; @@ -119,9 +119,9 @@ export class MkbPreset { // 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_SENSITIVITY_X] *= EmulatedMkbHandler.DEFAULT_PANNING_SENSITIVITY; + mouse[MkbPresetKey.MOUSE_SENSITIVITY_Y] *= EmulatedMkbHandler.DEFAULT_PANNING_SENSITIVITY; + mouse[MkbPresetKey.MOUSE_DEADZONE_COUNTERWEIGHT] *= EmulatedMkbHandler.DEFAULT_DEADZONE_COUNTERWEIGHT; const mouseMapTo = MouseMapTo[mouse[MkbPresetKey.MOUSE_MAP_TO]!]; if (typeof mouseMapTo !== 'undefined') { diff --git a/src/modules/mkb/mkb-remapper.ts b/src/modules/mkb/mkb-remapper.ts index a3db430..7005a44 100644 --- a/src/modules/mkb/mkb-remapper.ts +++ b/src/modules/mkb/mkb-remapper.ts @@ -6,7 +6,7 @@ import { getPref, setPref, PrefKey } from "@utils/preferences"; import { MkbPresetKey, GamepadKeyName } from "./definitions"; import { KeyHelper } from "./key-helper"; import { MkbPreset } from "./mkb-preset"; -import { MkbHandler } from "./mkb-handler"; +import { EmulatedMkbHandler } from "./mkb-handler"; import { LocalDb } from "@utils/local-db"; import { BxIcon } from "@utils/bx-icon"; import { SettingElement } from "@utils/settings"; @@ -258,7 +258,7 @@ export class MkbRemapper { defaultPresetId = this.#STATE.currentPresetId; setPref(PrefKey.MKB_DEFAULT_PRESET_ID, defaultPresetId); - MkbHandler.INSTANCE.refreshPresetData(); + EmulatedMkbHandler.getInstance().refreshPresetData(); } else { defaultPresetId = getPref(PrefKey.MKB_DEFAULT_PRESET_ID); } @@ -487,7 +487,7 @@ export class MkbRemapper { style: ButtonStyle.PRIMARY, onClick: e => { setPref(PrefKey.MKB_DEFAULT_PRESET_ID, this.#STATE.currentPresetId); - MkbHandler.INSTANCE.refreshPresetData(); + EmulatedMkbHandler.getInstance().refreshPresetData(); this.#refresh(); }, @@ -517,7 +517,7 @@ export class MkbRemapper { 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(); + EmulatedMkbHandler.getInstance().refreshPresetData(); } this.#toggleEditing(false); diff --git a/src/modules/mkb/native-mkb-handler.ts b/src/modules/mkb/native-mkb-handler.ts new file mode 100644 index 0000000..ddb2eec --- /dev/null +++ b/src/modules/mkb/native-mkb-handler.ts @@ -0,0 +1,319 @@ +import { Toast } from "@/utils/toast"; +import { PointerClient } from "./pointer-client"; +import { AppInterface } from "@/utils/global"; +import { MkbHandler } from "./base-mkb-handler"; +import { t } from "@/utils/translation"; +import { BxEvent } from "@/utils/bx-event"; +import { ButtonStyle, CE, createButton } from "@/utils/html"; +import { PrefKey, getPref } from "@/utils/preferences"; + +type NativeMouseData = { + X: number, + Y: number, + Buttons: number, + WheelX: number, + WheelY: number, + Type? : 0, // 0: Relative, 1: Absolute +} + +type XcloudInputSink = { + onMouseInput: (data: NativeMouseData) => void; +} + +export class NativeMkbHandler extends MkbHandler { + private static instance: NativeMkbHandler; + #pointerClient: PointerClient | undefined; + #enabled: boolean = false; + + #mouseButtonsPressed = 0; + #mouseWheelX = 0; + #mouseWheelY = 0; + + #mouseVerticalMultiply = 0; + #mouseHorizontalMultiply = 0; + + #inputSink: XcloudInputSink | undefined; + + #$message?: HTMLElement; + + public static getInstance(): NativeMkbHandler { + if (!NativeMkbHandler.instance) { + NativeMkbHandler.instance = new NativeMkbHandler(); + } + + return NativeMkbHandler.instance; + } + + #onKeyboardEvent(e: KeyboardEvent) { + if (e.type === 'keyup' && e.code === 'F8') { + e.preventDefault(); + this.toggle(); + return; + } + } + + #onPointerLockRequested(e: Event) { + AppInterface.requestPointerCapture(); + this.start(); + } + + #onPointerLockExited(e: Event) { + AppInterface.releasePointerCapture(); + this.stop(); + } + + #onPollingModeChanged = (e: Event) => { + if (!this.#$message) { + return; + } + + const mode = (e as any).mode; + if (mode === 'None') { + this.#$message.classList.remove('bx-offscreen'); + } else { + this.#$message.classList.add('bx-offscreen'); + } + } + + #onDialogShown = () => { + document.pointerLockElement && document.exitPointerLock(); + } + + #initMessage() { + if (!this.#$message) { + this.#$message = CE('div', {'class': 'bx-mkb-pointer-lock-msg'}, + CE('div', {}, + CE('p', {}, t('native-mkb')), + CE('p', {}, t('press-key-to-toggle-mkb', {key: 'F8'})), + ), + + CE('div', {'data-type': 'native'}, + createButton({ + style: ButtonStyle.PRIMARY | ButtonStyle.FULL_WIDTH | ButtonStyle.TALL, + label: t('activate'), + onClick: ((e: Event) => { + e.preventDefault(); + e.stopPropagation(); + + this.toggle(true); + }).bind(this), + }), + + createButton({ + style: ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH, + label: t('ignore'), + onClick: e => { + e.preventDefault(); + e.stopPropagation(); + + this.#$message?.classList.add('bx-gone'); + }, + }), + ), + ); + } + + if (!this.#$message.isConnected) { + document.documentElement.appendChild(this.#$message); + } + } + + handleEvent(event: Event) { + switch (event.type) { + case 'keyup': + this.#onKeyboardEvent(event as KeyboardEvent); + break; + + case BxEvent.XCLOUD_DIALOG_SHOWN: + this.#onDialogShown(); + break; + + case BxEvent.POINTER_LOCK_REQUESTED: + this.#onPointerLockRequested(event); + break; + case BxEvent.POINTER_LOCK_EXITED: + this.#onPointerLockExited(event); + break; + + case BxEvent.XCLOUD_POLLING_MODE_CHANGED: + this.#onPollingModeChanged(event); + break; + } + } + + init() { + this.#pointerClient = PointerClient.getInstance(); + this.#inputSink = window.BX_EXPOSED.inputSink; + + // Stop keyboard input at startup + this.#updateInputConfigurationAsync(false); + + try { + this.#pointerClient.start(this); + } catch (e) { + Toast.show('Cannot enable Mouse & Keyboard feature'); + } + + this.#mouseVerticalMultiply = getPref(PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY); + this.#mouseHorizontalMultiply = getPref(PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY); + + window.addEventListener('keyup', this); + + window.addEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this); + window.addEventListener(BxEvent.POINTER_LOCK_REQUESTED, this); + window.addEventListener(BxEvent.POINTER_LOCK_EXITED, this); + window.addEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this); + + this.#initMessage(); + + if (AppInterface) { + Toast.show(t('press-key-to-toggle-mkb', {key: `F8`}), t('native-mkb'), {html: true}); + this.#$message?.classList.add('bx-gone'); + } else { + this.#$message?.classList.remove('bx-gone'); + } + } + + toggle(force?: boolean) { + let setEnable: boolean; + if (typeof force !== 'undefined') { + setEnable = force; + } else { + setEnable = !this.#enabled; + } + + if (setEnable) { + document.documentElement.requestPointerLock(); + } else { + document.exitPointerLock(); + } + } + + #updateInputConfigurationAsync(enabled: boolean) { + window.BX_EXPOSED.streamSession.updateInputConfigurationAsync({ + enableKeyboardInput: enabled, + enableMouseInput: enabled, + enableAbsoluteMouse: false, + enableTouchInput: false, + }); + } + + start() { + this.#resetMouseInput(); + this.#enabled = true; + + this.#updateInputConfigurationAsync(true); + + window.BX_EXPOSED.stopTakRendering = true; + this.#$message?.classList.add('bx-gone'); + + Toast.show(t('native-mkb'), t('enabled'), {instant: true}); + } + + stop() { + this.#resetMouseInput(); + this.#enabled = false; + this.#updateInputConfigurationAsync(false); + + this.#$message?.classList.remove('bx-gone'); + } + + destroy(): void { + this.#pointerClient?.stop(); + window.removeEventListener('keyup', this); + + window.removeEventListener(BxEvent.XCLOUD_DIALOG_SHOWN, this); + window.removeEventListener(BxEvent.POINTER_LOCK_REQUESTED, this); + window.removeEventListener(BxEvent.POINTER_LOCK_EXITED, this); + window.removeEventListener(BxEvent.XCLOUD_POLLING_MODE_CHANGED, this); + + this.#$message?.classList.add('bx-gone'); + } + + handleMouseMove(data: MkbMouseMove): void { + this.#sendMouseInput({ + X: data.movementX, + Y: data.movementY, + Buttons: this.#mouseButtonsPressed, + WheelX: this.#mouseWheelX, + WheelY: this.#mouseWheelY, + }); + } + + handleMouseClick(data: MkbMouseClick): void { + const { pointerButton, pressed } = data; + + if (pressed) { + this.#mouseButtonsPressed |= pointerButton!; + } else { + this.#mouseButtonsPressed ^= pointerButton!; + } + this.#mouseButtonsPressed = Math.max(0, this.#mouseButtonsPressed); + + this.#sendMouseInput({ + X: 0, + Y: 0, + Buttons: this.#mouseButtonsPressed, + WheelX: this.#mouseWheelX, + WheelY: this.#mouseWheelY, + }); + } + + handleMouseWheel(data: MkbMouseWheel): boolean { + const { vertical, horizontal } = data; + + this.#mouseWheelX = horizontal; + if (this.#mouseHorizontalMultiply && this.#mouseHorizontalMultiply !== 1) { + this.#mouseWheelX *= this.#mouseHorizontalMultiply; + } + + this.#mouseWheelY = vertical; + if (this.#mouseVerticalMultiply && this.#mouseVerticalMultiply !== 1) { + this.#mouseWheelY *= this.#mouseVerticalMultiply; + } + + this.#sendMouseInput({ + X: 0, + Y: 0, + Buttons: this.#mouseButtonsPressed, + WheelX: this.#mouseWheelX, + WheelY: this.#mouseWheelY, + }); + + return true; + } + + setVerticalScrollMultiplier(vertical: number) { + this.#mouseVerticalMultiply = vertical; + } + + setHorizontalScrollMultiplier(horizontal: number) { + this.#mouseHorizontalMultiply = horizontal; + } + + waitForMouseData(enabled: boolean): void { + } + + isEnabled(): boolean { + return this.#enabled; + } + + #sendMouseInput(data: NativeMouseData) { + data.Type = 0; // Relative + this.#inputSink?.onMouseInput(data); + } + + #resetMouseInput() { + this.#mouseButtonsPressed = 0; + this.#mouseWheelX = 0; + this.#mouseWheelY = 0; + + this.#sendMouseInput({ + X: 0, + Y: 0, + Buttons: 0, + WheelX: 0, + WheelY: 0, + }); + } +} diff --git a/src/modules/mkb/pointer-client.ts b/src/modules/mkb/pointer-client.ts index d0b5814..96c3875 100644 --- a/src/modules/mkb/pointer-client.ts +++ b/src/modules/mkb/pointer-client.ts @@ -1,8 +1,6 @@ import { BxLogger } from "@/utils/bx-logger"; -import type { MkbHandler } from "./mkb-handler"; -import { KeyHelper } from "./key-helper"; -import { WheelCode } from "./definitions"; import { Toast } from "@/utils/toast"; +import type { MkbHandler } from "./base-mkb-handler"; const LOG_TAG = 'PointerClient'; @@ -14,11 +12,6 @@ enum PointerAction { POINTER_CAPTURE_CHANGED = 5, } -const FixedMouseIndex = { - 1: 0, - 2: 2, - 4: 1, -} export class PointerClient { static #PORT = 9269; @@ -97,15 +90,10 @@ export class PointerClient { } onPress(messageType: PointerAction, dataView: DataView, offset: number) { - const buttonIndex = dataView.getInt8(offset); - const fixedIndex = FixedMouseIndex[buttonIndex as keyof typeof FixedMouseIndex]; - const keyCode = 'Mouse' + fixedIndex; + const button = dataView.getUint8(offset); this.#mkbHandler?.handleMouseClick({ - key: { - code: keyCode, - name: KeyHelper.codeToKeyName(keyCode), - }, + pointerButton: button, pressed: messageType === PointerAction.BUTTON_PRESS, }); @@ -114,26 +102,13 @@ export class PointerClient { onScroll(dataView: DataView, offset: number) { // [V_SCROLL, H_SCROLL] - const vScroll = dataView.getInt8(offset); - offset += Int8Array.BYTES_PER_ELEMENT; - const hScroll = dataView.getInt8(offset); + const vScroll = dataView.getInt16(offset); + offset += Int16Array.BYTES_PER_ELEMENT; + const hScroll = dataView.getInt16(offset); - let code = ''; - if (vScroll < 0) { - code = WheelCode.SCROLL_UP; - } else if (vScroll > 0) { - code = WheelCode.SCROLL_DOWN; - } else if (hScroll < 0) { - code = WheelCode.SCROLL_LEFT; - } else if (hScroll > 0) { - code = WheelCode.SCROLL_RIGHT; - } - - code && this.#mkbHandler?.handleMouseWheel({ - key: { - code: code, - name: KeyHelper.codeToKeyName(code), - }, + this.#mkbHandler?.handleMouseWheel({ + vertical: vScroll, + horizontal: hScroll, }); // BxLogger.info(LOG_TAG, 'scroll', vScroll, hScroll); @@ -148,5 +123,6 @@ export class PointerClient { try { this.#socket?.close(); } catch (e) {} + this.#socket = null; } } diff --git a/src/modules/patcher.ts b/src/modules/patcher.ts index 9755d62..03995fc 100644 --- a/src/modules/patcher.ts +++ b/src/modules/patcher.ts @@ -304,6 +304,37 @@ window.dispatchEvent(new Event("${BxEvent.TOUCH_LAYOUT_MANAGER_READY}")); return str; }, + + patchBabylonRendererClass(str: string) { + // ()=>{a.current.render(),h.current=window.requestAnimationFrame(l) + let index = str.indexOf('.current.render(),'); + if (index === -1) { + return false; + } + + // Move back a character + index -= 1; + + // Get variable of the "BabylonRendererClass" object + const rendererVar = str[index]; + + const newCode = ` +if (window.BX_EXPOSED.stopTakRendering) { + try { + document.getElementById('BabylonCanvasContainer-main')?.parentElement.classList.add('bx-offscreen'); + + ${rendererVar}.current.dispose(); + } catch (e) {} + + window.BX_EXPOSED.stopTakRendering = false; + return; +} +`; + + str = str.substring(0, index) + newCode + str.substring(index); + return str; + }, + supportLocalCoOp(str: string) { const text = 'this.gamepadMappingsToSend=[],'; if (!str.includes(text)) { @@ -564,9 +595,58 @@ true` + text; str = str.replace(text, '&& false ' + text); return str; }, + + enableNativeMkb(str: string) { + const text = 'e.mouseSupported&&e.keyboardSupported&&e.fullscreenSupported;'; + if ((!str.includes(text))) { + return false; + } + + str = str.replace(text, text + 'return true;'); + return str; + }, + + patchMouseAndKeyboardEnabled(str: string) { + const text = 'get mouseAndKeyboardEnabled(){'; + if (!str.includes(text)) { + return false; + } + + str = str.replace(text, text + 'return true;'); + return str; + }, + + exposeInputSink(str: string) { + const text = 'this.controlChannel=null,this.inputChannel=null'; + if (!str.includes(text)) { + return false; + } + + const newCode = 'window.BX_EXPOSED.inputSink = this;'; + + str = str.replace(text, newCode + text); + return str; + }, + + disableNativeRequestPointerLock(str: string) { + const text = 'async requestPointerLock(){'; + if (!str.includes(text)) { + return false; + } + + str = str.replace(text, text + 'return;'); + return str; + } }; let PATCH_ORDERS: PatchArray = [ + ...(getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' ? [ + 'enableNativeMkb', + 'patchMouseAndKeyboardEnabled', + 'disableNativeRequestPointerLock', + 'exposeInputSink', + ] : []), + 'disableStreamGate', 'overrideSettings', 'broadcastPollingMode', @@ -618,11 +698,13 @@ let PLAYING_PATCH_ORDERS: PatchArray = [ // Skip feedback dialog getPref(PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG) && 'skipFeedbackDialog', - - STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', - STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', - STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer', - STATES.hasTouchSupport && getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity', + ...(STATES.hasTouchSupport ? [ + getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'patchShowSensorControls', + getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'all' && 'exposeTouchLayoutManager', + (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off' || getPref(PrefKey.STREAM_TOUCH_CONTROLLER_AUTO_OFF)) && 'disableTakRenderer', + getPref(PrefKey.STREAM_TOUCH_CONTROLLER_DEFAULT_OPACITY) !== 100 && 'patchTouchControlDefaultOpacity', + 'patchBabylonRendererClass', + ] : []), BX_FLAGS.EnableXcloudLogging && 'enableConsoleLogging', diff --git a/src/modules/stream/stream-badges.ts b/src/modules/stream/stream-badges.ts index de2bb15..7456b8a 100644 --- a/src/modules/stream/stream-badges.ts +++ b/src/modules/stream/stream-badges.ts @@ -1,5 +1,5 @@ import { t } from "@utils/translation"; -import { BxEvent, XcloudGuideWhere } from "@utils/bx-event"; +import { BxEvent } from "@utils/bx-event"; import { CE, createSvgIcon } from "@utils/html"; import { STATES } from "@utils/global"; import { BxLogger } from "@/utils/bx-logger"; diff --git a/src/modules/stream/stream-ui.ts b/src/modules/stream/stream-ui.ts index 93f7df4..504f2d4 100644 --- a/src/modules/stream/stream-ui.ts +++ b/src/modules/stream/stream-ui.ts @@ -1,7 +1,7 @@ import { STATES } from "@utils/global.ts"; -import { ButtonStyle, createButton, createSvgIcon } from "@utils/html.ts"; +import { createSvgIcon } from "@utils/html.ts"; import { BxIcon } from "@utils/bx-icon"; -import { BxEvent, XcloudGuideWhere } from "@utils/bx-event.ts"; +import { BxEvent } from "@utils/bx-event.ts"; import { t } from "@utils/translation.ts"; import { StreamBadges } from "./stream-badges.ts"; import { StreamStats } from "./stream-stats.ts"; @@ -283,43 +283,3 @@ export function showStreamSettings(tabId: string) { $parent.addEventListener('click', onClick); } } - - -export function setupStreamUiEvents() { - window.addEventListener(BxEvent.XCLOUD_GUIDE_SHOWN, async e => { - const where = (e as any).where as XcloudGuideWhere; - - if (where !== XcloudGuideWhere.HOME || !STATES.isPlaying) { - return; - } - - const $btnQuit = document.querySelector('#gamepass-dialog-root a[class*=QuitGameButton]'); - if (!$btnQuit) { - return; - } - - // Add buttons - const $btnReload = createButton({ - label: t('reload-stream'), - style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, - onClick: e => { - confirm(t('confirm-reload-stream')) && window.location.reload(); - }, - }); - - const $btnHome = createButton({ - label: t('back-to-home'), - style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, - onClick: e => { - confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31)); - }, - }); - - $btnQuit.insertAdjacentElement('afterend', $btnReload); - $btnReload.insertAdjacentElement('afterend', $btnHome); - - // Hide xCloud's Home button - const $btnXcloudHome = document.querySelector('#gamepass-dialog-root div[class^=HomeButtonWithDivider]') as HTMLElement; - $btnXcloudHome && ($btnXcloudHome.style.display = 'none'); - }); -} diff --git a/src/modules/ui/global-settings.ts b/src/modules/ui/global-settings.ts index f6554d2..09f6cb4 100644 --- a/src/modules/ui/global-settings.ts +++ b/src/modules/ui/global-settings.ts @@ -55,7 +55,7 @@ const SETTINGS_UI = { [t('mouse-and-keyboard')]: { items: [ - PrefKey.NATIVE_MKB_DISABLED, + PrefKey.NATIVE_MKB_ENABLED, PrefKey.MKB_ENABLED, PrefKey.MKB_HIDE_IDLE_CURSOR, ], @@ -375,7 +375,7 @@ export function setupSettingsUi() { $btnReload = createButton({ label: t('settings-reload'), classes: ['bx-settings-reload-button'], - style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH, + style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH | ButtonStyle.TALL, onClick: e => { window.location.reload(); $btnReload.disabled = true; diff --git a/src/modules/ui/guide-menu.ts b/src/modules/ui/guide-menu.ts new file mode 100644 index 0000000..6cfba4a --- /dev/null +++ b/src/modules/ui/guide-menu.ts @@ -0,0 +1,80 @@ +import { BxEvent } from "@/utils/bx-event"; +import { AppInterface, STATES } from "@/utils/global"; +import { createButton, ButtonStyle } from "@/utils/html"; +import { t } from "@/utils/translation"; + +export enum GuideMenuTab { + HOME, +} + +export class GuideMenu { + static #injectHome($root: HTMLElement) { + // Find the last divider + const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]'); + if (!$dividers) { + return; + } + const $lastDivider = $dividers[$dividers.length - 1]; + + // Add "Close app" button + if (AppInterface) { + const $btnQuit = createButton({ + label: t('close-app'), + style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER, + onClick: e => { + AppInterface.closeApp(); + }, + }); + + $lastDivider.insertAdjacentElement('afterend', $btnQuit); + } + } + + static #injectHomePlaying($root: HTMLElement) { + const $btnQuit = $root.querySelector('a[class*=QuitGameButton]'); + if (!$btnQuit) { + return; + } + + // Add buttons + const $btnReload = createButton({ + label: t('reload-stream'), + style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, + onClick: e => { + confirm(t('confirm-reload-stream')) && window.location.reload(); + }, + }); + + const $btnHome = createButton({ + label: t('back-to-home'), + style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, + onClick: e => { + confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31)); + }, + }); + + $btnQuit.insertAdjacentElement('afterend', $btnReload); + $btnReload.insertAdjacentElement('afterend', $btnHome); + + // Hide xCloud's Home button + const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement; + $btnXcloudHome && ($btnXcloudHome.style.display = 'none'); + } + + static async #onShown(e: Event) { + const where = (e as any).where as GuideMenuTab; + + if (where === GuideMenuTab.HOME) { + const $root = document.querySelector('#gamepass-dialog-root div[role=dialog]') as HTMLElement; + if (STATES.isPlaying) { + GuideMenu.#injectHomePlaying($root); + } else { + GuideMenu.#injectHome($root); + } + } + } + + static observe() { + window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown); + } +} diff --git a/src/modules/ui/ui.ts b/src/modules/ui/ui.ts index 2db9cf6..81d2941 100644 --- a/src/modules/ui/ui.ts +++ b/src/modules/ui/ui.ts @@ -1,4 +1,4 @@ -import { STATES } from "@utils/global"; +import { AppInterface, STATES } from "@utils/global"; import { CE, createButton, ButtonStyle, createSvgIcon } from "@utils/html"; import { BxIcon } from "@utils/bx-icon"; import { UserAgent } from "@utils/user-agent"; @@ -12,6 +12,7 @@ import { VibrationManager } from "@modules/vibration-manager"; import { Screenshot } from "@/utils/screenshot"; import { ControllerShortcut } from "../controller-shortcut"; import { SoundShortcut } from "../shortcuts/shortcut-sound"; +import { NativeMkbHandler } from "../mkb/native-mkb-handler"; export function localRedirect(path: string) { @@ -72,19 +73,6 @@ function setupStreamSettingsDialog() { const isSafari = UserAgent.isSafari(); const SETTINGS_UI = [ - getPref(PrefKey.MKB_ENABLED) && { - icon: BxIcon.MOUSE, - group: 'mkb', - items: [ - { - group: 'mkb', - label: t('mouse-and-keyboard'), - help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/', - content: MkbRemapper.INSTANCE.render(), - }, - ], - }, - { icon: BxIcon.DISPLAY, group: 'stream', @@ -241,6 +229,44 @@ function setupStreamSettingsDialog() { ], }, + getPref(PrefKey.MKB_ENABLED) && { + icon: BxIcon.VIRTUAL_CONTROLLER, + group: 'mkb', + items: [ + { + group: 'mkb', + label: t('virtual-controller'), + help_url: 'https://better-xcloud.github.io/mouse-and-keyboard/', + content: MkbRemapper.INSTANCE.render(), + }, + ], + }, + + AppInterface && getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' && { + icon: BxIcon.NATIVE_MKB, + group: 'native-mkb', + items: [ + { + group: 'native-mkb', + label: t('native-mkb'), + items: [ + { + pref: PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY, + onChange: (e: any, value: number) => { + NativeMkbHandler.getInstance().setVerticalScrollMultiplier(value / 100); + }, + }, + { + pref: PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY, + onChange: (e: any, value: number) => { + NativeMkbHandler.getInstance().setHorizontalScrollMultiplier(value / 100); + }, + }, + ], + }, + ], + }, + { icon: BxIcon.COMMAND, group: 'shortcuts', diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 9228cc2..366af6f 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -82,16 +82,12 @@ type MkbMouseMove = { } type MkbMouseClick = { - key: { - code: string; - name: string; - } | null; - pressed: boolean; + pointerButton?: number, + mouseButton?: number, + pressed: boolean, } type MkbMouseWheel = { - key: { - code: string; - name: string; - } | null; + vertical: number; + horizontal: number; } diff --git a/src/utils/bx-event.ts b/src/utils/bx-event.ts index a1b1a53..0a6679c 100644 --- a/src/utils/bx-event.ts +++ b/src/utils/bx-event.ts @@ -35,11 +35,14 @@ export enum BxEvent { CAPTURE_SCREENSHOT = 'bx-capture-screenshot', GAINNODE_VOLUME_CHANGED = 'bx-gainnode-volume-changed', + POINTER_LOCK_REQUESTED = 'bx-pointer-lock-requested', + POINTER_LOCK_EXITED = 'bx-pointer-lock-exited', + // xCloud Dialog events XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown', XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed', - XCLOUD_GUIDE_SHOWN = 'bx-xcloud-guide-shown', + XCLOUD_GUIDE_MENU_SHOWN = 'bx-xcloud-guide-menu-shown', XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed', } @@ -48,10 +51,6 @@ export enum XcloudEvent { MICROPHONE_STATE_CHANGED = 'microphoneStateChanged', } -export enum XcloudGuideWhere { - HOME, -} - export namespace BxEvent { export function dispatch(target: HTMLElement | Window, eventName: string, data?: any) { if (!eventName) { diff --git a/src/utils/bx-exposed.ts b/src/utils/bx-exposed.ts index bd07007..cd134fc 100644 --- a/src/utils/bx-exposed.ts +++ b/src/utils/bx-exposed.ts @@ -2,7 +2,6 @@ import { ControllerShortcut } from "@/modules/controller-shortcut"; import { BxEvent } from "@utils/bx-event"; import { STATES } from "@utils/global"; import { getPref, PrefKey } from "@utils/preferences"; -import { UserAgent } from "@utils/user-agent"; import { BxLogger } from "./bx-logger"; import { BX_FLAGS } from "./bx-flags"; @@ -24,13 +23,15 @@ export const BxExposed = { let supportedInputTypes = titleInfo.details.supportedInputTypes; - // Remove native MKB support on mobile browsers or by user's choice - if (getPref(PrefKey.NATIVE_MKB_DISABLED) || UserAgent.isMobile()) { - supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.MKB); - } else if (BX_FLAGS.ForceNativeMkbTitles.includes(titleInfo.details.productId)) { + if (BX_FLAGS.ForceNativeMkbTitles.includes(titleInfo.details.productId)) { supportedInputTypes.push(InputType.MKB); } + // Remove native MKB support on mobile browsers or by user's choice + if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') { + supportedInputTypes = supportedInputTypes.filter(i => i !== InputType.MKB); + } + titleInfo.details.hasMkbSupport = supportedInputTypes.includes(InputType.MKB); if (STATES.hasTouchSupport) { diff --git a/src/utils/bx-icon.ts b/src/utils/bx-icon.ts index dfd27b2..63b489f 100644 --- a/src/utils/bx-icon.ts +++ b/src/utils/bx-icon.ts @@ -4,8 +4,7 @@ import iconCopy from "@assets/svg/copy.svg" with { type: "text" }; import iconCursorText from "@assets/svg/cursor-text.svg" with { type: "text" }; import iconDisplay from "@assets/svg/display.svg" with { type: "text" }; import iconHome from "@assets/svg/home.svg" with { type: "text" }; -import iconMouseSettings from "@assets/svg/mouse-settings.svg" with { type: "text" }; -import iconMouse from "@assets/svg/mouse.svg" with { type: "text" }; +import iconNativeMkb from "@assets/svg/native-mkb.svg" with { type: "text" }; import iconNew from "@assets/svg/new.svg" with { type: "text" }; import iconQuestion from "@assets/svg/question.svg" with { type: "text" }; import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" }; @@ -15,6 +14,7 @@ import iconStreamStats from "@assets/svg/stream-stats.svg" with { type: "text" } import iconTrash from "@assets/svg/trash.svg" with { type: "text" }; import iconTouchControlEnable from "@assets/svg/touch-control-enable.svg" with { type: "text" }; import iconTouchControlDisable from "@assets/svg/touch-control-disable.svg" with { type: "text" }; +import iconVirtualController from "@assets/svg/virtual-controller.svg" with { type: "text" }; // Game Bar import iconCaretLeft from "@assets/svg/caret-left.svg" with { type: "text" }; @@ -39,14 +39,14 @@ export const BxIcon = { CONTROLLER: iconController, DISPLAY: iconDisplay, HOME: iconHome, - MOUSE: iconMouse, - MOUSE_SETTINGS: iconMouseSettings, + NATIVE_MKB: iconNativeMkb, NEW: iconNew, COPY: iconCopy, TRASH: iconTrash, CURSOR_TEXT: iconCursorText, QUESTION: iconQuestion, REFRESH: iconRefresh, + VIRTUAL_CONTROLLER: iconVirtualController, REMOTE_PLAY: iconRemotePlay, diff --git a/src/utils/css.ts b/src/utils/css.ts index 40c8250..d2e9903 100644 --- a/src/utils/css.ts +++ b/src/utils/css.ts @@ -19,15 +19,6 @@ button[class*=SocialEmptyCard], `; } - if (getPref(PrefKey.BLOCK_TRACKING)) { - css += ` -/* Remove Feedback button in the Guide menu */ -#gamepass-dialog-root #Home-panel button[class*=FeedbackButton] { - display: none; -} -`; - } - // Reduce animations if (getPref(PrefKey.REDUCE_ANIMATIONS)) { css += ` diff --git a/src/utils/gamepad.ts b/src/utils/gamepad.ts index c5823aa..1a4d5ef 100644 --- a/src/utils/gamepad.ts +++ b/src/utils/gamepad.ts @@ -1,4 +1,4 @@ -import { MkbHandler } from "@modules/mkb/mkb-handler"; +import { EmulatedMkbHandler } from "@modules/mkb/mkb-handler"; import { PrefKey, getPref } from "@utils/preferences"; import { t } from "@utils/translation"; import { Toast } from "@utils/toast"; @@ -7,7 +7,7 @@ import { BxLogger } from "@utils/bx-logger"; // Show a toast when connecting/disconecting controller export function showGamepadToast(gamepad: Gamepad) { // Don't show Toast for virtual controller - if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) { + if (gamepad.id === EmulatedMkbHandler.VIRTUAL_GAMEPAD_ID) { return; } diff --git a/src/utils/html.ts b/src/utils/html.ts index a055919..a324d5a 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -1,7 +1,7 @@ import type { BxIcon } from "@utils/bx-icon"; type BxButton = { - style?: number | string; + style?: number | string | ButtonStyle; url?: string; classes?: string[]; icon?: typeof BxIcon; @@ -67,6 +67,7 @@ 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'; +ButtonStyle[ButtonStyle.TALL = 64] = 'bx-tall'; const ButtonStyleIndices = Object.keys(ButtonStyle).splice(0, Object.keys(ButtonStyle).length / 2).map(i => parseInt(i)); diff --git a/src/utils/monkey-patches.ts b/src/utils/monkey-patches.ts index 82d4b64..6dce64e 100644 --- a/src/utils/monkey-patches.ts +++ b/src/utils/monkey-patches.ts @@ -219,3 +219,44 @@ export function patchCanvasContext() { return nativeGetContext.apply(this, [contextType, contextAttributes]); } } + + +export function patchPointerLockApi() { + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get() { + return document.documentElement; + }, + }); + + HTMLElement.prototype.requestFullscreen = function(options?: FullscreenOptions): Promise { + return Promise.resolve(); + } + + let pointerLockElement: unknown = null; + Object.defineProperty(document, 'pointerLockElement', { + configurable: true, + get() { + return pointerLockElement; + }, + }); + + // const nativeRequestPointerLock = HTMLElement.prototype.requestPointerLock; + HTMLElement.prototype.requestPointerLock = function() { + pointerLockElement = document.documentElement; + window.dispatchEvent(new Event(BxEvent.POINTER_LOCK_REQUESTED)); + // document.dispatchEvent(new Event('pointerlockchange')); + + // @ts-ignore + // nativeRequestPointerLock.apply(this, arguments); + } + + // const nativeExitPointerLock = Document.prototype.exitPointerLock; + Document.prototype.exitPointerLock = function() { + pointerLockElement = null; + window.dispatchEvent(new Event(BxEvent.POINTER_LOCK_EXITED)); + // document.dispatchEvent(new Event('pointerlockchange')); + + // nativeExitPointerLock.apply(this); + } +} diff --git a/src/utils/network.ts b/src/utils/network.ts index 998a670..5101775 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -9,7 +9,6 @@ import { STATES } from "@utils/global"; import { getPreferredServerRegion } from "@utils/region"; import { GamePassCloudGallery } from "./gamepass-gallery"; import { InputType } from "./bx-exposed"; -import { UserAgent } from "./user-agent"; enum RequestType { XCLOUD = 'xcloud', @@ -441,16 +440,17 @@ class XcloudInterceptor { let overrideMkb: boolean | null = null; - if (getPref(PrefKey.NATIVE_MKB_DISABLED) || UserAgent.isMobile()) { - overrideMkb = false; - } else if (BX_FLAGS.ForceNativeMkbTitles.includes(STATES.currentStream.titleInfo!.details.productId)) { + if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'on' || BX_FLAGS.ForceNativeMkbTitles.includes(STATES.currentStream.titleInfo!.details.productId)) { overrideMkb = true; } + if (getPref(PrefKey.NATIVE_MKB_ENABLED) === 'off') { + overrideMkb = false; + } + if (overrideMkb !== null) { overrides.inputConfiguration = Object.assign(overrides.inputConfiguration, { enableMouseInput: overrideMkb, - enableAbsoluteMouse: overrideMkb, enableKeyboardInput: overrideMkb, }); } diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts index 6d1bfaf..d004ae4 100644 --- a/src/utils/preferences.ts +++ b/src/utils/preferences.ts @@ -1,7 +1,7 @@ import { CE } from "@utils/html"; import { SUPPORTED_LANGUAGES, t } from "@utils/translation"; import { SettingElement, SettingElementType } from "@utils/settings"; -import { UserAgentProfile } from "@utils/user-agent"; +import { UserAgent, UserAgentProfile } from "@utils/user-agent"; import { StreamStat } from "@modules/stream/stream-stats"; import type { PreferenceSetting, PreferenceSettings } from "@/types/preferences"; import { AppInterface, STATES } from "@utils/global"; @@ -44,7 +44,10 @@ export enum PrefKey { CONTROLLER_DEVICE_VIBRATION = 'controller_device_vibration', CONTROLLER_VIBRATION_INTENSITY = 'controller_vibration_intensity', - NATIVE_MKB_DISABLED = 'native_mkb_disabled', + NATIVE_MKB_ENABLED = 'native_mkb_enabled', + NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY = 'native_mkb_scroll_x_sensitivity', + NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY = 'native_mkb_scroll_y_sensitivity', + MKB_ENABLED = 'mkb_enabled', MKB_HIDE_IDLE_CURSOR = 'mkb_hide_idle_cursor', MKB_ABSOLUTE_MOUSE = 'mkb_absolute_mouse', @@ -337,8 +340,6 @@ export class Preferences { } else { return (value / (1024 * 1000)).toFixed(1) + ' Mb/s'; } - - return null; }, }, migrate: function(savedPrefs: any, value: any) { @@ -436,9 +437,64 @@ export class Preferences { }, }, - [PrefKey.NATIVE_MKB_DISABLED]: { - label: t('disable-native-mkb'), - default: false, + [PrefKey.NATIVE_MKB_ENABLED]: { + label: t('native-mkb'), + default: 'default', + options: { + default: t('default'), + on: t('on'), + off: t('off'), + }, + ready: (setting: PreferenceSetting) => { + if (AppInterface) { + + } else if (UserAgent.isMobile()) { + setting.unsupported = true; + setting.default = 'off'; + delete setting.options!['default']; + delete setting.options!['on']; + } else { + delete setting.options!['on']; + } + }, + }, + + [PrefKey.NATIVE_MKB_SCROLL_HORIZONTAL_SENSITIVITY]: { + label: t('horizontal-scroll-sensitivity'), + type: SettingElementType.NUMBER_STEPPER, + default: 0, + min: 0, + max: 100 * 100, + steps: 10, + params: { + exactTicks: 20 * 100, + customTextValue: (value: any) => { + if (!value) { + return t('default'); + } + + return (value / 100).toFixed(1) + 'x'; + }, + }, + }, + + [PrefKey.NATIVE_MKB_SCROLL_VERTICAL_SENSITIVITY]: { + label: t('vertical-scroll-sensitivity'), + type: SettingElementType.NUMBER_STEPPER, + default: 0, + min: 0, + max: 100 * 100, + steps: 10, + params: { + exactTicks: 20 * 100, + customTextValue: (value: any) => { + if (!value) { + return t('default'); + } + + return (value / 100).toFixed(1) + 'x'; + }, + }, }, [PrefKey.MKB_DEFAULT_PRESET_ID]: { diff --git a/src/utils/translation.ts b/src/utils/translation.ts index 5995b96..335829e 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -4,8 +4,8 @@ export const SUPPORTED_LANGUAGES = { 'en-US': 'English (United States)', 'ca-CA': 'Català', - 'en-ID': 'Bahasa Indonesia', 'de-DE': 'Deutsch', + 'en-ID': 'Bahasa Indonesia', 'es-ES': 'español (España)', 'fr-FR': 'français', 'it-IT': 'italiano', @@ -52,6 +52,7 @@ const Texts = { "clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON", "clear": "Clear", "close": "Close", + "close-app": "Close app", "combine-audio-video-streams": "Combine audio & video streams", "combine-audio-video-streams-summary": "May fix the laggy audio problem", "conditional-formatting": "Conditional formatting text color", @@ -77,7 +78,6 @@ const Texts = { "device-vibration-not-using-gamepad": "On when not using gamepad", "disable": "Disable", "disable-home-context-menu": "Disable context menu in Home page", - "disable-native-mkb": "Disable native Mouse & Keyboard feature", "disable-post-stream-feedback-dialog": "Disable post-stream feedback dialog", "disable-social-features": "Disable social features", "disable-xcloud-analytics": "Disable xCloud analytics", @@ -106,7 +106,9 @@ const Texts = { "hide-scrollbar": "Hide web page's scrollbar", "hide-system-menu-icon": "Hide System menu's icon", "hide-touch-controller": "Hide touch controller", + "horizontal-scroll-sensitivity": "Horizontal scroll sensitivity", "horizontal-sensitivity": "Horizontal sensitivity", + "ignore": "Ignore", "import": "Import", "increase": "Increase", "install-android": "Install Better xCloud app for Android", @@ -125,8 +127,10 @@ const Texts = { "mkb-click-to-activate": "Click to activate", "mkb-disclaimer": "Using this feature when playing online could be viewed as cheating", "mouse-and-keyboard": "Mouse & Keyboard", + "mouse-wheel": "Mouse wheel", "muted": "Muted", "name": "Name", + "native-mkb": "Native Mouse & Keyboard", "new": "New", "no-consoles-found": "No consoles found", "normal": "Normal", @@ -144,23 +148,23 @@ const Texts = { "preset": "Preset", "press-esc-to-cancel": "Press Esc to cancel", "press-key-to-toggle-mkb": [ - (e: any) => `Press ${e.key} to toggle the Mouse and Keyboard feature`, - (e: any) => `Premeu ${e.key} per alternar la funció de teclat i ratolí`, - (e: any) => `${e.key}: Maus- und Tastaturunterstützung an-/ausschalten`, - (e: any) => `Tekan ${e.key} untuk mengaktifkan fitur Mouse dan Keyboard`, - (e: any) => `Pulsa ${e.key} para activar la función de ratón y teclado`, - (e: any) => `Appuyez sur ${e.key} pour activer/désactiver la fonction Souris et Clavier`, - (e: any) => `Premi ${e.key} per attivare o disattivare la funzione Mouse e Tastiera`, - (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) => `Press ${e.key} to toggle this feature`, + , + (e: any) => `${e.key}: Funktion an-/ausschalten`, + , + , + (e: any) => `Appuyez sur ${e.key} pour activer cette fonctionnalité`, + (e: any) => `Premi ${e.key} per attivare questa funzionalità`, + (e: any) => `${e.key} でこの機能を切替`, + , + , + , + , + , + (e: any) => `Etkinleştirmek için ${e.key} tuşuna basın`, + (e: any) => `Натисніть ${e.key} щоб перемкнути цю функцію`, + (e: any) => `Nhấn ${e.key} để bật/tắt tính năng này`, , - (e: any) => `Klavye ve fare özelliğini açmak için ${e.key} tuşuna basın`, - (e: any) => `Натисніть ${e.key}, щоб увімкнути або вимкнути функцію миші та клавіатури`, - (e: any) => `Nhấn ${e.key} để bật/tắt tính năng Chuột và Bàn phím`, - (e: any) => `按下 ${e.key} 切换键鼠模式`, ], "press-to-bind": "Press a key or do a mouse click to bind...", "prompt-preset-name": "Preset's name:", @@ -261,10 +265,12 @@ const Texts = { "unmuted": "Unmuted", "use-mouse-absolute-position": "Use mouse's absolute position", "user-agent-profile": "User-Agent profile", + "vertical-scroll-sensitivity": "Vertical scroll sensitivity", "vertical-sensitivity": "Vertical sensitivity", "vibration-intensity": "Vibration intensity", "vibration-status": "Vibration", "video": "Video", + "virtual-controller": "Virtual controller", "visual-quality": "Visual quality", "visual-quality-high": "High", "visual-quality-low": "Low",