Squashed commit of the following:

commit 2faed50e5c2165647e389d794de673038d56241e
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 18:09:25 2024 +0700

    Make shortcuts work with controller

commit b8f6c503ba7969de3a232644d3f6b53532a4b7bb
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 17:01:15 2024 +0700

    Update translations

commit 6f6c0899e5a09cd5534e06a9e272bf78c74536dc
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 17:00:50 2024 +0700

    Preload PrompFont

commit 1bf0f2b9dae77890d35091bed970b942c4d61fbc
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Fri May 24 07:08:05 2024 +0700

    Render Controller shortcuts settings

commit 2f24965c73a941be2ebc8a3509dc540a47b4e38d
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Thu May 23 17:21:55 2024 +0700

    Fix not able to capture screenshot after switching games

commit 6ac791e2dfb17215ee82d449047d0cd11d185c42
Author: redphx <96280+redphx@users.noreply.github.com>
Date:   Thu May 23 17:11:19 2024 +0700

    Hijack the Home button
This commit is contained in:
redphx
2024-05-24 18:10:38 +07:00
parent 22e29e1d92
commit 07c1757237
17 changed files with 606 additions and 58 deletions

View File

@@ -0,0 +1,321 @@
import { Screenshot } from "@utils/screenshot";
import { GamepadKey } from "./mkb/definitions";
import { PrompFont } from "@utils/prompt-font";
import { CE } from "@utils/html";
import { t } from "@utils/translation";
import { PrefKey, getPref } from "@/utils/preferences";
import { AppInterface } from "@/utils/global";
import { MkbHandler } from "./mkb/mkb-handler";
import { StreamStat, StreamStats } from "./stream/stream-stats";
import { MicrophoneShortcut } from "./shortcuts/shortcut-microphone";
import { StreamUiShortcut } from "./shortcuts/shortcut-stream-ui";
enum ShortcutAction {
STREAM_SCREENSHOT_CAPTURE = 'stream-screenshot-capture',
STREAM_MENU_TOGGLE = 'stream-menu-toggle',
STREAM_STATS_TOGGLE = 'stream-stats-toggle',
STREAM_SOUND_TOGGLE = 'stream-sound-toggle',
STREAM_MICROPHONE_TOGGLE = 'stream-microphone-toggle',
STREAM_VOLUME_INC = 'stream-volume-inc',
STREAM_VOLUME_DEC = 'stream-volume-dec',
DEVICE_VOLUME_INC = 'device-volume-inc',
DEVICE_VOLUME_DEC = 'device-volume-dec',
SCREEN_BRIGHTNESS_INC = 'screen-brightness-inc',
SCREEN_BRIGHTNESS_DEC = 'screen-brightness-dec',
}
export class ControllerShortcut {
static readonly #STORAGE_KEY = 'better_xcloud_controller_shortcuts';
static #buttonsCache: {[key: string]: boolean[]} = {};
static #buttonsStatus: {[key: string]: boolean[]} = {};
static #$selectProfile: HTMLSelectElement;
static #$selectActions: Partial<{[key in GamepadKey]: HTMLSelectElement}> = {};
static #$remap: HTMLElement;
static #ACTIONS: {[key: string]: (ShortcutAction | null)[]} = {};
static reset(index: number) {
ControllerShortcut.#buttonsCache[index] = [];
ControllerShortcut.#buttonsStatus[index] = [];
}
static handle(gamepad: Gamepad): boolean {
const gamepadIndex = gamepad.index;
const actions = ControllerShortcut.#ACTIONS[gamepad.id];
if (!actions) {
return false;
}
// Move the buttons status from the previous frame to the cache
ControllerShortcut.#buttonsCache[gamepadIndex] = ControllerShortcut.#buttonsStatus[gamepadIndex].slice(0);
// Clear the buttons status
ControllerShortcut.#buttonsStatus[gamepadIndex] = [];
const pressed: boolean[] = [];
let otherButtonPressed = false;
gamepad.buttons.forEach((button, index) => {
// Only add the newly pressed button to the array (holding doesn't count)
if (button.pressed && index !== GamepadKey.HOME) {
otherButtonPressed = true;
pressed[index] = true;
// If this is newly pressed button > run action
if (actions[index] && !ControllerShortcut.#buttonsCache[gamepadIndex][index]) {
ControllerShortcut.#runAction(actions[index]!);
}
}
});
ControllerShortcut.#buttonsStatus[gamepadIndex] = pressed;
return otherButtonPressed;
}
static #runAction(action: ShortcutAction) {
switch (action) {
case ShortcutAction.STREAM_SCREENSHOT_CAPTURE:
Screenshot.takeScreenshot();
break;
case ShortcutAction.STREAM_STATS_TOGGLE:
StreamStats.toggle();
break;
case ShortcutAction.STREAM_MICROPHONE_TOGGLE:
MicrophoneShortcut.toggle();
break;
case ShortcutAction.STREAM_MENU_TOGGLE:
StreamUiShortcut.showHideStreamMenu();
break;
}
}
static #updateAction(profile: string, button: GamepadKey, action: ShortcutAction | null) {
if (!(profile in ControllerShortcut.#ACTIONS)) {
ControllerShortcut.#ACTIONS[profile] = [];
}
if (!action) {
action = null;
}
ControllerShortcut.#ACTIONS[profile][button] = action;
// Remove empty profiles
for (const key in ControllerShortcut.#ACTIONS) {
let empty = true;
for (const value of ControllerShortcut.#ACTIONS[key]) {
if (!!value) {
empty = false;
break;
}
}
if (empty) {
delete ControllerShortcut.#ACTIONS[key];
}
}
// Save to storage
window.localStorage.setItem(ControllerShortcut.#STORAGE_KEY, JSON.stringify(ControllerShortcut.#ACTIONS));
console.log(ControllerShortcut.#ACTIONS);
}
static #updateProfileList(e?: GamepadEvent) {
const $select = ControllerShortcut.#$selectProfile;
const $remap = ControllerShortcut.#$remap;
const $fragment = document.createDocumentFragment();
// Remove old profiles
while ($select.firstElementChild) {
$select.firstElementChild.remove();
}
const gamepads = navigator.getGamepads();
let hasGamepad = false;
for (const gamepad of gamepads) {
if (!gamepad || !gamepad.connected) {
continue;
}
// Ignore emulated gamepad
if (gamepad.id === MkbHandler.VIRTUAL_GAMEPAD_ID) {
continue;
}
hasGamepad = true;
const $option = CE<HTMLOptionElement>('option', {value: gamepad.id}, gamepad.id);
$fragment.appendChild($option);
}
if (hasGamepad) {
$select.appendChild($fragment);
$remap.classList.remove('bx-gone');
$select.disabled = false;
$select.selectedIndex = 0;
$select.dispatchEvent(new Event('change'));
} else {
$remap.classList.add('bx-gone');
$select.disabled = true;
const $option = CE<HTMLOptionElement>('option', {}, '---');
$fragment.appendChild($option);
$select.appendChild($fragment);
}
}
static #switchProfile(profile: string) {
let actions = ControllerShortcut.#ACTIONS[profile];
if (!actions) {
actions = [];
}
// Reset selects' values
let button: any;
for (button in ControllerShortcut.#$selectActions) {
const $select = ControllerShortcut.#$selectActions[button as GamepadKey]!;
$select.value = actions[button] || '';
}
}
static renderSettings() {
// Read actions from localStorage
ControllerShortcut.#ACTIONS = JSON.parse(window.localStorage.getItem(ControllerShortcut.#STORAGE_KEY) || '{}');
const buttons = {
[GamepadKey.A]: PrompFont.A,
[GamepadKey.B]: PrompFont.B,
[GamepadKey.X]: PrompFont.X,
[GamepadKey.Y]: PrompFont.Y,
[GamepadKey.LB]: PrompFont.LB,
[GamepadKey.RB]: PrompFont.RB,
[GamepadKey.LT]: PrompFont.LT,
[GamepadKey.RT]: PrompFont.RT,
[GamepadKey.SELECT]: PrompFont.SELECT,
[GamepadKey.START]: PrompFont.START,
[GamepadKey.UP]: PrompFont.UP,
[GamepadKey.DOWN]: PrompFont.DOWN,
[GamepadKey.LEFT]: PrompFont.LEFT,
[GamepadKey.RIGHT]: PrompFont.RIGHT,
};
const actions: {[key: string]: Partial<{[key in ShortcutAction]: string | string[]}>} = {
/*
[t('device')]: AppInterface && {
[ShortcutAction.DEVICE_VOLUME_INC]: [t('device'), t('volume'), t('increase')],
[ShortcutAction.DEVICE_VOLUME_DEC]: [t('device'), t('volume'), t('decrease')],
[ShortcutAction.SCREEN_BRIGHTNESS_INC]: [t('screen'), t('brightness'), t('increase')],
[ShortcutAction.SCREEN_BRIGHTNESS_DEC]: [t('screen'), t('brightness'), t('decrease')],
},
*/
[t('stream')]: {
[ShortcutAction.STREAM_SCREENSHOT_CAPTURE]: [t('stream'), t('take-screenshot')],
[ShortcutAction.STREAM_STATS_TOGGLE]: [t('stream'), t('stats'), t('show-hide')],
[ShortcutAction.STREAM_MICROPHONE_TOGGLE]: [t('stream'), t('microphone'), t('toggle')],
[ShortcutAction.STREAM_MENU_TOGGLE]: [t('stream'), t('menu'), t('show')],
// [ShortcutAction.STREAM_SOUND_TOGGLE]: [t('stream'), t('sound'), t('toggle')],
// [ShortcutAction.STREAM_VOLUME_INC]: getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && [t('stream'), t('volume'), t('increase')],
// [ShortcutAction.STREAM_VOLUME_DEC]: getPref(PrefKey.AUDIO_ENABLE_VOLUME_CONTROL) && [t('stream'), t('volume'), t('decrease')],
}
};
const $baseSelect = CE<HTMLSelectElement>('select', {autocomplete: 'off'}, CE('option', {value: ''}, '---'));
for (const groupLabel in actions) {
const items = actions[groupLabel];
if (!items) {
continue;
}
const $optGroup = CE<HTMLOptGroupElement>('optgroup', {'label': groupLabel});
for (const action in items) {
let label = items[action as keyof typeof items];
if (!label) {
continue;
}
if (Array.isArray(label)) {
label = label.join(' > ');
}
const $option = CE<HTMLOptionElement>('option', {value: action}, label);
$optGroup.appendChild($option);
}
$baseSelect.appendChild($optGroup);
}
const $container = CE('div', {});
const $profile = CE<HTMLSelectElement>('select', {'class': 'bx-shortcut-profile', autocomplete: 'off'});
$profile.addEventListener('change', e => {
ControllerShortcut.#switchProfile($profile.value);
});
$container.appendChild($profile);
const onActionChanged = (e: Event) => {
const $target = e.target as HTMLSelectElement;
const profile = $profile.value;
const button: unknown = $target.dataset.button;
const action = $target.value as ShortcutAction;
ControllerShortcut.#updateAction(profile, button as GamepadKey, action);
};
const $remap = CE('div', {'class': 'bx-gone'});
let button: keyof typeof buttons;
// @ts-ignore
for (button in buttons) {
const $row = CE('div', {'class': 'bx-shortcut-row'});
const prompt = buttons[button];
const $label = CE('label', {'class': 'bx-prompt'}, `${PrompFont.HOME} + ${prompt}`);
const $select = $baseSelect.cloneNode(true) as HTMLSelectElement;
$select.dataset.button = button.toString();
$select.addEventListener('change', onActionChanged);
ControllerShortcut.#$selectActions[button] = $select;
$row.appendChild($label);
$row.appendChild($select);
$remap.appendChild($row);
}
$container.appendChild($remap);
ControllerShortcut.#$selectProfile = $profile;
ControllerShortcut.#$remap = $remap;
// Detect when gamepad connected/disconnect
window.addEventListener('gamepadconnected', ControllerShortcut.#updateProfileList);
window.addEventListener('gamepaddisconnected', ControllerShortcut.#updateProfileList);
ControllerShortcut.#updateProfileList();
return $container;
}
}

View File

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

View File

@@ -1,4 +1,5 @@
import type { GamepadKeyNameType } from "@/types/mkb";
import { PrompFont } from "@/utils/prompt-font";
export enum GamepadKey {
A = 0,
@@ -18,6 +19,7 @@ export enum GamepadKey {
LEFT = 14,
RIGHT = 15,
HOME = 16,
SHARE = 17,
LS_UP = 100,
LS_DOWN = 101,
@@ -32,36 +34,36 @@ export enum GamepadKey {
export const GamepadKeyName: GamepadKeyNameType = {
[GamepadKey.A]: ['A', '⇓'],
[GamepadKey.B]: ['B', '⇒'],
[GamepadKey.X]: ['X', '⇐'],
[GamepadKey.Y]: ['Y', '⇑'],
[GamepadKey.A]: ['A', PrompFont.A],
[GamepadKey.B]: ['B', PrompFont.B],
[GamepadKey.X]: ['X', PrompFont.X],
[GamepadKey.Y]: ['Y', PrompFont.Y],
[GamepadKey.LB]: ['LB', '↘'],
[GamepadKey.RB]: ['RB', '↙'],
[GamepadKey.LT]: ['LT', '↖'],
[GamepadKey.RT]: ['RT', '↗'],
[GamepadKey.LB]: ['LB', PrompFont.LB],
[GamepadKey.RB]: ['RB', PrompFont.RB],
[GamepadKey.LT]: ['LT', PrompFont.LT],
[GamepadKey.RT]: ['RT', PrompFont.RT],
[GamepadKey.SELECT]: ['Select', '⇺'],
[GamepadKey.START]: ['Start', '⇻'],
[GamepadKey.HOME]: ['Home', ''],
[GamepadKey.SELECT]: ['Select', PrompFont.SELECT],
[GamepadKey.START]: ['Start', PrompFont.START],
[GamepadKey.HOME]: ['Home', PrompFont.HOME],
[GamepadKey.UP]: ['D-Pad Up', '≻'],
[GamepadKey.DOWN]: ['D-Pad Down', '≽'],
[GamepadKey.LEFT]: ['D-Pad Left', '≺'],
[GamepadKey.RIGHT]: ['D-Pad Right', '≼'],
[GamepadKey.UP]: ['D-Pad Up', PrompFont.UP],
[GamepadKey.DOWN]: ['D-Pad Down', PrompFont.DOWN],
[GamepadKey.LEFT]: ['D-Pad Left', PrompFont.LEFT],
[GamepadKey.RIGHT]: ['D-Pad Right', PrompFont.RIGHT],
[GamepadKey.L3]: ['L3', '↺'],
[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.L3]: ['L3', PrompFont.L3],
[GamepadKey.LS_UP]: ['Left Stick Up', PrompFont.LS_UP],
[GamepadKey.LS_DOWN]: ['Left Stick Down', PrompFont.LS_DOWN],
[GamepadKey.LS_LEFT]: ['Left Stick Left', PrompFont.LS_LEFT],
[GamepadKey.LS_RIGHT]: ['Left Stick Right', PrompFont.LS_RIGHT],
[GamepadKey.R3]: ['R3', '↻'],
[GamepadKey.RS_UP]: ['Right Stick Up', '↿'],
[GamepadKey.RS_DOWN]: ['Right Stick Down', '⇃'],
[GamepadKey.RS_LEFT]: ['Right Stick Left', '↽'],
[GamepadKey.RS_RIGHT]: ['Right Stick Right', '⇁'],
[GamepadKey.R3]: ['R3', PrompFont.R3],
[GamepadKey.RS_UP]: ['Right Stick Up', PrompFont.RS_UP],
[GamepadKey.RS_DOWN]: ['Right Stick Down', PrompFont.RS_DOWN],
[GamepadKey.RS_LEFT]: ['Right Stick Left', PrompFont.RS_LEFT],
[GamepadKey.RS_RIGHT]: ['Right Stick Right', PrompFont.RS_RIGHT],
};

View File

@@ -384,13 +384,19 @@ if (!!window.BX_REMOTE_PLAY_CONFIG) {
return false;
}
// Restore the "..." button
str = str.replace(text, 'e.guideUI = null;' + text);
let newCode = `
// Expose onShowStreamMenu
window.BX_EXPOSED.showStreamMenu = e.onShowStreamMenu;
// Restore the "..." button
e.guideUI = null;
`;
// Remove the TAK Edit button when the touch controller is disabled
if (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) === 'off') {
str = str.replace(text, 'e.canShowTakHUD = false;' + text);
newCode += 'e.canShowTakHUD = false;';
}
str = str.replace(text, newCode + text);
return str;
},

View File

@@ -1,5 +1,87 @@
const currentGamepad = ${gamepadVar};
// Share button on XS controller
if (currentGamepad.buttons[17] && currentGamepad.buttons[17].value === 1) {
window.dispatchEvent(new Event(BxEvent.CAPTURE_SCREENSHOT));
}
const btnHome = currentGamepad.buttons[16];
if (btnHome) {
if (!this.bxHomeStates) {
this.bxHomeStates = {};
}
if (btnHome.pressed) {
this.gamepadIsIdle.set(currentGamepad.index, false);
if (this.bxHomeStates[currentGamepad.index]) {
const lastTimestamp = this.bxHomeStates[currentGamepad.index].timestamp;
if (currentGamepad.timestamp !== lastTimestamp) {
this.bxHomeStates[currentGamepad.index].timestamp = currentGamepad.timestamp;
const handled = window.BX_EXPOSED.handleControllerShortcut(currentGamepad);
if (handled) {
this.bxHomeStates[currentGamepad.index].shortcutPressed += 1;
}
}
} else {
// First time pressing > save current timestamp
window.BX_EXPOSED.resetControllerShortcut(currentGamepad.index);
this.bxHomeStates[currentGamepad.index] = {
shortcutPressed: 0,
timestamp: currentGamepad.timestamp,
};
}
// Listen to next button press
const intervalMs = 50;
this.inputConfiguration.useIntervalWorkerThreadForInput && this.intervalWorker ? this.intervalWorker.scheduleTimer(intervalMs) : this.pollGamepadssetTimeoutTimerID = setTimeout(this.pollGamepads, intervalMs);
// Hijack this button
return;
} else if (this.bxHomeStates[currentGamepad.index]) {
const info = structuredClone(this.bxHomeStates[currentGamepad.index]);
// Home button released
this.bxHomeStates[currentGamepad.index] = null;
if (info.shortcutPressed === 0) {
const fakeGamepadMappings = [{
GamepadIndex: 0,
A: 0,
B: 0,
X: 0,
Y: 0,
LeftShoulder: 0,
RightShoulder: 0,
LeftTrigger: 0,
RightTrigger: 0,
View: 0,
Menu: 0,
LeftThumb: 0,
RightThumb: 0,
DPadUp: 0,
DPadDown: 0,
DPadLeft: 0,
DPadRight: 0,
Nexus: 1,
LeftThumbXAxis: 0,
LeftThumbYAxis: 0,
RightThumbXAxis: 0,
RightThumbYAxis: 0,
PhysicalPhysicality: 0,
VirtualPhysicality: 0,
Dirty: true,
Virtual: false,
}];
const isLongPress = (currentGamepad.timestamp - info.timestamp) >= 500;
const intervalMs = isLongPress ? 500 : 100;
this.inputSink.onGamepadInput(performance.now() - intervalMs, fakeGamepadMappings);
this.inputConfiguration.useIntervalWorkerThreadForInput && this.intervalWorker ? this.intervalWorker.scheduleTimer(intervalMs) : this.pollGamepadssetTimeoutTimerID = setTimeout(this.pollGamepads, intervalMs);
return;
}
}
}

View File

@@ -0,0 +1,24 @@
import { t } from "@utils/translation";
import { Toast } from "@utils/toast";
export class MicrophoneShortcut {
static toggle(showToast: boolean = true): boolean {
if (!window.BX_EXPOSED.streamSession) {
return false;
}
const state = window.BX_EXPOSED.streamSession._microphoneState;
const enableMic = state === 'Enabled' ? false : true;
try {
window.BX_EXPOSED.streamSession.tryEnableChatAsync(enableMic);
showToast && Toast.show(t('microphone'), t(enableMic ? 'unmuted': 'muted'), {instant: true});
return enableMic;
} catch (e) {
console.log(e);
}
return false;
}
}

View File

@@ -0,0 +1,6 @@
export class StreamUiShortcut {
static showHideStreamMenu() {
// Show menu
window.BX_EXPOSED.showStreamMenu && window.BX_EXPOSED.showStreamMenu();
}
}

View File

@@ -210,7 +210,7 @@ export function injectStreamMenuButtons() {
// Create Stream Settings button
if (!$btnStreamSettings) {
$btnStreamSettings = cloneStreamHudButton($orgButton, t('menu-stream-settings'), BxIcon.STREAM_SETTINGS);
$btnStreamSettings = cloneStreamHudButton($orgButton, t('stream-settings'), BxIcon.STREAM_SETTINGS);
$btnStreamSettings.addEventListener('click', e => {
hideGripHandle();
e.preventDefault();
@@ -228,7 +228,7 @@ export function injectStreamMenuButtons() {
// Create Stream Stats button
if (!$btnStreamStats) {
$btnStreamStats = cloneStreamHudButton($orgButton, t('menu-stream-stats'), BxIcon.STREAM_STATS);
$btnStreamStats = cloneStreamHudButton($orgButton, t('stream-stats'), BxIcon.STREAM_STATS);
$btnStreamStats.addEventListener('click', e => {
hideGripHandle();
e.preventDefault();

View File

@@ -10,6 +10,7 @@ import { TouchController } from "@modules/touch-controller";
import { t } from "@utils/translation";
import { VibrationManager } from "@modules/vibration-manager";
import { Screenshot } from "@/utils/screenshot";
import { ControllerShortcut } from "../controller-shortcut";
export function localRedirect(path: string) {
@@ -239,13 +240,25 @@ function setupQuickSettingsBar() {
],
},
{
icon: BxIcon.COMMAND,
group: 'shortcuts',
items: [
{
group: 'shortcuts_controller',
label: t('controller-shortcuts'),
content: ControllerShortcut.renderSettings(),
},
],
},
{
icon: BxIcon.STREAM_STATS,
group: 'stats',
items: [
{
group: 'stats',
label: t('menu-stream-stats'),
label: t('stream-stats'),
help_url: 'https://better-xcloud.github.io/stream-stats/',
items: [
{
@@ -490,9 +503,24 @@ function resizeVideoPlayer() {
}
function preloadFonts() {
const $link = CE<HTMLLinkElement>('link', {
rel: 'preload',
href: 'https://redphx.github.io/better-xcloud/fonts/promptfont.otf',
as: 'font',
type: 'font/otf',
crossorigin: '',
});
document.querySelector('head')?.appendChild($link);
}
export function setupStreamUi() {
// Prevent initializing multiple times
if (!document.querySelector('.bx-quick-settings-bar')) {
preloadFonts();
window.addEventListener('resize', updateVideoPlayerCss);
setupQuickSettingsBar();
StreamStats.render();