* Fix games with custom touch control sometimes not showing touch icon

* Create game-bar with screenshot button

* Disable Game bar when opening the Guide

* Remove SCREENSHOT_BUTTON_POSITION pref

* Make the touch control action functional

* Show game bar when the game starts

* Fix 720p/High not working (#387)

* Update icons

* Update game bar's animations

* Reset states of Game bar actions before playing

* Don't show Touch control action on non-touch-supported devices

* Clean up

* Update translations

* Update actions' texts

* Clean up
This commit is contained in:
redphx
2024-05-10 18:35:40 +07:00
committed by GitHub
parent b66ca192b2
commit b2e932cc4c
23 changed files with 533 additions and 315 deletions

View File

@@ -0,0 +1,6 @@
export abstract class BaseGameBarAction {
constructor() {}
reset() {}
abstract render(): HTMLElement;
}

View File

@@ -0,0 +1,78 @@
import { BxEvent } from "@utils/bx-event";
import { AppInterface, STATES } from "@utils/global";
import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { BaseGameBarAction } from "./action-base";
import { t } from "@utils/translation";
export class ScreenshotAction extends BaseGameBarAction {
$content: HTMLElement;
constructor() {
super();
const currentStream = STATES.currentStream;
currentStream.$screenshotCanvas = CE('canvas', {'class': 'bx-gone'});
document.documentElement.appendChild(currentStream.$screenshotCanvas!);
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
this.takeScreenshot();
};
this.$content = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.SCREENSHOT,
title: t('take-screenshot'),
onClick: onClick,
});
}
render(): HTMLElement {
return this.$content;
}
takeScreenshot(callback?: any) {
const currentStream = STATES.currentStream;
const $video = currentStream.$video;
const $canvas = currentStream.$screenshotCanvas;
if (!$video || !$canvas) {
return;
}
const $canvasContext = $canvas.getContext('2d', {
alpha: false,
willReadFrequently: false,
})!;
$canvasContext.drawImage($video, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app
if (AppInterface) {
const data = $canvas.toDataURL('image/png').split(';base64,')[1];
AppInterface.saveScreenshot(currentStream.titleId, data);
// Free screenshot from memory
$canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
return;
}
$canvas && $canvas.toBlob(blob => {
// Download screenshot
const now = +new Date;
const $anchor = CE<HTMLAnchorElement>('a', {
'download': `${currentStream.titleId}-${now}.png`,
'href': URL.createObjectURL(blob!),
});
$anchor.click();
// Free screenshot from memory
URL.revokeObjectURL($anchor.href);
$canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
}, 'image/png');
}
}

View File

@@ -0,0 +1,51 @@
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { TouchController } from "@modules/touch-controller";
import { BaseGameBarAction } from "./action-base";
import { t } from "@utils/translation";
export class TouchControlAction extends BaseGameBarAction {
$content: HTMLElement;
constructor() {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
const $parent = (e as any).target.closest('div[data-enabled]');
let enabled = $parent.getAttribute('data-enabled', 'true') === 'true';
$parent.setAttribute('data-enabled', (!enabled).toString());
TouchController.toggleVisibility(enabled);
};
const $btnEnable = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.TOUCH_CONTROL_ENABLE,
title: t('show-touch-controller'),
onClick: onClick,
});
const $btnDisable = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.TOUCH_CONTROL_DISABLE,
title: t('hide-touch-controller'),
onClick: onClick,
});
this.$content = CE('div', {'data-enabled': 'true'},
$btnEnable,
$btnDisable,
);
}
render(): HTMLElement {
return this.$content;
}
reset(): void {
this.$content.setAttribute('data-enabled', 'true');
}
}

View File

@@ -0,0 +1,116 @@
import { CE, createSvgIcon } from "@utils/html";
import { ScreenshotAction } from "./action-screenshot";
import { TouchControlAction } from "./action-touch-control";
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import type { BaseGameBarAction } from "./action-base";
import { STATES } from "@utils/global";
import { PrefKey, getPref } from "@utils/preferences";
export class GameBar {
static readonly #VISIBLE_DURATION = 2000;
static #timeout: number | null;
static #$gameBar: HTMLElement;
static #$container: HTMLElement;
static #$actions: BaseGameBarAction[] = [];
static #beginHideTimeout() {
GameBar.#clearHideTimeout();
GameBar.#timeout = window.setTimeout(() => {
GameBar.#timeout = null;
GameBar.hideBar();
}, GameBar.#VISIBLE_DURATION);
}
static #clearHideTimeout() {
GameBar.#timeout && clearTimeout(GameBar.#timeout);
GameBar.#timeout = null;
}
static enable() {
GameBar.#$gameBar && GameBar.#$gameBar.classList.remove('bx-gone');
}
static disable() {
GameBar.#$gameBar && GameBar.#$gameBar.classList.add('bx-gone');
GameBar.hideBar();
}
static showBar() {
if (!GameBar.#$container) {
return;
}
GameBar.#$container.classList.remove('bx-offscreen', 'bx-hide');
GameBar.#$container.classList.add('bx-show');
GameBar.#beginHideTimeout();
}
static hideBar() {
if (!GameBar.#$container) {
return;
}
GameBar.#$container.classList.remove('bx-show');
GameBar.#$container.classList.add('bx-hide');
}
// Reset all states
static reset() {
for (const action of GameBar.#$actions) {
action.reset();
}
}
static setup() {
let $container;
const $gameBar = CE('div', {id: 'bx-game-bar', class: 'bx-gone'},
$container = CE('div', {class: 'bx-game-bar-container bx-offscreen'}),
createSvgIcon(BxIcon.CARET_RIGHT),
);
GameBar.#$actions = [
new ScreenshotAction(),
...(STATES.hasTouchSupport && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== 'off') ? [new TouchControlAction()] : []),
];
for (const action of GameBar.#$actions) {
$container.appendChild(action.render());
}
// Toggle game bar when clicking on the game bar box
$gameBar.addEventListener('click', e => {
if (e.target === $gameBar) {
if ($container.classList.contains('bx-show')) {
GameBar.hideBar();
} else {
GameBar.showBar();
}
}
});
// Hide game bar after clicking on an action
window.addEventListener(BxEvent.GAME_BAR_ACTION_ACTIVATED, GameBar.hideBar);
$container.addEventListener('pointerover', GameBar.#clearHideTimeout);
$container.addEventListener('pointerout', GameBar.#beginHideTimeout);
// Add animation when hiding game bar
$container.addEventListener('transitionend', e => {
const classList = $container.classList;
if (classList.contains('bx-hide')) {
classList.remove('bx-offscreen', 'bx-hide');
classList.add('bx-offscreen');
}
});
document.documentElement.appendChild($gameBar);
GameBar.#$gameBar = $gameBar;
GameBar.#$container = $container;
}
}

View File

@@ -1,100 +0,0 @@
import { STATES, AppInterface } from "@utils/global";
import { CE } from "@utils/html";
export function takeScreenshot(callback: any) {
const currentStream = STATES.currentStream!;
const $video = currentStream.$video;
const $canvas = currentStream.$screenshotCanvas;
if (!$video || !$canvas) {
return;
}
const $canvasContext = $canvas.getContext('2d', {
alpha: false,
willReadFrequently: false,
})!;
$canvasContext.drawImage($video, 0, 0, $canvas.width, $canvas.height);
// Get data URL and pass to parent app
if (AppInterface) {
const data = $canvas.toDataURL('image/png').split(';base64,')[1];
AppInterface.saveScreenshot(currentStream.titleId, data);
// Free screenshot from memory
$canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
return;
}
$canvas && $canvas.toBlob(blob => {
// Download screenshot
const now = +new Date;
const $anchor = CE<HTMLAnchorElement>('a', {
'download': `${currentStream.titleId}-${now}.png`,
'href': URL.createObjectURL(blob!),
});
$anchor.click();
// Free screenshot from memory
URL.revokeObjectURL($anchor.href);
$canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
callback && callback();
}, 'image/png');
}
export function setupScreenshotButton() {
const currentStream = STATES.currentStream!
currentStream.$screenshotCanvas = CE('canvas', {'class': 'bx-screenshot-canvas'});
document.documentElement.appendChild(currentStream.$screenshotCanvas!);
const delay = 2000;
const $btn = CE('div', {'class': 'bx-screenshot-button', 'data-showing': false});
let timeout: number | null;
const detectDbClick = (e: MouseEvent) => {
if (!currentStream.$video) {
timeout = null;
$btn.style.display = 'none';
return;
}
if (timeout) {
clearTimeout(timeout);
timeout = null;
$btn.setAttribute('data-capturing', 'true');
takeScreenshot(() => {
// Hide button
$btn.setAttribute('data-showing', 'false');
window.setTimeout(() => {
if (!timeout) {
$btn.setAttribute('data-capturing', 'false');
}
}, 100);
});
return;
}
const isShowing = $btn.getAttribute('data-showing') === 'true';
if (!isShowing) {
// Show button
$btn.setAttribute('data-showing', 'true');
$btn.setAttribute('data-capturing', 'false');
timeout && clearTimeout(timeout);
timeout = window.setTimeout(() => {
timeout = null;
$btn.setAttribute('data-showing', 'false');
$btn.setAttribute('data-capturing', 'false');
}, delay);
}
}
$btn.addEventListener('mousedown', detectDbClick);
document.documentElement.appendChild($btn);
}

View File

@@ -1,5 +1,5 @@
import { STATES } from "@utils/global";
import { CE, escapeHtml } from "@utils/html";
import { escapeHtml } from "@utils/html";
import { Toast } from "@utils/toast";
import { BxEvent } from "@utils/bx-event";
import { BX_FLAGS } from "@utils/bx-flags";
@@ -12,7 +12,11 @@ const LOG_TAG = 'TouchController';
export class TouchController {
static readonly #EVENT_SHOW_DEFAULT_CONTROLLER = new MessageEvent('message', {
data: '{"content":"{\\"layoutId\\":\\"\\"}","target":"/streaming/touchcontrols/showlayoutv2","type":"Message"}',
data: JSON.stringify({
content: '{"layoutId":""}',
target: '/streaming/touchcontrols/showlayoutv2',
type: 'Message',
}),
origin: 'better-xcloud',
});
@@ -23,17 +27,17 @@ export class TouchController {
});
*/
static #$bar: HTMLElement;
static #$style: HTMLStyleElement;
static #enable = false;
static #showing = false;
static #dataChannel: RTCDataChannel | null;
static #customLayouts: {[index: string]: any} = {};
static #baseCustomLayouts: {[index: string]: any} = {};
static #currentLayoutId: string;
static #customList: string[];
static enable() {
TouchController.#enable = true;
}
@@ -48,37 +52,28 @@ export class TouchController {
static #showDefault() {
TouchController.#dispatchMessage(TouchController.#EVENT_SHOW_DEFAULT_CONTROLLER);
TouchController.#showing = true;
}
static #show() {
document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.remove('bx-offscreen');
TouchController.#showing = true;
}
static #hide() {
document.querySelector('#BabylonCanvasContainer-main')?.parentElement?.classList.add('bx-offscreen');
TouchController.#showing = false;
}
static #toggleVisibility() {
static toggleVisibility(status: boolean) {
if (!TouchController.#dataChannel) {
return;
}
TouchController.#showing ? TouchController.#hide() : TouchController.#show();
}
static #toggleBar(value: boolean) {
TouchController.#$bar && TouchController.#$bar.setAttribute('data-showing', value.toString());
status ? TouchController.#hide() : TouchController.#show();
}
static reset() {
TouchController.#enable = false;
TouchController.#showing = false;
TouchController.#dataChannel = null;
TouchController.#$bar && TouchController.#$bar.removeAttribute('data-showing');
TouchController.#$style && (TouchController.#$style.textContent = '');
}
@@ -195,15 +190,19 @@ export class TouchController {
}
static updateCustomList() {
const key = 'better_xcloud_custom_touch_layouts';
TouchController.#customList = JSON.parse(window.localStorage.getItem(key) || '[]');
NATIVE_FETCH('https://raw.githubusercontent.com/redphx/better-xcloud/gh-pages/touch-layouts/ids.json')
.then(response => response.json())
.then(json => {
window.localStorage.setItem('better_xcloud_custom_touch_layouts', JSON.stringify(json));
TouchController.#customList = json;
window.localStorage.setItem(key, JSON.stringify(json));
});
}
static getCustomList(): string[] {
return JSON.parse(window.localStorage.getItem('better_xcloud_custom_touch_layouts') || '[]');
return TouchController.#customList;
}
static setup() {
@@ -223,32 +222,9 @@ export class TouchController {
});
};
const $fragment = document.createDocumentFragment();
const $style = document.createElement('style');
$fragment.appendChild($style);
document.documentElement.appendChild($style);
const $bar = CE('div', {'id': 'bx-touch-controller-bar'});
$fragment.appendChild($bar);
document.documentElement.appendChild($fragment);
// Setup double-tap event
let clickTimeout: number | null;
$bar.addEventListener('mousedown', (e: MouseEvent) => {
clickTimeout && clearTimeout(clickTimeout);
if (clickTimeout) {
// Double-clicked
clickTimeout = null;
TouchController.#toggleVisibility();
return;
}
clickTimeout = window.setTimeout(() => {
clickTimeout = null;
}, 400);
});
TouchController.#$bar = $bar;
TouchController.#$style = $style;
const PREF_STYLE_STANDARD = getPref(PrefKey.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD);
@@ -307,7 +283,6 @@ export class TouchController {
try {
if (msg.data.includes('/titleinfo')) {
const json = JSON.parse(JSON.parse(msg.data).content);
TouchController.#toggleBar(json.focused);
focused = json.focused;
if (!json.focused) {

View File

@@ -31,7 +31,6 @@ const SETTINGS_UI = {
PrefKey.AUDIO_MIC_ON_PLAYING,
PrefKey.STREAM_DISABLE_FEEDBACK_DIALOG,
PrefKey.SCREENSHOT_BUTTON_POSITION,
PrefKey.SCREENSHOT_APPLY_FILTERS,
PrefKey.AUDIO_ENABLE_VOLUME_CONTROL,

View File

@@ -5,11 +5,11 @@ import { UserAgent } from "@utils/user-agent";
import { BxEvent } from "@utils/bx-event";
import { MkbRemapper } from "@modules/mkb/mkb-remapper";
import { getPref, PrefKey, toPrefElement } from "@utils/preferences";
import { setupScreenshotButton } from "@modules/screenshot";
import { StreamStats } from "@modules/stream/stream-stats";
import { TouchController } from "@modules/touch-controller";
import { t } from "@utils/translation";
import { VibrationManager } from "@modules/vibration-manager";
import { GameBar } from "../game-bar/game-bar";
export function localRedirect(path: string) {
@@ -468,13 +468,14 @@ div[data-testid="media-container"] {
$elm.textContent = css;
}
export function setupBxUi() {
export function setupStreamUi() {
// Prevent initializing multiple times
if (!document.querySelector('.bx-quick-settings-bar')) {
window.addEventListener('resize', updateVideoPlayerCss);
setupQuickSettingsBar();
setupScreenshotButton();
StreamStats.render();
GameBar.setup();
}
updateVideoPlayerCss();