Compare commits

...

24 Commits

Author SHA1 Message Date
15b7869e5d Bump version to 5.7.0 2024-09-04 20:53:37 +07:00
2ed4e23c87 Update better-xcloud.user.js 2024-09-04 20:19:38 +07:00
e952bf07c8 Fix problem with "|" character in game title 2024-09-04 20:19:31 +07:00
8d44dab04d Update better-xcloud.user.js 2024-09-04 19:45:02 +07:00
6a792548fa Update TrueAchievements button in Guide Menu 2024-09-04 19:44:41 +07:00
29f6413306 Support suggesting boolean settings 2024-09-04 16:59:18 +07:00
53d67616c3 Fix not clearing states when quitting game while queueing 2024-09-04 16:43:39 +07:00
03ad02bd4d Don't show the "Close app" button in Guide Menu when playing 2024-09-04 16:42:52 +07:00
110106aa97 Update better-xcloud.user.js 2024-09-04 07:31:40 +07:00
7310700dbb Add button to download wallpapers in app 2024-09-03 19:56:34 +07:00
5a0ef88237 Update better-xcloud.user.js 2024-09-03 16:57:17 +07:00
a6e358479a Integrate TrueAchievements 2024-09-03 16:56:58 +07:00
4b02fec8ac Update better-xcloud.user.js 2024-09-03 16:50:32 +07:00
93e3f1fa49 Update better-xcloud.user.js 2024-09-03 10:19:43 +07:00
ae9a1a68d4 Update better-xcloud.user.js 2024-09-02 21:25:14 +07:00
adf6b05c10 Update better-xcloud.user.js 2024-09-02 21:18:32 +07:00
e0489d30bb Update better-xcloud.user.js 2024-09-02 20:22:08 +07:00
9f46eca956 Minify SVG in generated JS 2024-09-02 14:57:03 +07:00
4888c399f0 Upgrade bun 2024-09-02 10:44:36 +07:00
e372db8dd9 Update better-xcloud.user.js 2024-08-31 19:03:58 +07:00
5ba4a669e6 Compress Loading Screen's CSS 2024-08-31 19:02:36 +07:00
26b28564cc Optimize Guide Menu's buttons 2024-08-31 17:03:42 +07:00
ad0be634d2 Update better-xcloud.user.js 2024-08-31 10:25:58 +07:00
6f460302cf Fix Game Bar not showing sometimes 2024-08-31 09:57:49 +07:00
35 changed files with 803 additions and 318 deletions

View File

@ -35,6 +35,13 @@ const postProcess = (str: string): string => {
// Add ADDITIONAL CODE block
str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS');
// Minify SVG
str = str.replaceAll(/= "(<svg.*)";/g, function(match) {
match = match.replaceAll(/\\n*\s*/g, '');
return match;
});
assert(str.includes('/* ADDITIONAL CODE */'));
assert(str.includes('window.BX_EXPOSED = BxExposed'));
assert(str.includes('window.BxEvent = BxEvent'));

BIN
bun.lockb

Binary file not shown.

View File

@ -1,5 +1,5 @@
// ==UserScript==
// @name Better xCloud
// @namespace https://github.com/redphx
// @version 5.6.1
// @version 5.7.0
// ==/UserScript==

File diff suppressed because one or more lines are too long

View File

@ -10,13 +10,13 @@
},
"devDependencies": {
"@types/bun": "^1.1.8",
"@types/node": "^20.16.2",
"@types/node": "^22.5.2",
"@types/stylus": "^0.48.42",
"eslint": "^9.9.1",
"eslint-plugin-compat": "^6.0.0",
"stylus": "^0.63.0"
},
"peerDependencies": {
"typescript": "^5.5.2"
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,19 @@
.bx-guide-home-buttons {
> div {
display: flex;
flex-direction: row;
gap: 12px;
}
&[data-is-playing="true"] {
button[data-state='normal'] {
display: none;
}
}
&[data-is-playing="false"] {
button[data-state='playing'] {
display: none;
}
}
}

View File

@ -133,6 +133,13 @@ div[class^=HUDButton-module__hiddenContainer] ~ div:not([class^=HUDButton-module
text-transform: none !important;
}
.bx-normal-link {
text-transform: none !important;
text-align: left !important;
font-weight: 400 !important;
font-family: var(--bx-normal-font) !important;
}
select[multiple] {
overflow: auto;
}

View File

@ -9,6 +9,7 @@
@import 'loading-screen.styl';
@import 'remote-play.styl';
@import 'web-components.styl';
@import 'guide-menu.styl';
@import 'stream.styl';
@import 'number-stepper.styl';

3
src/assets/svg/power.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='#fff' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M16 2.445v12.91m7.746-11.619C27.631 6.27 30.2 10.37 30.2 15.355c0 7.79-6.41 14.2-14.2 14.2s-14.2-6.41-14.2-14.2c0-4.985 2.569-9.085 6.454-11.619'/>
</svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@ -0,0 +1,3 @@
<svg xmlns='http://www.w3.org/2000/svg' fill='#fff' stroke='nons' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M2.497 14.127c.781-6.01 5.542-10.849 11.551-11.708V0C6.634.858.858 6.712 0 14.127h2.497zM17.952 2.419V0C25.366.858 31.142 6.712 32 14.127h-2.497c-.781-6.01-5.542-10.849-11.551-11.708zM2.497 17.873c.781 6.01 5.542 10.849 11.551 11.708V32C6.634 31.142.858 25.288 0 17.873h2.497zm27.006 0H32C31.142 25.288 25.366 31.142 17.952 32v-2.419c6.009-.859 10.77-5.698 11.551-11.708zm-19.2-4.527h2.028a.702.702 0 1 0 0-1.404h-2.107a1.37 1.37 0 0 1-1.326-1.327V9.21a.7.7 0 0 0-.703-.703c-.387 0-.703.316-.703.7v1.408c.079 1.483 1.25 2.731 2.811 2.731zm2.809 7.337h-2.888a1.37 1.37 0 0 1-1.326-1.327v-4.917c0-.387-.316-.703-.7-.703a.7.7 0 0 0-.706.703v4.917a2.77 2.77 0 0 0 2.732 2.732h2.81c.387 0 .702-.316.702-.7.078-.393-.234-.705-.624-.705zM25.6 19.2a.7.7 0 0 0-.702-.702c-.387 0-.703.316-.703.699v.081c0 .702-.546 1.326-1.248 1.326H19.98c-.702-.078-1.248-.624-1.248-1.326v-.312c0-.78.624-1.327 1.326-1.327h2.811a2.77 2.77 0 0 0 2.731-2.732v-.312a2.68 2.68 0 0 0-2.576-2.732h-4.76a.702.702 0 1 0 0 1.405h4.526a1.37 1.37 0 0 1 1.327 1.327v.234c0 .781-.624 1.327-1.327 1.327h-2.81a2.77 2.77 0 0 0-2.731 2.732v.312a2.77 2.77 0 0 0 2.731 2.732h2.967a2.74 2.74 0 0 0 2.575-2.732s.078.078.078 0z'/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -14,7 +14,7 @@ import { Toast } from "@utils/toast";
import { LoadingScreen } from "@modules/loading-screen";
import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider";
import { TouchController } from "@modules/touch-controller";
import { checkForUpdate, disablePwa } from "@utils/utils";
import { checkForUpdate, disablePwa, productTitleToSlug } from "@utils/utils";
import { Patcher } from "@modules/patcher";
import { RemotePlay } from "@modules/remote-play";
import { onHistoryChanged, patchHistoryMethod } from "@utils/history";
@ -26,7 +26,7 @@ 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";
import { GuideMenu } from "./modules/ui/guide-menu";
import { updateVideoPlayer } from "./modules/stream/stream-settings-utils";
import { UiSection } from "./enums/ui-sections";
import { HeaderSection } from "./modules/ui/header";
@ -39,6 +39,7 @@ import { compressCss } from "@macros/build" with {type: "macro"};
import { SettingsNavigationDialog } from "./modules/ui/dialog/settings-dialog";
import { StreamUiHandler } from "./modules/stream/stream-ui";
import { UserAgent } from "./utils/user-agent";
import { XboxApi } from "./utils/xbox-api";
// Handle login page
@ -198,15 +199,10 @@ window.addEventListener(BxEvent.XCLOUD_SERVERS_READY, e => {
window.addEventListener(BxEvent.STREAM_LOADING, e => {
// Get title ID for screenshot's name
if (window.location.pathname.includes('/launch/')) {
const matches = /\/launch\/(?<title_id>[^\/]+)\/(?<product_id>\w+)/.exec(window.location.pathname);
if (matches?.groups) {
STATES.currentStream.titleId = matches.groups.title_id;
STATES.currentStream.productId = matches.groups.product_id;
}
if (window.location.pathname.includes('/launch/') && STATES.currentStream.titleInfo) {
STATES.currentStream.titleSlug = productTitleToSlug(STATES.currentStream.titleInfo.product.title);
} else {
STATES.currentStream.titleId = 'remote-play';
STATES.currentStream.productId = '';
STATES.currentStream.titleSlug = 'remote-play';
}
});
@ -248,15 +244,50 @@ window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => {
window.addEventListener(BxEvent.XCLOUD_RENDERING_COMPONENT, e => {
const component = (e as any).component;
if (component === 'product-details') {
ProductDetailsPage.injectShortcutButton();
ProductDetailsPage.injectButtons();
}
});
function unload() {
if (!STATES.isPlaying) {
// Detect game change
window.addEventListener(BxEvent.DATA_CHANNEL_CREATED, e => {
const dataChannel = (e as any).dataChannel;
if (!dataChannel || dataChannel.label !== 'message') {
return;
}
dataChannel.addEventListener('message', async (msg: MessageEvent) => {
if (msg.origin === 'better-xcloud' || typeof msg.data !== 'string') {
return;
}
// Get xboxTitleId from message
if (msg.data.includes('/titleinfo')) {
const json = JSON.parse(JSON.parse(msg.data).content);
const xboxTitleId = parseInt(json.titleid, 16);
STATES.currentStream.xboxTitleId = xboxTitleId;
// Get titleSlug for Remote Play
if (STATES.remotePlay.isPlaying) {
STATES.currentStream.titleSlug = 'remote-play';
if (json.focused) {
const productTitle = await XboxApi.getProductTitle(xboxTitleId);
if (productTitle) {
STATES.currentStream.titleSlug = productTitleToSlug(productTitle);
}
}
}
}
});
});
function unload() {
if (!STATES.isPlaying && !Object.keys(STATES.currentStream).length) {
return;
}
STATES.isPlaying = false;
STATES.currentStream = {};
// Stop MKB listeners
EmulatedMkbHandler.getInstance().destroy();
NativeMkbHandler.getInstance().destroy();
@ -264,8 +295,6 @@ function unload() {
// Destroy StreamPlayer
STATES.currentStream.streamPlayer?.destroy();
STATES.isPlaying = false;
STATES.currentStream = {};
window.BX_EXPOSED.shouldShowSensorControls = false;
window.BX_EXPOSED.stopTakRendering = false;
@ -288,7 +317,7 @@ window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => {
function observeRootDialog($root: HTMLElement) {
let currentShown = false;
let beingShown = false;
const observer = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
@ -296,31 +325,20 @@ function observeRootDialog($root: HTMLElement) {
continue;
}
BX_FLAGS.Debug && BxLogger.warning('RootDialog', 'added', mutation.addedNodes);
if (mutation.addedNodes.length === 1) {
const $addedElm = mutation.addedNodes[0];
if ($addedElm instanceof HTMLElement && $addedElm.className) {
if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) {
// Make sure it's Guide dialog
if (document.querySelector('#gamepass-dialog-root div[class*=GuideDialog]')) {
// Find navigation bar
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
if ($selectedTab) {
let $elm: Element | null = $selectedTab;
let index;
for (index = 0; ($elm = $elm?.previousElementSibling); index++);
if (index === 0) {
BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
}
}
}
// Make sure it's Guide dialog
if ($root.querySelector('div[class*=GuideDialog]')) {
GuideMenu.observe($addedElm);
}
}
}
const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false;
if (shown !== currentShown) {
currentShown = shown;
const shown = !!($root.firstElementChild && $root.firstElementChild.childElementCount > 0);
if (shown !== beingShown) {
beingShown = shown;
BxEvent.dispatch(window, shown ? BxEvent.XCLOUD_DIALOG_SHOWN : BxEvent.XCLOUD_DIALOG_DISMISSED);
}
}
@ -379,7 +397,7 @@ function main() {
(getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance();
Screenshot.setup();
GuideMenu.observe();
GuideMenu.addEventListeners();
StreamBadges.setupEvents();
StreamStats.setupEvents();
EmulatedMkbHandler.setupEvents();

View File

@ -14,6 +14,6 @@ export const renderStylus = async () => {
};
export const compressCss = async (css: string) => {
return await (stylus(css, {}).set('compress', true)).render();
export const compressCss = (css: string) => {
return (stylus(css, {}).set('compress', true)).render();
};

View File

@ -15,11 +15,11 @@ export class MicrophoneAction extends BaseGameBarAction {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
const enabled = MicrophoneShortcut.toggle(false);
this.$content.setAttribute('data-enabled', enabled.toString());
};
const enabled = MicrophoneShortcut.toggle(false);
this.$content.setAttribute('data-enabled', enabled.toString());
};
const $btnDefault = createButton({
style: ButtonStyle.GHOST,

View File

@ -12,16 +12,16 @@ export class ScreenshotAction extends BaseGameBarAction {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
Screenshot.takeScreenshot();
};
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
Screenshot.takeScreenshot();
};
this.$content = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.SCREENSHOT,
title: t('take-screenshot'),
onClick: onClick,
});
style: ButtonStyle.GHOST,
icon: BxIcon.SCREENSHOT,
title: t('take-screenshot'),
onClick: onClick,
});
}
render(): HTMLElement {

View File

@ -0,0 +1,30 @@
import { BxEvent } from "@/utils/bx-event";
import { BxIcon } from "@/utils/bx-icon";
import { createButton, ButtonStyle } from "@/utils/html";
import { t } from "@/utils/translation";
import { BaseGameBarAction } from "./action-base";
import { TrueAchievements } from "@/utils/true-achievements";
export class TrueAchievementsAction extends BaseGameBarAction {
$content: HTMLElement;
constructor() {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
TrueAchievements.open(false);
};
this.$content = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.TRUE_ACHIEVEMENTS,
title: t('true-achievements'),
onClick: onClick,
});
}
render(): HTMLElement {
return this.$content;
}
}

View File

@ -8,11 +8,11 @@ import { STATES } from "@utils/global";
import { MicrophoneAction } from "./action-microphone";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, StreamTouchController } from "@/utils/settings-storages/global-settings-storage";
import { TrueAchievementsAction } from "./action-true-achievements";
export class GameBar {
private static instance: GameBar;
public static getInstance(): GameBar {
if (!GameBar.instance) {
GameBar.instance = new GameBar();
@ -43,6 +43,7 @@ export class GameBar {
this.actions = [
new ScreenshotAction(),
...(STATES.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.OFF) ? [new TouchControlAction()] : []),
new TrueAchievementsAction(),
new MicrophoneAction(),
];
@ -93,7 +94,7 @@ export class GameBar {
// Toggle Game bar
const mode = (e as any).mode;
mode !== 'None' ? this.disable() : this.enable();
mode !== 'none' ? this.disable() : this.enable();
}).bind(this));
}
@ -125,7 +126,7 @@ export class GameBar {
return;
}
this.$container.classList.remove('bx-offscreen', 'bx-hide');
this.$container.classList.remove('bx-offscreen', 'bx-hide' , 'bx-gone');
this.$container.classList.add('bx-show');
this.beginHideTimeout();

View File

@ -4,6 +4,7 @@ import { t } from "@utils/translation";
import { STATES } from "@utils/global";
import { PrefKey } from "@/enums/pref-keys";
import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { compressCss } from "@macros/build" with {type: "macro"};
export class LoadingScreen {
static #$bgStyle: HTMLElement;
@ -43,7 +44,7 @@ export class LoadingScreen {
static #hideRocket() {
let $bgStyle = LoadingScreen.#$bgStyle;
const css = `
const css = compressCss(`
#game-stream div[class*=RocketAnimation-module__container] > svg {
display: none;
}
@ -51,8 +52,8 @@ export class LoadingScreen {
#game-stream video[class*=RocketAnimationVideo-module__video] {
display: none;
}
`;
$bgStyle.textContent += css;
`);
$bgStyle.textContent! += css;
}
static #setBackground(imageUrl: string) {
@ -62,9 +63,8 @@ export class LoadingScreen {
// Limit max width to reduce image size
imageUrl = imageUrl + '?w=1920';
const css = `
const css = compressCss(`
#game-stream {
background-image: linear-gradient(#00000033, #000000e6), url(${imageUrl}) !important;
background-color: transparent !important;
background-position: center center !important;
background-repeat: no-repeat !important;
@ -74,16 +74,16 @@ export class LoadingScreen {
#game-stream rect[width="800"] {
transition: opacity 0.3s ease-in-out !important;
}
`;
$bgStyle.textContent += css;
`) + `#game-stream {background-image: linear-gradient(#00000033, #000000e6), url(${imageUrl}) !important;}`;
$bgStyle.textContent! += css;
const bg = new Image();
bg.onload = e => {
$bgStyle.textContent += `
$bgStyle.textContent += compressCss(`
#game-stream rect[width="800"] {
opacity: 0 !important;
}
`;
`);
};
bg.src = imageUrl;
}
@ -150,18 +150,18 @@ export class LoadingScreen {
if (getPref(PrefKey.UI_LOADING_SCREEN_GAME_ART) && LoadingScreen.#$bgStyle) {
const $rocketBg = document.querySelector('#game-stream rect[width="800"]');
$rocketBg && $rocketBg.addEventListener('transitionend', e => {
LoadingScreen.#$bgStyle.textContent += `
LoadingScreen.#$bgStyle.textContent += compressCss(`
#game-stream {
background: #000 !important;
}
`;
`);
});
LoadingScreen.#$bgStyle.textContent += `
LoadingScreen.#$bgStyle.textContent += compressCss(`
#game-stream rect[width="800"] {
opacity: 1 !important;
}
`;
`);
}
setTimeout(LoadingScreen.reset, 2000);

View File

@ -459,7 +459,7 @@ export class EmulatedMkbHandler extends MkbHandler {
}
const mode = (e as any).mode;
if (mode === 'None') {
if (mode === 'none') {
this.#$message.classList.remove('bx-offscreen');
} else {
this.#$message.classList.add('bx-offscreen');

View File

@ -69,7 +69,7 @@ export class NativeMkbHandler extends MkbHandler {
}
const mode = (e as any).mode;
if (mode === 'None') {
if (mode === 'none') {
this.#$message.classList.remove('bx-offscreen');
} else {
this.#$message.classList.add('bx-offscreen');

View File

@ -465,7 +465,7 @@ e.guideUI = null;
}
const newCode = `
BxEvent.dispatch(window, BxEvent.XCLOUD_POLLING_MODE_CHANGED, {mode: e});
BxEvent.dispatch(window, BxEvent.XCLOUD_POLLING_MODE_CHANGED, {mode: e.toLowerCase()});
`;
str = str.replace(text, text + newCode);
return str;

View File

@ -3,6 +3,8 @@ import { AppInterface, STATES } from "@/utils/global";
import { createButton, ButtonStyle, CE } from "@/utils/html";
import { t } from "@/utils/translation";
import { SettingsNavigationDialog } from "./dialog/settings-dialog";
import { TrueAchievements } from "@/utils/true-achievements";
import { BxIcon } from "@/utils/bx-icon";
export enum GuideMenuTab {
HOME = 'home',
@ -24,27 +26,22 @@ export class GuideMenu {
},
}),
appSettings: createButton({
label: t('app-settings'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes.closeAll();
AppInterface.openAppSettings && AppInterface.openAppSettings();
},
}),
closeApp: createButton({
label: t('close-app'),
closeApp: AppInterface && createButton({
icon: BxIcon.POWER,
title: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
attributes: {
'data-state': 'normal',
},
}),
reloadPage: createButton({
label: t('reload-page'),
icon: BxIcon.REFRESH,
title: t('reload-page'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
if (STATES.isPlaying) {
@ -59,74 +56,87 @@ export class GuideMenu {
}),
backToHome: createButton({
label: t('back-to-home'),
icon: BxIcon.HOME,
title: 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));
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes.closeAll();
},
attributes: {
'data-state': 'playing',
},
}),
}
static #renderButtons(buttons: HTMLElement[]) {
const $div = CE('div', {});
static #$renderedButtons: HTMLElement;
for (const $button of buttons) {
$div.appendChild($button);
static #renderButtons() {
if (GuideMenu.#$renderedButtons) {
return GuideMenu.#$renderedButtons;
}
const $div = CE('div', {
class: 'bx-guide-home-buttons',
});
const buttons = [
GuideMenu.#BUTTONS.scriptSettings,
[
TrueAchievements.$button,
GuideMenu.#BUTTONS.backToHome,
GuideMenu.#BUTTONS.reloadPage,
GuideMenu.#BUTTONS.closeApp,
],
];
for (const $button of buttons) {
if (!$button) {
continue;
}
if ($button instanceof HTMLElement) {
$div.appendChild($button);
} else if (Array.isArray($button)) {
const $wrapper = CE('div', {});
for (const $child of $button) {
$child && $wrapper.appendChild($child);
}
$div.appendChild($wrapper);
}
}
GuideMenu.#$renderedButtons = $div;
return $div;
}
static #injectHome($root: HTMLElement) {
// Find the last divider
const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]');
if (!$dividers) {
return;
static #injectHome($root: HTMLElement, isPlaying = false) {
// Find the element to add buttons to
let $target: HTMLElement | null = null;
if (isPlaying) {
// Quit button
$target = $root.querySelector('a[class*=QuitGameButton]');
// Hide xCloud's Home button
const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement;
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
} else {
// Last divider
const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]');
if ($dividers) {
$target = $dividers[$dividers.length - 1] as HTMLElement;
}
}
const buttons: HTMLElement[] = [];
// "Better xCloud" button
buttons.push(GuideMenu.#BUTTONS.scriptSettings);
// "App settings" button
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
// "Reload page" button
buttons.push(GuideMenu.#BUTTONS.reloadPage);
// "Close app" buttons
AppInterface && buttons.push(GuideMenu.#BUTTONS.closeApp);
const $buttons = GuideMenu.#renderButtons(buttons);
const $lastDivider = $dividers[$dividers.length - 1];
$lastDivider.insertAdjacentElement('afterend', $buttons);
}
static #injectHomePlaying($root: HTMLElement) {
const $btnQuit = $root.querySelector('a[class*=QuitGameButton]');
if (!$btnQuit) {
return;
if (!$target) {
return false;
}
const buttons: HTMLElement[] = [];
buttons.push(GuideMenu.#BUTTONS.scriptSettings);
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings);
// Reload page
buttons.push(GuideMenu.#BUTTONS.reloadPage);
// Back to home
buttons.push(GuideMenu.#BUTTONS.backToHome);
const $buttons = GuideMenu.#renderButtons(buttons);
$btnQuit.insertAdjacentElement('afterend', $buttons);
// Hide xCloud's Home button
const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement;
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
const $buttons = GuideMenu.#renderButtons();
$buttons.dataset.isPlaying = isPlaying.toString();
$target.insertAdjacentElement('afterend', $buttons);
}
static async #onShown(e: Event) {
@ -134,17 +144,39 @@ export class GuideMenu {
if (where === GuideMenuTab.HOME) {
const $root = document.querySelector('#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]') as HTMLElement;
if ($root) {
if (STATES.isPlaying) {
GuideMenu.#injectHomePlaying($root);
} else {
GuideMenu.#injectHome($root);
}
}
$root && GuideMenu.#injectHome($root, STATES.isPlaying);
}
}
static observe() {
static addEventListeners() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown);
}
static observe($addedElm: HTMLElement) {
const className = $addedElm.className;
if (!className.startsWith('NavigationAnimation') &&
!className.startsWith('DialogRoutes') &&
!className.startsWith('Dialog-module__container')) {
return;
}
// Achievement Details page
const $achievDetailPage = $addedElm.querySelector('div[class*=AchievementDetailPage]');
if ($achievDetailPage) {
TrueAchievements.injectAchievementDetailPage($achievDetailPage as HTMLElement);
return;
}
// Find navigation bar
const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true');
if ($selectedTab) {
let $elm: Element | null = $selectedTab;
let index;
for (index = 0; ($elm = $elm?.previousElementSibling); index++);
if (index === 0) {
BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
}
}
}
}

View File

@ -5,30 +5,60 @@ import { ButtonStyle, createButton } from "@/utils/html";
import { t } from "@/utils/translation";
export class ProductDetailsPage {
private static $btnShortcut = createButton({
private static $btnShortcut = AppInterface && createButton({
classes: ['bx-button-shortcut'],
icon: BxIcon.CREATE_SHORTCUT,
label: t('create-shortcut'),
style: ButtonStyle.FOCUSABLE,
tabIndex: 0,
onClick: e => {
AppInterface && AppInterface.createShortcut(window.location.pathname.substring(6));
AppInterface.createShortcut(window.location.pathname.substring(6));
},
});
private static shortcutTimeoutId: number | null = null;
private static $btnWallpaper = AppInterface && createButton({
classes: ['bx-button-shortcut'],
icon: BxIcon.DOWNLOAD,
label: t('wallpaper'),
style: ButtonStyle.FOCUSABLE,
tabIndex: 0,
onClick: async e => {
try {
const matches = /\/games\/(?<titleSlug>[^\/]+)\/(?<productId>\w+)/.exec(window.location.pathname);
if (!matches?.groups) {
return;
}
static injectShortcutButton() {
if (!AppInterface || BX_FLAGS.DeviceInfo.deviceType !== 'android') {
const titleSlug = matches.groups.titleSlug.replaceAll('\%' + '7C', '-');
const productId = matches.groups.productId;
AppInterface.downloadWallpapers(titleSlug, productId);
} catch (e) {}
},
});
private static injectTimeoutId: number | null = null;
static injectButtons() {
if (!AppInterface) {
return;
}
ProductDetailsPage.shortcutTimeoutId && clearTimeout(ProductDetailsPage.shortcutTimeoutId);
ProductDetailsPage.shortcutTimeoutId = window.setTimeout(() => {
ProductDetailsPage.injectTimeoutId && clearTimeout(ProductDetailsPage.injectTimeoutId);
ProductDetailsPage.injectTimeoutId = window.setTimeout(() => {
// Find action buttons container
const $container = document.querySelector('div[class*=ActionButtons-module__container]');
if ($container) {
$container.parentElement?.appendChild(ProductDetailsPage.$btnShortcut);
if ($container && $container.parentElement) {
const fragment = document.createDocumentFragment();
// Shortcut button
if (BX_FLAGS.DeviceInfo.deviceType === 'android') {
fragment.appendChild(ProductDetailsPage.$btnShortcut);
}
// Wallpaper button
fragment.appendChild(ProductDetailsPage.$btnWallpaper);
$container.parentElement.appendChild(fragment);
}
}, 500);
}

View File

@ -11,7 +11,7 @@ export function localRedirect(path: string) {
const $anchor = CE<HTMLAnchorElement>('a', {
href: url,
class: 'bx-hidden bx-offscreen'
class: 'bx-hidden bx-offscreen',
}, '');
$anchor.addEventListener('click', e => {
// Remove element after clicking on it

46
src/types/index.d.ts vendored
View File

@ -48,9 +48,9 @@ type BxStates = {
};
currentStream: Partial<{
titleId: string;
productId: string;
titleSlug: string;
titleInfo: XcloudTitleInfo;
xboxTitleId: number;
streamPlayer: StreamPlayer | null;
@ -65,6 +65,7 @@ type BxStates = {
config: {
serverId: string;
};
titleId?: string;
}>;
pointerServerPort: number;
@ -75,6 +76,7 @@ type XcloudTitleInfo = {
details: {
productId: string;
xboxTitleId: number;
supportedInputTypes: InputType[];
supportedTabs: any[];
hasNativeTouchSupport: boolean;
@ -84,6 +86,7 @@ type XcloudTitleInfo = {
};
product: {
title: string;
heroImageUrl: string;
titledHeroImageUrl: string;
tileImageUrl: string;
@ -118,3 +121,42 @@ type MkbMouseWheel = {
vertical: number;
horizontal: number;
}
type XboxAchievement = {
version: number;
id: string;
name: string;
gamerscore: number;
isSecret: boolean;
isUnlocked: boolean;
description: {
locked: string;
unlocked: string;
};
imageUrl: string,
requirements: Array<{
current: number;
target: number;
percentComplete: number;
}>;
percentComplete: 0,
rarity: {
currentCategory: string;
currentPercentage: number;
};
rewards: Array<{
value: number;
valueType: string;
type: string;
}>;
title: {
id: string;
scid: string;
productId: string;
name: string;
}
};

View File

@ -1,4 +1,6 @@
import { AppInterface } from "@utils/global";
import { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags";
export namespace BxEvent {
@ -75,6 +77,8 @@ export namespace BxEvent {
target.dispatchEvent(event);
AppInterface && AppInterface.onEvent(eventName);
BX_FLAGS.Debug && BxLogger.warning('BxEvent', 'dispatch', eventName, data)
}
}

View File

@ -1,6 +1,8 @@
import { BxLogger } from "./bx-logger";
type BxFlags = {
Debug: boolean;
CheckForUpdate: boolean;
EnableXcloudLogging: boolean;
SafariWorkaround: boolean;
@ -20,6 +22,8 @@ type BxFlags = {
// Setup flags
const DEFAULT_FLAGS: BxFlags = {
Debug: false,
CheckForUpdate: true,
EnableXcloudLogging: false,
SafariWorkaround: true,

View File

@ -1,4 +1,5 @@
import iconBetterXcloud from "@assets/svg/better-xcloud.svg" with { type: "text" };
import iconTrueAchievements from "@assets/svg/true-achievements.svg" with { type: "text" };
import iconClose from "@assets/svg/close.svg" with { type: "text" };
import iconCommand from "@assets/svg/command.svg" with { type: "text" };
import iconController from "@assets/svg/controller.svg" with { type: "text" };
@ -9,6 +10,7 @@ import iconDisplay from "@assets/svg/display.svg" with { type: "text" };
import iconHome from "@assets/svg/home.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 iconPower from "@assets/svg/power.svg" with { type: "text" };
import iconQuestion from "@assets/svg/question.svg" with { type: "text" };
import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" };
import iconRemotePlay from "@assets/svg/remote-play.svg" with { type: "text" };
@ -37,6 +39,7 @@ import iconUpload from "@assets/svg/upload.svg" with { type: "text" };
export const BxIcon = {
BETTER_XCLOUD: iconBetterXcloud,
TRUE_ACHIEVEMENTS: iconTrueAchievements,
STREAM_SETTINGS: iconStreamSettings,
STREAM_STATS: iconStreamStats,
CLOSE: iconClose,
@ -50,6 +53,7 @@ export const BxIcon = {
COPY: iconCopy,
TRASH: iconTrash,
CURSOR_TEXT: iconCursorText,
POWER: iconPower,
QUESTION: iconQuestion,
REFRESH: iconRefresh,
VIRTUAL_CONTROLLER: iconVirtualController,

View File

@ -14,6 +14,7 @@ export enum ButtonStyle {
TALL = 256,
CIRCULAR = 512,
NORMAL_CASE = 1024,
NORMAL_LINK = 2048,
}
const ButtonStyleClass = {
@ -28,6 +29,7 @@ const ButtonStyleClass = {
[ButtonStyle.TALL]: 'bx-tall',
[ButtonStyle.CIRCULAR]: 'bx-circular',
[ButtonStyle.NORMAL_CASE]: 'bx-normal-case',
[ButtonStyle.NORMAL_LINK]: 'bx-normal-link',
}
type BxButton = {

View File

@ -71,7 +71,7 @@ export class Screenshot {
// Get data URL and pass to parent app
if (AppInterface) {
const data = $canvas.toDataURL('image/png').split(';base64,')[1];
AppInterface.saveScreenshot(currentStream.titleId, data);
AppInterface.saveScreenshot(currentStream.titleSlug, data);
// Free screenshot from memory
canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
@ -84,7 +84,7 @@ export class Screenshot {
// Download screenshot
const now = +new Date;
const $anchor = CE<HTMLAnchorElement>('a', {
'download': `${currentStream.titleId}-${now}.png`,
'download': `${currentStream.titleSlug}-${now}.png`,
'href': URL.createObjectURL(blob!),
});
$anchor.click();

View File

@ -140,6 +140,10 @@ export class SettingElement {
!(e as any).ignoreOnChange && onChange(e, (e.target as HTMLInputElement).checked);
});
($control as any).setValue = (value: boolean) => {
$control.checked = !!value;
};
return $control;
}

View File

@ -2,6 +2,7 @@ import type { PrefKey } from "@/enums/pref-keys";
import type { NumberStepperParams, SettingDefinitions } from "@/types/setting-definition";
import { BxEvent } from "../bx-event";
import { SettingElementType } from "../setting-element";
import { t } from "../translation";
export class BaseSettingsStore {
private storage: Storage;
@ -145,6 +146,8 @@ export class BaseSettingsStore {
if (value in options) {
return options[value];
}
} else if (typeof value === 'boolean') {
return value ? t('on') : t('off')
}
return value.toString();

View File

@ -321,6 +321,7 @@ const Texts = {
],
"touch-controller": "Touch controller",
"transparent-background": "Transparent background",
"true-achievements": "TrueAchievements",
"ui": "UI",
"unexpected-behavior": "May cause unexpected behavior",
"united-states": "United States",

View File

@ -0,0 +1,96 @@
import { BxIcon } from "./bx-icon";
import { AppInterface, STATES } from "./global";
import { ButtonStyle, CE, createButton, getReactProps } from "./html";
import { t } from "./translation";
export class TrueAchievements {
private static $link = createButton({
label: t('true-achievements'),
url: '#',
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.NORMAL_LINK,
onClick: TrueAchievements.onClick,
}) as HTMLAnchorElement;
static $button = createButton({
title: t('true-achievements'),
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE | ButtonStyle.FULL_WIDTH,
onClick: TrueAchievements.onClick,
}) as HTMLAnchorElement;
private static onClick(e: Event) {
e.preventDefault();
const dataset = TrueAchievements.$link.dataset;
TrueAchievements.open(true, dataset.xboxTitleId, dataset.id);
}
private static $hiddenLink = CE<HTMLAnchorElement>('a', {
target: '_blank',
});
private static updateLinks(xboxTitleId?: string, id?: string) {
TrueAchievements.$link.dataset.xboxTitleId = xboxTitleId;
TrueAchievements.$link.dataset.id = id;
TrueAchievements.$button.dataset.xboxTitleId = xboxTitleId;
TrueAchievements.$button.dataset.id = id;
}
static injectAchievementDetailPage($parent: HTMLElement) {
const props = getReactProps($parent);
if (!props) {
return;
}
try {
// Achievement list
const achievementList: XboxAchievement[] = props.children.props.data.data;
// Get current achievement name
const $header = $parent.querySelector('div[class*=AchievementDetailHeader]') as HTMLElement;
const achievementName = getReactProps($header).children[0].props.achievementName;
// Find achievement based on name
let id: string | undefined;
let xboxTitleId: string | undefined;
for (const achiev of achievementList) {
if (achiev.name === achievementName) {
id = achiev.id;
xboxTitleId = achiev.title.id;
break;
}
}
// Found achievement -> add TrueAchievements button
if (id) {
TrueAchievements.updateLinks(xboxTitleId, id);
$parent.appendChild(TrueAchievements.$link);
}
} catch (e) {};
}
static open(override: boolean, xboxTitleId?: number | string, id?: number | string) {
if (!xboxTitleId || xboxTitleId === 'undefined') {
xboxTitleId = STATES.currentStream.xboxTitleId || STATES.currentStream.titleInfo?.details.xboxTitleId;
}
if (AppInterface && AppInterface.openTrueAchievementsLink) {
AppInterface.openTrueAchievementsLink(override, xboxTitleId?.toString(), id?.toString());
return;
}
let url = 'https://www.trueachievements.com';
if (xboxTitleId) {
if (id && id !== 'undefined') {
url += `/deeplink/${xboxTitleId}/${id}`;
} else {
url += `/deeplink/${xboxTitleId}`;
}
}
TrueAchievements.$hiddenLink.href = url;
TrueAchievements.$hiddenLink.click();
}
}

View File

@ -110,3 +110,13 @@ export async function copyToClipboard(text: string, showToast=true): Promise<boo
return false;
}
export function productTitleToSlug(title: string): string {
return title.replace(/[;,/?:@&=+_`~$%#^*()!^\u2122\xae\xa9]/g, '')
.replace(/\|/g, '-')
.replace(/ {2,}/g, ' ')
.trim()
.substr(0, 50)
.replace(/ /g, '-')
.toLowerCase();
}

25
src/utils/xbox-api.ts Normal file
View File

@ -0,0 +1,25 @@
import { NATIVE_FETCH } from "./bx-flags"
export class XboxApi {
private static CACHED_TITLES: Record<string, string> = {};
static async getProductTitle(xboxTitleId: number | string): Promise<string | null> {
xboxTitleId = xboxTitleId.toString();
if (XboxApi.CACHED_TITLES[xboxTitleId]) {
return XboxApi.CACHED_TITLES[xboxTitleId];
}
try {
const url = `https://displaycatalog.mp.microsoft.com/v7.0/products/lookup?market=US&languages=en&value=${xboxTitleId}&alternateId=XboxTitleId&fieldsTemplate=browse`;
const resp = await NATIVE_FETCH(url);
const json = await resp.json();
const productTitle = json['Products'][0]['LocalizedProperties'][0]['ProductTitle'];
XboxApi.CACHED_TITLES[xboxTitleId] = productTitle;
return productTitle;
} catch (e) {}
return null;
}
}