Compare commits

...

43 Commits

Author SHA1 Message Date
714178a82d Bump version to 5.7.2 2024-09-06 20:55:12 +07:00
5c2c13e0e6 Update better-xcloud.user.js 2024-09-06 20:52:35 +07:00
3f423325b9 Add Game Bar action to mute/unmute speaker (#491) 2024-09-06 20:44:28 +07:00
870a205ead Update better-xcloud.user.js 2024-09-06 18:17:39 +07:00
421af05882 Update TA button's logic & layout in the Guide Menu 2024-09-06 18:07:13 +07:00
756d105f74 Clear focus on Game Bar after activating it 2024-09-06 17:03:55 +07:00
4d90ebca68 Bump version to 5.7.1 2024-09-05 06:39:19 +07:00
1297230192 Update better-xcloud.user.js 2024-09-05 06:34:57 +07:00
a45d0f8b98 Update buttons layout in Guide Menu with TV layout (#492) 2024-09-05 06:34:30 +07:00
821904066b Fix no sound when using volume control feature (#490) 2024-09-05 06:17:23 +07:00
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
24f0cf18d9 Bump version to 5.6.1 2024-08-30 20:24:04 +07:00
2df8274233 Update better-xcloud.user.js 2024-08-30 20:18:18 +07:00
a095370ab8 Show the wait time of every games in the "Jump back in" section all at once 2024-08-30 20:04:40 +07:00
339447d29c Update Settings dialog's style 2024-08-30 20:04:11 +07:00
efe0caf02f Update better-xcloud.user.js 2024-08-29 21:34:17 +07:00
6daabea288 Add troubleshooting link 2024-08-29 21:30:27 +07:00
772a642283 Update translations 2024-08-29 21:03:42 +07:00
675fc8431c Don't build meta.js for beta version 2024-08-29 17:44:14 +07:00
9a97053662 Upgrade bun 2024-08-29 17:38:39 +07:00
42 changed files with 1274 additions and 417 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'));
@ -85,8 +92,10 @@ const build = async (target: BuildTarget, version: string, config: any={}) => {
// Save to script
await Bun.write(path, scriptHeader + result);
// Create meta file
await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version));
// Create meta file (don't build if it's beta version)
if (!version.includes('beta')) {
await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version));
}
// Check with ESLint
const eslint = new ESLint();

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.0
// @version 5.7.2
// ==/UserScript==

File diff suppressed because one or more lines are too long

View File

@ -9,14 +9,14 @@
"build": "build.ts"
},
"devDependencies": {
"@types/bun": "^1.1.6",
"@types/node": "^20.16.1",
"@types/bun": "^1.1.8",
"@types/node": "^22.5.2",
"@types/stylus": "^0.48.42",
"eslint": "^9.9.0",
"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,65 @@
.bx-guide-home-achievements-progress {
display: flex;
gap: 10px;
flex-direction: row;
.bx-button {
margin-bottom: 0 !important;
}
html[data-xds-platform=tv] & {
flex-direction: column;
}
html:not([data-xds-platform=tv]) & {
flex-direction: row;
> button:first-of-type {
flex: 1;
}
> button:last-of-type {
width: 40px;
span {
display: none;
}
}
}
}
.bx-guide-home-buttons {
> div {
display: flex;
flex-direction: row;
gap: 12px;
html[data-xds-platform=tv] & {
flex-direction: column;
button {
margin-bottom: 0 !important;
}
}
html:not([data-xds-platform=tv]) & {
button {
span {
display: none;
}
}
}
}
&[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;
}
@ -168,16 +175,11 @@ div[class*=SupportedInputsBadge] {
left: 0;
z-index: 1;
background: #0000008c;
display: none;
border-radius: 0 0 4px 0;
display: flex;
border-radius: 4px 0 4px 0;
align-items: center;
padding: 4px 8px;
a[class^=BaseItem-module__container]:focus &,
button[class^=BaseItem-module__container]:focus & {
display: flex;
}
svg {
width: 14px;
height: 16px;
@ -190,6 +192,7 @@ div[class*=SupportedInputsBadge] {
line-height: 16px;
font-size: 12px;
font-weight: bold;
margin-left: 2px;
}
}

View File

@ -60,11 +60,11 @@
}
a {
color: #5dc21e;
color: #1c9d1c;
text-decoration: none;
&:hover, &:focus {
color: #128112;
color: #5dc21e;
}
}
}
@ -470,9 +470,10 @@
}
.bx-suggest-link {
font-size: 12px;
font-size: 14px;
display: inline-block;
margin-top: 10px;
margin-top: 4px;
padding: 4px;
}
.bx-suggest-row {

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='none' fill-rule='evenodd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 32 32'>
<path d='M5.462 3.4c-.205-.23-.499-.363-.808-.363-.592 0-1.079.488-1.079 1.08a1.08 1.08 0 0 0 .289.736l4.247 4.672H2.504a2.17 2.17 0 0 0-2.16 2.16v8.637a2.17 2.17 0 0 0 2.16 2.16h6.107l9.426 7.33a1.08 1.08 0 0 0 .662.227c.592 0 1.08-.487 1.08-1.079v-6.601l5.679 6.247a1.08 1.08 0 0 0 .808.363c.592 0 1.08-.487 1.08-1.079a1.08 1.08 0 0 0-.29-.736L5.462 3.4zm-2.958 8.285h5.398v8.637H2.504v-8.637zM17.62 26.752l-7.558-5.878V11.67l7.558 8.313v6.769zm5.668-8.607c1.072-1.218 1.072-3.063 0-4.281a1.08 1.08 0 0 1-.293-.74c0-.592.487-1.079 1.079-1.079a1.08 1.08 0 0 1 .834.393 5.42 5.42 0 0 1 0 7.137 1.08 1.08 0 0 1-.81.365c-.593 0-1.08-.488-1.08-1.08 0-.263.096-.517.27-.715zM12.469 7.888c-.147-.19-.228-.423-.228-.663a1.08 1.08 0 0 1 .417-.853l5.379-4.184a1.08 1.08 0 0 1 .662-.227c.593 0 1.08.488 1.08 1.08v10.105c0 .593-.487 1.08-1.08 1.08s-1.079-.487-1.079-1.08V5.255l-3.636 2.834c-.469.362-1.153.273-1.515-.196v-.005zm19.187 8.115a10.79 10.79 0 0 1-2.749 7.199 1.08 1.08 0 0 1-.793.347c-.593 0-1.08-.487-1.08-1.079 0-.26.094-.511.264-.708 2.918-3.262 2.918-8.253 0-11.516-.184-.2-.287-.461-.287-.733 0-.592.487-1.08 1.08-1.08a1.08 1.08 0 0 1 .816.373 10.78 10.78 0 0 1 2.749 7.197z' fill-rule='nonzero'/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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";
@ -38,6 +38,8 @@ import { getPref, StreamTouchController } from "./utils/settings-storages/global
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
@ -70,26 +72,65 @@ if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') {
.bx-reload-overlay {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
background: #000000cc;
z-index: 9999;
width: 100%;
line-height: 100vh;
color: #fff;
text-align: center;
font-weight: 400;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
font-size: 1.3rem;
}
.bx-reload-overlay *:focus {
outline: none !important;
}
.bx-reload-overlay > div {
margin: 0 auto;
}
.bx-reload-overlay a {
text-decoration: none;
display: inline-block;
background: #107c10;
color: white;
border-radius: 4px;
padding: 6px;
}
`);
const isSafari = UserAgent.isSafari();
let $secondaryAction: HTMLElement;
if (isSafari) {
$secondaryAction = CE('p', {}, t('settings-reloading'));
} else {
$secondaryAction = CE('a', {
href: 'https://better-xcloud.github.io/troubleshooting',
target: '_blank',
}, '🤓 ' + t('how-to-fix'));
}
const $fragment = document.createDocumentFragment();
$fragment.appendChild(CE('style', {}, css));
$fragment.appendChild(CE('div', {'class': 'bx-reload-overlay'}, t('safari-failed-message')));
$fragment.appendChild(CE('div',{
class: 'bx-reload-overlay',
},
CE('div', {},
CE('p', {}, t('load-failed-message')),
$secondaryAction,
),
));
document.documentElement.appendChild($fragment);
// Reload the page
// Reload the page if using Safari
// @ts-ignore
window.location.reload(true);
isSafari && window.location.reload(true);
// Stop processing the script
throw new Error('[Better xCloud] Executing workaround for Safari');
@ -158,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';
}
});
@ -208,10 +244,42 @@ 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();
}
});
// 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) {
return;
@ -248,7 +316,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) {
@ -256,31 +324,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);
}
}
@ -339,7 +396,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

@ -1,7 +1,6 @@
import { BxEvent } from "@utils/bx-event";
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, MicrophoneState } from "../shortcuts/shortcut-microphone";
@ -15,16 +14,15 @@ 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,
icon: BxIcon.MICROPHONE,
title: t('show-touch-controller'),
onClick: onClick,
classes: ['bx-activated'],
});
@ -32,7 +30,6 @@ export class MicrophoneAction extends BaseGameBarAction {
const $btnMuted = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.MICROPHONE_MUTED,
title: t('hide-touch-controller'),
onClick: onClick,
});

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,54 @@
import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle, CE } from "@utils/html";
import { BaseGameBarAction } from "./action-base";
import { SoundShortcut, SpeakerState } from "../shortcuts/shortcut-sound";
export class SpeakerAction extends BaseGameBarAction {
$content: HTMLElement;
constructor() {
super();
const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
SoundShortcut.muteUnmute();
};
const $btnEnable = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.AUDIO,
onClick: onClick,
});
const $btnMuted = createButton({
style: ButtonStyle.GHOST,
icon: BxIcon.SPEAKER_MUTED,
onClick: onClick,
classes: ['bx-activated'],
});
this.$content = CE('div', {},
$btnEnable,
$btnMuted,
);
this.reset();
window.addEventListener(BxEvent.SPEAKER_STATE_CHANGED, e => {
const speakerState = (e as any).speakerState;
const enabled = speakerState === SpeakerState.ENABLED;
this.$content.dataset.enabled = enabled.toString();
});
}
render(): HTMLElement {
return this.$content;
}
reset(): void {
this.$content.dataset.enabled = 'true';
}
}

View File

@ -26,7 +26,6 @@ export class TouchControlAction extends BaseGameBarAction {
icon: BxIcon.TOUCH_CONTROL_ENABLE,
title: t('show-touch-controller'),
onClick: onClick,
classes: ['bx-activated'],
});
const $btnDisable = createButton({
@ -34,6 +33,7 @@ export class TouchControlAction extends BaseGameBarAction {
icon: BxIcon.TOUCH_CONTROL_DISABLE,
title: t('hide-touch-controller'),
onClick: onClick,
classes: ['bx-activated'],
});
this.$content = CE('div', {},

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

@ -1,4 +1,4 @@
import { CE, createSvgIcon } from "@utils/html";
import { CE, clearFocus, createSvgIcon } from "@utils/html";
import { ScreenshotAction } from "./action-screenshot";
import { TouchControlAction } from "./action-touch-control";
import { BxEvent } from "@utils/bx-event";
@ -8,11 +8,12 @@ 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";
import { SpeakerAction } from "./action-speaker";
export class GameBar {
private static instance: GameBar;
public static getInstance(): GameBar {
if (!GameBar.instance) {
GameBar.instance = new GameBar();
@ -43,7 +44,9 @@ export class GameBar {
this.actions = [
new ScreenshotAction(),
...(STATES.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.OFF) ? [new TouchControlAction()] : []),
new SpeakerAction(),
new MicrophoneAction(),
new TrueAchievementsAction(),
];
// Reverse the action list if Game Bar's position is on the right side
@ -93,7 +96,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,13 +128,16 @@ 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();
}
hideBar() {
// Stop focusing Game Bar
clearFocus();
if (!this.$container) {
return;
}

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

@ -4,6 +4,12 @@ import { Toast } from "@utils/toast";
import { ceilToNearest, floorToNearest } from "@/utils/utils";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
import { BxEvent } from "@/utils/bx-event";
export enum SpeakerState {
ENABLED,
MUTED,
}
export class SoundShortcut {
static adjustGainNodeVolume(amount: number): number {
@ -64,6 +70,10 @@ export class SoundShortcut {
SoundShortcut.setGainNodeVolume(targetValue);
Toast.show(`${t('stream')} ${t('volume')}`, status, {instant: true});
BxEvent.dispatch(window, BxEvent.SPEAKER_STATE_CHANGED, {
speakerState: targetValue === 0 ? SpeakerState.MUTED : SpeakerState.ENABLED,
})
return;
}
@ -79,6 +89,10 @@ export class SoundShortcut {
const status = $media.muted ? t('muted') : t('unmuted');
Toast.show(`${t('stream')} ${t('volume')}`, status, {instant: true});
BxEvent.dispatch(window, BxEvent.SPEAKER_STATE_CHANGED, {
speakerState: $media.muted ? SpeakerState.MUTED : SpeakerState.ENABLED,
})
}
}
}

View File

@ -913,7 +913,7 @@ export class SettingsNavigationDialog extends NavigationDialog {
href: 'https://better-xcloud.github.io/guide/android-webview-tweaks/',
target: '_blank',
tabindex: 0,
}, t('how-to-improve-app-performance')),
}, '🤓 ' + t('how-to-improve-app-performance')),
BX_FLAGS.DeviceInfo.deviceType.includes('android') && !hasRecommendedSettings && CE('a', {
class: 'bx-suggest-link bx-focusable',

View File

@ -1,6 +1,6 @@
import { BxEvent } from "@/utils/bx-event";
import { BxIcon } from "@/utils/bx-icon";
import { CE, createSvgIcon, getReactProps } from "@/utils/html";
import { CE, createSvgIcon, getReactProps, isElementVisible } from "@/utils/html";
import { XcloudApi } from "@/utils/xcloud-api";
export class GameTile {
@ -23,6 +23,11 @@ export class GameTile {
}
static async #showWaitTime($elm: HTMLElement, productId: string) {
if (($elm as any).hasWaitTime) {
return;
}
($elm as any).hasWaitTime = true;
let totalWaitTime;
const api = XcloudApi.getInstance();
@ -34,7 +39,7 @@ export class GameTile {
}
}
if (typeof totalWaitTime === 'number' && $elm.isConnected) {
if (typeof totalWaitTime === 'number' && isElementVisible($elm)) {
const $div = CE('div', {'class': 'bx-game-tile-wait-time'},
createSvgIcon(BxIcon.PLAYTIME),
CE('span', {}, GameTile.#secondsToHms(totalWaitTime)),
@ -43,45 +48,61 @@ export class GameTile {
}
}
static requestWaitTime($elm: HTMLElement, productId: string) {
static #requestWaitTime($elm: HTMLElement, productId: string) {
GameTile.#timeout && clearTimeout(GameTile.#timeout);
GameTile.#timeout = window.setTimeout(async () => {
if (!($elm as any).hasWaitTime) {
($elm as any).hasWaitTime = true;
GameTile.#showWaitTime($elm, productId);
GameTile.#showWaitTime($elm, productId);
}, 500);
}
static #findProductId($elm: HTMLElement): string | null {
let productId = null;
try {
if (($elm.tagName === 'BUTTON' && $elm.className.includes('MruGameCard')) || (($elm.tagName === 'A' && $elm.className.includes('GameCard')))) {
let props = getReactProps($elm.parentElement!);
// When context menu is enabled
if (Array.isArray(props.children)) {
productId = props.children[0].props.productId;
} else {
productId = props.children.props.productId;
}
} else if ($elm.tagName === 'A' && $elm.className.includes('GameItem')) {
let props = getReactProps($elm.parentElement!);
props = props.children.props;
if (props.location !== 'NonStreamableGameItem') {
if ('productId' in props) {
productId = props.productId;
} else {
// Search page
productId = props.children.props.productId;
}
}
}
}, 1000);
} catch (e) {}
return productId;
}
static setup() {
window.addEventListener(BxEvent.NAVIGATION_FOCUS_CHANGED, e => {
let productId;
const $elm = (e as any).element;
try {
if (($elm.tagName === 'BUTTON' && $elm.className.includes('MruGameCard')) || (($elm.tagName === 'A' && $elm.className.includes('GameCard')))) {
let props = getReactProps($elm.parentElement);
// When context menu is enabled
if (Array.isArray(props.children)) {
productId = props.children[0].props.productId;
} else {
productId = props.children.props.productId;
}
} else if ($elm.tagName === 'A' && $elm.className.includes('GameItem')) {
let props = getReactProps($elm.parentElement);
props = props.children.props;
if (props.location !== 'NonStreamableGameItem') {
if ('productId' in props) {
productId = props.productId;
} else {
// Search page
productId = props.children.props.productId;
}
}
const className = $elm.className || '';
if (className.includes('MruGameCard')) {
// Show the wait time of every games in the "Jump back in" section all at once
const $ol = $elm.closest('ol');
if ($ol && !($ol as any).hasWaitTime) {
($ol as any).hasWaitTime = true;
$ol.querySelectorAll('button[class*=MruGameCard]').forEach(($elm: HTMLElement) => {
const productId = GameTile.#findProductId($elm);
productId && GameTile.#showWaitTime($elm, productId);
});
}
} catch (e) {}
productId && GameTile.requestWaitTime($elm, productId);
} else {
const productId = GameTile.#findProductId($elm);
productId && GameTile.#requestWaitTime($elm, productId);
}
});
}
}

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,24 @@ 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({
closeApp: AppInterface && createButton({
icon: BxIcon.POWER,
label: t('close-app'),
title: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
attributes: {
'data-state': 'normal',
},
}),
reloadPage: createButton({
icon: BxIcon.REFRESH,
label: t('reload-page'),
title: t('reload-page'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => {
if (STATES.isPlaying) {
@ -59,74 +58,92 @@ export class GuideMenu {
}),
backToHome: createButton({
icon: BxIcon.HOME,
label: t('back-to-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,
[
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) {
const $achievementsProgress = $root.querySelector('button[class*=AchievementsButton-module__progressBarContainer]');
if ($achievementsProgress) {
TrueAchievements.injectAchievementsProgress($achievementsProgress as HTMLElement);
}
const buttons: HTMLElement[] = [];
// Find the element to add buttons to
let $target: HTMLElement | null = null;
if (isPlaying) {
// Quit button
$target = $root.querySelector('a[class*=QuitGameButton]');
// "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;
// 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[] = [];
if (!$target) {
return false;
}
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 +151,45 @@ 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.includes('AchievementsButton-module__progressBarContainer')) {
TrueAchievements.injectAchievementsProgress($addedElm);
return;
}
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 {
@ -35,6 +37,7 @@ export namespace BxEvent {
export const GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated';
export const MICROPHONE_STATE_CHANGED = 'bx-microphone-state-changed';
export const SPEAKER_STATE_CHANGED = 'bx-speaker-state-changed';
export const CAPTURE_SCREENSHOT = 'bx-capture-screenshot';
@ -51,7 +54,7 @@ export namespace BxEvent {
export const XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed';
export const XCLOUD_RENDERING_COMPONENT = 'bx-xcloud-rendering-page';
export const XCLOUD_RENDERING_COMPONENT = 'bx-xcloud-rendering-component';
export const XCLOUD_ROUTER_HISTORY_READY = 'bx-xcloud-router-history-ready';
@ -75,6 +78,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,9 +10,11 @@ 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" };
import iconSpeakerSlash from "@assets/svg/speaker-slash.svg" with { type: "text" };
import iconStreamSettings from "@assets/svg/stream-settings.svg" with { type: "text" };
import iconStreamStats from "@assets/svg/stream-stats.svg" with { type: "text" };
import iconTouchControlDisable from "@assets/svg/touch-control-disable.svg" with { type: "text" };
@ -37,6 +40,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 +54,7 @@ export const BxIcon = {
COPY: iconCopy,
TRASH: iconTrash,
CURSOR_TEXT: iconCursorText,
POWER: iconPower,
QUESTION: iconQuestion,
REFRESH: iconRefresh,
VIRTUAL_CONTROLLER: iconVirtualController,
@ -60,6 +65,7 @@ export const BxIcon = {
CARET_LEFT: iconCaretLeft,
CARET_RIGHT: iconCaretRight,
SCREENSHOT: iconCamera,
SPEAKER_MUTED: iconSpeakerSlash,
TOUCH_CONTROL_ENABLE: iconTouchControlEnable,
TOUCH_CONTROL_DISABLE: iconTouchControlDisable,

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 = {
@ -172,3 +174,16 @@ export function removeChildElements($parent: HTMLElement) {
$parent.firstElementChild.remove();
}
}
export function clearFocus() {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
export function clearDataSet($elm: HTMLElement) {
Object.keys($elm.dataset).forEach(key => {
delete $elm.dataset[key];
});
}

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

@ -113,6 +113,7 @@ const Texts = {
"fortnite-force-console-version": "Fortnite: force console version",
"game-bar": "Game Bar",
"getting-consoles-list": "Getting the list of consoles...",
"guide": "Guide",
"help": "Help",
"hide": "Hide",
"hide-idle-cursor": "Hide mouse cursor on idle",
@ -125,6 +126,7 @@ const Texts = {
"highest-quality-note": "Your device may not be powerful enough to use these settings",
"horizontal-scroll-sensitivity": "Horizontal scroll sensitivity",
"horizontal-sensitivity": "Horizontal sensitivity",
"how-to-fix": "How to fix",
"how-to-improve-app-performance": "How to improve app's performance",
"ignore": "Ignore",
"import": "Import",
@ -137,6 +139,7 @@ const Texts = {
"large": "Large",
"layout": "Layout",
"left-stick": "Left stick",
"load-failed-message": "Failed to run Better xCloud",
"loading-screen": "Loading screen",
"local-co-op": "Local co-op",
"low-power": "Low power",
@ -198,22 +201,22 @@ const Texts = {
(e: any) => `Recommended settings for ${e.device}`,
,
,
,
(e: any) => `Empfohlene Einstellungen für ${e.device}`,
,
(e: any) => `Ajustes recomendados para ${e.device}`,
,
(e: any) => `Paramètres recommandés pour ${e.device}`,
(e: any) => `Configurazioni consigliate per ${e.device}`,
,
(e: any) => `${e.device} の推奨設定`,
(e: any) => `다음 기기에서 권장되는 설정: ${e.device}`,
(e: any) => `Zalecane ustawienia dla ${e.device}`,
,
(e: any) => `Рекомендуемые настройки для ${e.device}`,
,
,
,
,
(e: any) => `${e.device} için önerilen ayarlar`,
(e: any) => `Рекомендовані налаштування для ${e.device}`,
(e: any) => `Cấu hình được đề xuất cho ${e.device}`,
,
,
(e: any) => `${e.device} 的推荐设置`,
(e: any) => `${e.device} 推薦的設定`,
],
"reduce-animations": "Reduce UI animations",
"region": "Region",
@ -228,7 +231,6 @@ const Texts = {
"rocket-always-show": "Always show",
"rocket-animation": "Rocket animation",
"rocket-hide-queue": "Hide when queuing",
"safari-failed-message": "Failed to run Better xCloud. Retrying, please wait...",
"saturation": "Saturation",
"save": "Save",
"screen": "Screen",
@ -319,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,150 @@
import { BxIcon } from "./bx-icon";
import { AppInterface, STATES } from "./global";
import { ButtonStyle, CE, clearDataSet, 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({
label: t('true-achievements'),
title: t('true-achievements'),
icon: BxIcon.TRUE_ACHIEVEMENTS,
style: ButtonStyle.FOCUSABLE,
onClick: TrueAchievements.onClick,
}) as HTMLAnchorElement;
private static onClick(e: Event) {
e.preventDefault();
const dataset = TrueAchievements.$link.dataset;
TrueAchievements.open(true, dataset.xboxTitleId, dataset.id);
// Close all xCloud's dialogs
window.BX_EXPOSED.dialogRoutes.closeAll();
}
private static $hiddenLink = CE<HTMLAnchorElement>('a', {
target: '_blank',
});
private static updateIds(xboxTitleId?: string, id?: string) {
const $link = TrueAchievements.$link;
const $button = TrueAchievements.$button;
clearDataSet($link);
clearDataSet($button);
if (xboxTitleId) {
$link.dataset.xboxTitleId = xboxTitleId;
$button.dataset.xboxTitleId = xboxTitleId;
}
if (id) {
$link.dataset.id = id;
$button.dataset.id = id;
}
}
static injectAchievementsProgress($elm: HTMLElement) {
const $parent = $elm.parentElement!;
// Wrap xCloud's element with our own
const $div = CE('div', {
class: 'bx-guide-home-achievements-progress',
}, $elm);
// Get xboxTitleId of the game
let xboxTitleId: string | number | undefined;
try {
const $container = $parent.closest('div[class*=AchievementsPreview-module__container]') as HTMLElement;
if ($container) {
const props = getReactProps($container);
xboxTitleId = props.children.props.data.data.xboxTitleId;
}
} catch (e) {}
if (!xboxTitleId) {
xboxTitleId = TrueAchievements.getStreamXboxTitleId();
}
if (typeof xboxTitleId !== 'undefined') {
xboxTitleId = xboxTitleId.toString();
}
TrueAchievements.updateIds(xboxTitleId);
if (document.documentElement.dataset.xdsPlatform === 'tv') {
$div.appendChild(TrueAchievements.$link);
} else {
$div.appendChild(TrueAchievements.$button);
}
$parent.appendChild($div);
}
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.updateIds(xboxTitleId, id);
$parent.appendChild(TrueAchievements.$link);
}
} catch (e) {};
}
private static getStreamXboxTitleId() : number | undefined {
return STATES.currentStream.xboxTitleId || STATES.currentStream.titleInfo?.details.xboxTitleId;
}
static open(override: boolean, xboxTitleId?: number | string, id?: number | string) {
if (!xboxTitleId || xboxTitleId === 'undefined') {
xboxTitleId = TrueAchievements.getStreamXboxTitleId();
}
if (AppInterface && AppInterface.openTrueAchievementsLink) {
AppInterface.openTrueAchievementsLink(override, xboxTitleId?.toString(), id?.toString());
return;
}
let url = 'https://www.trueachievements.com';
if (xboxTitleId) {
url += `/deeplink/${xboxTitleId}`;
if (id) {
url += `/${id}`;
}
}
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;
}
}