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 // Add ADDITIONAL CODE block
str = str.replace('var DEFAULT_FLAGS', '\n/* ADDITIONAL CODE */\n\nvar DEFAULT_FLAGS'); 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('/* ADDITIONAL CODE */'));
assert(str.includes('window.BX_EXPOSED = BxExposed')); assert(str.includes('window.BX_EXPOSED = BxExposed'));
assert(str.includes('window.BxEvent = BxEvent')); assert(str.includes('window.BxEvent = BxEvent'));
@ -85,8 +92,10 @@ const build = async (target: BuildTarget, version: string, config: any={}) => {
// Save to script // Save to script
await Bun.write(path, scriptHeader + result); await Bun.write(path, scriptHeader + result);
// Create meta file // Create meta file (don't build if it's beta version)
await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version)); if (!version.includes('beta')) {
await Bun.write(outDir + '/' + outputMetaName, txtMetaHeader.replace('[[VERSION]]', version));
}
// Check with ESLint // Check with ESLint
const eslint = new ESLint(); const eslint = new ESLint();

BIN
bun.lockb

Binary file not shown.

View File

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

File diff suppressed because one or more lines are too long

View File

@ -9,14 +9,14 @@
"build": "build.ts" "build": "build.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.1.6", "@types/bun": "^1.1.8",
"@types/node": "^20.16.1", "@types/node": "^22.5.2",
"@types/stylus": "^0.48.42", "@types/stylus": "^0.48.42",
"eslint": "^9.9.0", "eslint": "^9.9.1",
"eslint-plugin-compat": "^6.0.0", "eslint-plugin-compat": "^6.0.0",
"stylus": "^0.63.0" "stylus": "^0.63.0"
}, },
"peerDependencies": { "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; 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] { select[multiple] {
overflow: auto; overflow: auto;
} }
@ -168,16 +175,11 @@ div[class*=SupportedInputsBadge] {
left: 0; left: 0;
z-index: 1; z-index: 1;
background: #0000008c; background: #0000008c;
display: none; display: flex;
border-radius: 0 0 4px 0; border-radius: 4px 0 4px 0;
align-items: center; align-items: center;
padding: 4px 8px; padding: 4px 8px;
a[class^=BaseItem-module__container]:focus &,
button[class^=BaseItem-module__container]:focus & {
display: flex;
}
svg { svg {
width: 14px; width: 14px;
height: 16px; height: 16px;
@ -190,6 +192,7 @@ div[class*=SupportedInputsBadge] {
line-height: 16px; line-height: 16px;
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
margin-left: 2px;
} }
} }

View File

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

View File

@ -9,6 +9,7 @@
@import 'loading-screen.styl'; @import 'loading-screen.styl';
@import 'remote-play.styl'; @import 'remote-play.styl';
@import 'web-components.styl'; @import 'web-components.styl';
@import 'guide-menu.styl';
@import 'stream.styl'; @import 'stream.styl';
@import 'number-stepper.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 { LoadingScreen } from "@modules/loading-screen";
import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider"; import { MouseCursorHider } from "@modules/mkb/mouse-cursor-hider";
import { TouchController } from "@modules/touch-controller"; 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 { Patcher } from "@modules/patcher";
import { RemotePlay } from "@modules/remote-play"; import { RemotePlay } from "@modules/remote-play";
import { onHistoryChanged, patchHistoryMethod } from "@utils/history"; 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 { GameBar } from "./modules/game-bar/game-bar";
import { Screenshot } from "./utils/screenshot"; import { Screenshot } from "./utils/screenshot";
import { NativeMkbHandler } from "./modules/mkb/native-mkb-handler"; 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 { updateVideoPlayer } from "./modules/stream/stream-settings-utils";
import { UiSection } from "./enums/ui-sections"; import { UiSection } from "./enums/ui-sections";
import { HeaderSection } from "./modules/ui/header"; 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 { compressCss } from "@macros/build" with {type: "macro"};
import { SettingsNavigationDialog } from "./modules/ui/dialog/settings-dialog"; import { SettingsNavigationDialog } from "./modules/ui/dialog/settings-dialog";
import { StreamUiHandler } from "./modules/stream/stream-ui"; import { StreamUiHandler } from "./modules/stream/stream-ui";
import { UserAgent } from "./utils/user-agent";
import { XboxApi } from "./utils/xbox-api";
// Handle login page // Handle login page
@ -70,26 +72,65 @@ if (BX_FLAGS.SafariWorkaround && document.readyState !== 'loading') {
.bx-reload-overlay { .bx-reload-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
background: #000000cc; background: #000000cc;
z-index: 9999; z-index: 9999;
width: 100%;
line-height: 100vh;
color: #fff; color: #fff;
text-align: center; text-align: center;
font-weight: 400; font-weight: 400;
font-family: "Segoe UI", Arial, Helvetica, sans-serif; font-family: "Segoe UI", Arial, Helvetica, sans-serif;
font-size: 1.3rem; 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(); const $fragment = document.createDocumentFragment();
$fragment.appendChild(CE('style', {}, css)); $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); document.documentElement.appendChild($fragment);
// Reload the page // Reload the page if using Safari
// @ts-ignore // @ts-ignore
window.location.reload(true); isSafari && window.location.reload(true);
// Stop processing the script // Stop processing the script
throw new Error('[Better xCloud] Executing workaround for Safari'); 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 => { window.addEventListener(BxEvent.STREAM_LOADING, e => {
// Get title ID for screenshot's name // Get title ID for screenshot's name
if (window.location.pathname.includes('/launch/')) { if (window.location.pathname.includes('/launch/') && STATES.currentStream.titleInfo) {
const matches = /\/launch\/(?<title_id>[^\/]+)\/(?<product_id>\w+)/.exec(window.location.pathname); STATES.currentStream.titleSlug = productTitleToSlug(STATES.currentStream.titleInfo.product.title);
if (matches?.groups) {
STATES.currentStream.titleId = matches.groups.title_id;
STATES.currentStream.productId = matches.groups.product_id;
}
} else { } else {
STATES.currentStream.titleId = 'remote-play'; STATES.currentStream.titleSlug = 'remote-play';
STATES.currentStream.productId = '';
} }
}); });
@ -208,10 +244,42 @@ window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => {
window.addEventListener(BxEvent.XCLOUD_RENDERING_COMPONENT, e => { window.addEventListener(BxEvent.XCLOUD_RENDERING_COMPONENT, e => {
const component = (e as any).component; const component = (e as any).component;
if (component === 'product-details') { 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() { function unload() {
if (!STATES.isPlaying) { if (!STATES.isPlaying) {
return; return;
@ -248,7 +316,7 @@ window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => {
function observeRootDialog($root: HTMLElement) { function observeRootDialog($root: HTMLElement) {
let currentShown = false; let beingShown = false;
const observer = new MutationObserver(mutationList => { const observer = new MutationObserver(mutationList => {
for (const mutation of mutationList) { for (const mutation of mutationList) {
@ -256,31 +324,20 @@ function observeRootDialog($root: HTMLElement) {
continue; continue;
} }
BX_FLAGS.Debug && BxLogger.warning('RootDialog', 'added', mutation.addedNodes);
if (mutation.addedNodes.length === 1) { if (mutation.addedNodes.length === 1) {
const $addedElm = mutation.addedNodes[0]; const $addedElm = mutation.addedNodes[0];
if ($addedElm instanceof HTMLElement && $addedElm.className) { 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
// Make sure it's Guide dialog if ($root.querySelector('div[class*=GuideDialog]')) {
if (document.querySelector('#gamepass-dialog-root div[class*=GuideDialog]')) { GuideMenu.observe($addedElm);
// 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});
}
}
}
} }
} }
} }
const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false; const shown = !!($root.firstElementChild && $root.firstElementChild.childElementCount > 0);
if (shown !== currentShown) { if (shown !== beingShown) {
currentShown = shown; beingShown = shown;
BxEvent.dispatch(window, shown ? BxEvent.XCLOUD_DIALOG_SHOWN : BxEvent.XCLOUD_DIALOG_DISMISSED); 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(); (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') && GameBar.getInstance();
Screenshot.setup(); Screenshot.setup();
GuideMenu.observe(); GuideMenu.addEventListeners();
StreamBadges.setupEvents(); StreamBadges.setupEvents();
StreamStats.setupEvents(); StreamStats.setupEvents();
EmulatedMkbHandler.setupEvents(); EmulatedMkbHandler.setupEvents();

View File

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

View File

@ -1,7 +1,6 @@
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
import { BxIcon } from "@utils/bx-icon"; import { BxIcon } from "@utils/bx-icon";
import { createButton, ButtonStyle, CE } from "@utils/html"; import { createButton, ButtonStyle, CE } from "@utils/html";
import { t } from "@utils/translation";
import { BaseGameBarAction } from "./action-base"; import { BaseGameBarAction } from "./action-base";
import { MicrophoneShortcut, MicrophoneState } from "../shortcuts/shortcut-microphone"; import { MicrophoneShortcut, MicrophoneState } from "../shortcuts/shortcut-microphone";
@ -15,16 +14,15 @@ export class MicrophoneAction extends BaseGameBarAction {
super(); super();
const onClick = (e: Event) => { 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); const enabled = MicrophoneShortcut.toggle(false);
this.$content.setAttribute('data-enabled', enabled.toString()); this.$content.setAttribute('data-enabled', enabled.toString());
}; };
const $btnDefault = createButton({ const $btnDefault = createButton({
style: ButtonStyle.GHOST, style: ButtonStyle.GHOST,
icon: BxIcon.MICROPHONE, icon: BxIcon.MICROPHONE,
title: t('show-touch-controller'),
onClick: onClick, onClick: onClick,
classes: ['bx-activated'], classes: ['bx-activated'],
}); });
@ -32,7 +30,6 @@ export class MicrophoneAction extends BaseGameBarAction {
const $btnMuted = createButton({ const $btnMuted = createButton({
style: ButtonStyle.GHOST, style: ButtonStyle.GHOST,
icon: BxIcon.MICROPHONE_MUTED, icon: BxIcon.MICROPHONE_MUTED,
title: t('hide-touch-controller'),
onClick: onClick, onClick: onClick,
}); });

View File

@ -12,16 +12,16 @@ export class ScreenshotAction extends BaseGameBarAction {
super(); super();
const onClick = (e: Event) => { const onClick = (e: Event) => {
BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED); BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
Screenshot.takeScreenshot(); Screenshot.takeScreenshot();
}; };
this.$content = createButton({ this.$content = createButton({
style: ButtonStyle.GHOST, style: ButtonStyle.GHOST,
icon: BxIcon.SCREENSHOT, icon: BxIcon.SCREENSHOT,
title: t('take-screenshot'), title: t('take-screenshot'),
onClick: onClick, onClick: onClick,
}); });
} }
render(): HTMLElement { 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, icon: BxIcon.TOUCH_CONTROL_ENABLE,
title: t('show-touch-controller'), title: t('show-touch-controller'),
onClick: onClick, onClick: onClick,
classes: ['bx-activated'],
}); });
const $btnDisable = createButton({ const $btnDisable = createButton({
@ -34,6 +33,7 @@ export class TouchControlAction extends BaseGameBarAction {
icon: BxIcon.TOUCH_CONTROL_DISABLE, icon: BxIcon.TOUCH_CONTROL_DISABLE,
title: t('hide-touch-controller'), title: t('hide-touch-controller'),
onClick: onClick, onClick: onClick,
classes: ['bx-activated'],
}); });
this.$content = CE('div', {}, 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 { ScreenshotAction } from "./action-screenshot";
import { TouchControlAction } from "./action-touch-control"; import { TouchControlAction } from "./action-touch-control";
import { BxEvent } from "@utils/bx-event"; import { BxEvent } from "@utils/bx-event";
@ -8,11 +8,12 @@ import { STATES } from "@utils/global";
import { MicrophoneAction } from "./action-microphone"; import { MicrophoneAction } from "./action-microphone";
import { PrefKey } from "@/enums/pref-keys"; import { PrefKey } from "@/enums/pref-keys";
import { getPref, StreamTouchController } from "@/utils/settings-storages/global-settings-storage"; import { getPref, StreamTouchController } from "@/utils/settings-storages/global-settings-storage";
import { TrueAchievementsAction } from "./action-true-achievements";
import { SpeakerAction } from "./action-speaker";
export class GameBar { export class GameBar {
private static instance: GameBar; private static instance: GameBar;
public static getInstance(): GameBar { public static getInstance(): GameBar {
if (!GameBar.instance) { if (!GameBar.instance) {
GameBar.instance = new GameBar(); GameBar.instance = new GameBar();
@ -43,7 +44,9 @@ export class GameBar {
this.actions = [ this.actions = [
new ScreenshotAction(), new ScreenshotAction(),
...(STATES.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.OFF) ? [new TouchControlAction()] : []), ...(STATES.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.OFF) ? [new TouchControlAction()] : []),
new SpeakerAction(),
new MicrophoneAction(), new MicrophoneAction(),
new TrueAchievementsAction(),
]; ];
// Reverse the action list if Game Bar's position is on the right side // Reverse the action list if Game Bar's position is on the right side
@ -93,7 +96,7 @@ export class GameBar {
// Toggle Game bar // Toggle Game bar
const mode = (e as any).mode; const mode = (e as any).mode;
mode !== 'None' ? this.disable() : this.enable(); mode !== 'none' ? this.disable() : this.enable();
}).bind(this)); }).bind(this));
} }
@ -125,13 +128,16 @@ export class GameBar {
return; 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.$container.classList.add('bx-show');
this.beginHideTimeout(); this.beginHideTimeout();
} }
hideBar() { hideBar() {
// Stop focusing Game Bar
clearFocus();
if (!this.$container) { if (!this.$container) {
return; return;
} }

View File

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

View File

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

View File

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

View File

@ -465,7 +465,7 @@ e.guideUI = null;
} }
const newCode = ` 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); str = str.replace(text, text + newCode);
return str; return str;

View File

@ -4,6 +4,12 @@ import { Toast } from "@utils/toast";
import { ceilToNearest, floorToNearest } from "@/utils/utils"; import { ceilToNearest, floorToNearest } from "@/utils/utils";
import { PrefKey } from "@/enums/pref-keys"; import { PrefKey } from "@/enums/pref-keys";
import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage"; import { getPref, setPref } from "@/utils/settings-storages/global-settings-storage";
import { BxEvent } from "@/utils/bx-event";
export enum SpeakerState {
ENABLED,
MUTED,
}
export class SoundShortcut { export class SoundShortcut {
static adjustGainNodeVolume(amount: number): number { static adjustGainNodeVolume(amount: number): number {
@ -64,6 +70,10 @@ export class SoundShortcut {
SoundShortcut.setGainNodeVolume(targetValue); SoundShortcut.setGainNodeVolume(targetValue);
Toast.show(`${t('stream')} ${t('volume')}`, status, {instant: true}); Toast.show(`${t('stream')} ${t('volume')}`, status, {instant: true});
BxEvent.dispatch(window, BxEvent.SPEAKER_STATE_CHANGED, {
speakerState: targetValue === 0 ? SpeakerState.MUTED : SpeakerState.ENABLED,
})
return; return;
} }
@ -79,6 +89,10 @@ export class SoundShortcut {
const status = $media.muted ? t('muted') : t('unmuted'); const status = $media.muted ? t('muted') : t('unmuted');
Toast.show(`${t('stream')} ${t('volume')}`, status, {instant: true}); 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/', href: 'https://better-xcloud.github.io/guide/android-webview-tweaks/',
target: '_blank', target: '_blank',
tabindex: 0, tabindex: 0,
}, t('how-to-improve-app-performance')), }, '🤓 ' + t('how-to-improve-app-performance')),
BX_FLAGS.DeviceInfo.deviceType.includes('android') && !hasRecommendedSettings && CE('a', { BX_FLAGS.DeviceInfo.deviceType.includes('android') && !hasRecommendedSettings && CE('a', {
class: 'bx-suggest-link bx-focusable', class: 'bx-suggest-link bx-focusable',

View File

@ -1,6 +1,6 @@
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
import { BxIcon } from "@/utils/bx-icon"; 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"; import { XcloudApi } from "@/utils/xcloud-api";
export class GameTile { export class GameTile {
@ -23,6 +23,11 @@ export class GameTile {
} }
static async #showWaitTime($elm: HTMLElement, productId: string) { static async #showWaitTime($elm: HTMLElement, productId: string) {
if (($elm as any).hasWaitTime) {
return;
}
($elm as any).hasWaitTime = true;
let totalWaitTime; let totalWaitTime;
const api = XcloudApi.getInstance(); 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'}, const $div = CE('div', {'class': 'bx-game-tile-wait-time'},
createSvgIcon(BxIcon.PLAYTIME), createSvgIcon(BxIcon.PLAYTIME),
CE('span', {}, GameTile.#secondsToHms(totalWaitTime)), 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 && clearTimeout(GameTile.#timeout);
GameTile.#timeout = window.setTimeout(async () => { GameTile.#timeout = window.setTimeout(async () => {
if (!($elm as any).hasWaitTime) { GameTile.#showWaitTime($elm, productId);
($elm as any).hasWaitTime = true; }, 500);
GameTile.#showWaitTime($elm, productId); }
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() { static setup() {
window.addEventListener(BxEvent.NAVIGATION_FOCUS_CHANGED, e => { window.addEventListener(BxEvent.NAVIGATION_FOCUS_CHANGED, e => {
let productId;
const $elm = (e as any).element; const $elm = (e as any).element;
try { const className = $elm.className || '';
if (($elm.tagName === 'BUTTON' && $elm.className.includes('MruGameCard')) || (($elm.tagName === 'A' && $elm.className.includes('GameCard')))) { if (className.includes('MruGameCard')) {
let props = getReactProps($elm.parentElement); // Show the wait time of every games in the "Jump back in" section all at once
const $ol = $elm.closest('ol');
// When context menu is enabled if ($ol && !($ol as any).hasWaitTime) {
if (Array.isArray(props.children)) { ($ol as any).hasWaitTime = true;
productId = props.children[0].props.productId; $ol.querySelectorAll('button[class*=MruGameCard]').forEach(($elm: HTMLElement) => {
} else { const productId = GameTile.#findProductId($elm);
productId = props.children.props.productId; productId && GameTile.#showWaitTime($elm, 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;
}
}
} }
} catch (e) {} } else {
const productId = GameTile.#findProductId($elm);
productId && GameTile.requestWaitTime($elm, productId); 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 { createButton, ButtonStyle, CE } from "@/utils/html";
import { t } from "@/utils/translation"; import { t } from "@/utils/translation";
import { SettingsNavigationDialog } from "./dialog/settings-dialog"; import { SettingsNavigationDialog } from "./dialog/settings-dialog";
import { TrueAchievements } from "@/utils/true-achievements";
import { BxIcon } from "@/utils/bx-icon";
export enum GuideMenuTab { export enum GuideMenuTab {
HOME = 'home', HOME = 'home',
@ -24,27 +26,24 @@ export class GuideMenu {
}, },
}), }),
appSettings: createButton({ closeApp: AppInterface && createButton({
label: t('app-settings'), icon: BxIcon.POWER,
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'), label: t('close-app'),
title: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER, style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => { onClick: e => {
AppInterface.closeApp(); AppInterface.closeApp();
}, },
attributes: {
'data-state': 'normal',
},
}), }),
reloadPage: createButton({ reloadPage: createButton({
icon: BxIcon.REFRESH,
label: t('reload-page'), label: t('reload-page'),
title: t('reload-page'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => { onClick: e => {
if (STATES.isPlaying) { if (STATES.isPlaying) {
@ -59,74 +58,92 @@ export class GuideMenu {
}), }),
backToHome: createButton({ backToHome: createButton({
icon: BxIcon.HOME,
label: t('back-to-home'), label: t('back-to-home'),
title: t('back-to-home'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE, style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
onClick: e => { onClick: e => {
confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31)); 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[]) { static #$renderedButtons: HTMLElement;
const $div = CE('div', {});
for (const $button of buttons) { static #renderButtons() {
$div.appendChild($button); 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; return $div;
} }
static #injectHome($root: HTMLElement) { static #injectHome($root: HTMLElement, isPlaying = false) {
// Find the last divider const $achievementsProgress = $root.querySelector('button[class*=AchievementsButton-module__progressBarContainer]');
const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]'); if ($achievementsProgress) {
if (!$dividers) { TrueAchievements.injectAchievementsProgress($achievementsProgress as HTMLElement);
return;
} }
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 // Hide xCloud's Home button
buttons.push(GuideMenu.#BUTTONS.scriptSettings); const $btnXcloudHome = $root.querySelector('div[class^=HomeButtonWithDivider]') as HTMLElement;
$btnXcloudHome && ($btnXcloudHome.style.display = 'none');
// "App settings" button } else {
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings); // Last divider
const $dividers = $root.querySelectorAll('div[class*=Divider-module__divider]');
// "Reload page" button if ($dividers) {
buttons.push(GuideMenu.#BUTTONS.reloadPage); $target = $dividers[$dividers.length - 1] as HTMLElement;
}
// "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;
} }
const buttons: HTMLElement[] = []; if (!$target) {
return false;
}
buttons.push(GuideMenu.#BUTTONS.scriptSettings); const $buttons = GuideMenu.#renderButtons();
AppInterface && buttons.push(GuideMenu.#BUTTONS.appSettings); $buttons.dataset.isPlaying = isPlaying.toString();
$target.insertAdjacentElement('afterend', $buttons);
// 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');
} }
static async #onShown(e: Event) { static async #onShown(e: Event) {
@ -134,17 +151,45 @@ export class GuideMenu {
if (where === GuideMenuTab.HOME) { if (where === GuideMenuTab.HOME) {
const $root = document.querySelector('#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]') as HTMLElement; const $root = document.querySelector('#gamepass-dialog-root div[role=dialog] div[role=tabpanel] div[class*=HomeLandingPage]') as HTMLElement;
if ($root) { $root && GuideMenu.#injectHome($root, STATES.isPlaying);
if (STATES.isPlaying) {
GuideMenu.#injectHomePlaying($root);
} else {
GuideMenu.#injectHome($root);
}
}
} }
} }
static observe() { static addEventListeners() {
window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, GuideMenu.#onShown); 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"; import { t } from "@/utils/translation";
export class ProductDetailsPage { export class ProductDetailsPage {
private static $btnShortcut = createButton({ private static $btnShortcut = AppInterface && createButton({
classes: ['bx-button-shortcut'], classes: ['bx-button-shortcut'],
icon: BxIcon.CREATE_SHORTCUT, icon: BxIcon.CREATE_SHORTCUT,
label: t('create-shortcut'), label: t('create-shortcut'),
style: ButtonStyle.FOCUSABLE, style: ButtonStyle.FOCUSABLE,
tabIndex: 0, tabIndex: 0,
onClick: e => { 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() { const titleSlug = matches.groups.titleSlug.replaceAll('\%' + '7C', '-');
if (!AppInterface || BX_FLAGS.DeviceInfo.deviceType !== 'android') { const productId = matches.groups.productId;
AppInterface.downloadWallpapers(titleSlug, productId);
} catch (e) {}
},
});
private static injectTimeoutId: number | null = null;
static injectButtons() {
if (!AppInterface) {
return; return;
} }
ProductDetailsPage.shortcutTimeoutId && clearTimeout(ProductDetailsPage.shortcutTimeoutId); ProductDetailsPage.injectTimeoutId && clearTimeout(ProductDetailsPage.injectTimeoutId);
ProductDetailsPage.shortcutTimeoutId = window.setTimeout(() => { ProductDetailsPage.injectTimeoutId = window.setTimeout(() => {
// Find action buttons container // Find action buttons container
const $container = document.querySelector('div[class*=ActionButtons-module__container]'); const $container = document.querySelector('div[class*=ActionButtons-module__container]');
if ($container) { if ($container && $container.parentElement) {
$container.parentElement?.appendChild(ProductDetailsPage.$btnShortcut); 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); }, 500);
} }

View File

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

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

@ -48,9 +48,9 @@ type BxStates = {
}; };
currentStream: Partial<{ currentStream: Partial<{
titleId: string; titleSlug: string;
productId: string;
titleInfo: XcloudTitleInfo; titleInfo: XcloudTitleInfo;
xboxTitleId: number;
streamPlayer: StreamPlayer | null; streamPlayer: StreamPlayer | null;
@ -65,6 +65,7 @@ type BxStates = {
config: { config: {
serverId: string; serverId: string;
}; };
titleId?: string;
}>; }>;
pointerServerPort: number; pointerServerPort: number;
@ -75,6 +76,7 @@ type XcloudTitleInfo = {
details: { details: {
productId: string; productId: string;
xboxTitleId: number;
supportedInputTypes: InputType[]; supportedInputTypes: InputType[];
supportedTabs: any[]; supportedTabs: any[];
hasNativeTouchSupport: boolean; hasNativeTouchSupport: boolean;
@ -84,6 +86,7 @@ type XcloudTitleInfo = {
}; };
product: { product: {
title: string;
heroImageUrl: string; heroImageUrl: string;
titledHeroImageUrl: string; titledHeroImageUrl: string;
tileImageUrl: string; tileImageUrl: string;
@ -118,3 +121,42 @@ type MkbMouseWheel = {
vertical: number; vertical: number;
horizontal: 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 { AppInterface } from "@utils/global";
import { BxLogger } from "./bx-logger";
import { BX_FLAGS } from "./bx-flags";
export namespace BxEvent { export namespace BxEvent {
@ -35,6 +37,7 @@ export namespace BxEvent {
export const GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated'; export const GAME_BAR_ACTION_ACTIVATED = 'bx-game-bar-action-activated';
export const MICROPHONE_STATE_CHANGED = 'bx-microphone-state-changed'; 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'; 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_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'; export const XCLOUD_ROUTER_HISTORY_READY = 'bx-xcloud-router-history-ready';
@ -75,6 +78,8 @@ export namespace BxEvent {
target.dispatchEvent(event); target.dispatchEvent(event);
AppInterface && AppInterface.onEvent(eventName); AppInterface && AppInterface.onEvent(eventName);
BX_FLAGS.Debug && BxLogger.warning('BxEvent', 'dispatch', eventName, data)
} }
} }

View File

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

View File

@ -1,4 +1,5 @@
import iconBetterXcloud from "@assets/svg/better-xcloud.svg" with { type: "text" }; 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 iconClose from "@assets/svg/close.svg" with { type: "text" };
import iconCommand from "@assets/svg/command.svg" with { type: "text" }; import iconCommand from "@assets/svg/command.svg" with { type: "text" };
import iconController from "@assets/svg/controller.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 iconHome from "@assets/svg/home.svg" with { type: "text" };
import iconNativeMkb from "@assets/svg/native-mkb.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 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 iconQuestion from "@assets/svg/question.svg" with { type: "text" };
import iconRefresh from "@assets/svg/refresh.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 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 iconStreamSettings from "@assets/svg/stream-settings.svg" with { type: "text" };
import iconStreamStats from "@assets/svg/stream-stats.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" }; 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 = { export const BxIcon = {
BETTER_XCLOUD: iconBetterXcloud, BETTER_XCLOUD: iconBetterXcloud,
TRUE_ACHIEVEMENTS: iconTrueAchievements,
STREAM_SETTINGS: iconStreamSettings, STREAM_SETTINGS: iconStreamSettings,
STREAM_STATS: iconStreamStats, STREAM_STATS: iconStreamStats,
CLOSE: iconClose, CLOSE: iconClose,
@ -50,6 +54,7 @@ export const BxIcon = {
COPY: iconCopy, COPY: iconCopy,
TRASH: iconTrash, TRASH: iconTrash,
CURSOR_TEXT: iconCursorText, CURSOR_TEXT: iconCursorText,
POWER: iconPower,
QUESTION: iconQuestion, QUESTION: iconQuestion,
REFRESH: iconRefresh, REFRESH: iconRefresh,
VIRTUAL_CONTROLLER: iconVirtualController, VIRTUAL_CONTROLLER: iconVirtualController,
@ -60,6 +65,7 @@ export const BxIcon = {
CARET_LEFT: iconCaretLeft, CARET_LEFT: iconCaretLeft,
CARET_RIGHT: iconCaretRight, CARET_RIGHT: iconCaretRight,
SCREENSHOT: iconCamera, SCREENSHOT: iconCamera,
SPEAKER_MUTED: iconSpeakerSlash,
TOUCH_CONTROL_ENABLE: iconTouchControlEnable, TOUCH_CONTROL_ENABLE: iconTouchControlEnable,
TOUCH_CONTROL_DISABLE: iconTouchControlDisable, TOUCH_CONTROL_DISABLE: iconTouchControlDisable,

View File

@ -14,6 +14,7 @@ export enum ButtonStyle {
TALL = 256, TALL = 256,
CIRCULAR = 512, CIRCULAR = 512,
NORMAL_CASE = 1024, NORMAL_CASE = 1024,
NORMAL_LINK = 2048,
} }
const ButtonStyleClass = { const ButtonStyleClass = {
@ -28,6 +29,7 @@ const ButtonStyleClass = {
[ButtonStyle.TALL]: 'bx-tall', [ButtonStyle.TALL]: 'bx-tall',
[ButtonStyle.CIRCULAR]: 'bx-circular', [ButtonStyle.CIRCULAR]: 'bx-circular',
[ButtonStyle.NORMAL_CASE]: 'bx-normal-case', [ButtonStyle.NORMAL_CASE]: 'bx-normal-case',
[ButtonStyle.NORMAL_LINK]: 'bx-normal-link',
} }
type BxButton = { type BxButton = {
@ -172,3 +174,16 @@ export function removeChildElements($parent: HTMLElement) {
$parent.firstElementChild.remove(); $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 // Get data URL and pass to parent app
if (AppInterface) { if (AppInterface) {
const data = $canvas.toDataURL('image/png').split(';base64,')[1]; const data = $canvas.toDataURL('image/png').split(';base64,')[1];
AppInterface.saveScreenshot(currentStream.titleId, data); AppInterface.saveScreenshot(currentStream.titleSlug, data);
// Free screenshot from memory // Free screenshot from memory
canvasContext.clearRect(0, 0, $canvas.width, $canvas.height); canvasContext.clearRect(0, 0, $canvas.width, $canvas.height);
@ -84,7 +84,7 @@ export class Screenshot {
// Download screenshot // Download screenshot
const now = +new Date; const now = +new Date;
const $anchor = CE<HTMLAnchorElement>('a', { const $anchor = CE<HTMLAnchorElement>('a', {
'download': `${currentStream.titleId}-${now}.png`, 'download': `${currentStream.titleSlug}-${now}.png`,
'href': URL.createObjectURL(blob!), 'href': URL.createObjectURL(blob!),
}); });
$anchor.click(); $anchor.click();

View File

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

View File

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

View File

@ -113,6 +113,7 @@ const Texts = {
"fortnite-force-console-version": "Fortnite: force console version", "fortnite-force-console-version": "Fortnite: force console version",
"game-bar": "Game Bar", "game-bar": "Game Bar",
"getting-consoles-list": "Getting the list of consoles...", "getting-consoles-list": "Getting the list of consoles...",
"guide": "Guide",
"help": "Help", "help": "Help",
"hide": "Hide", "hide": "Hide",
"hide-idle-cursor": "Hide mouse cursor on idle", "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", "highest-quality-note": "Your device may not be powerful enough to use these settings",
"horizontal-scroll-sensitivity": "Horizontal scroll sensitivity", "horizontal-scroll-sensitivity": "Horizontal scroll sensitivity",
"horizontal-sensitivity": "Horizontal sensitivity", "horizontal-sensitivity": "Horizontal sensitivity",
"how-to-fix": "How to fix",
"how-to-improve-app-performance": "How to improve app's performance", "how-to-improve-app-performance": "How to improve app's performance",
"ignore": "Ignore", "ignore": "Ignore",
"import": "Import", "import": "Import",
@ -137,6 +139,7 @@ const Texts = {
"large": "Large", "large": "Large",
"layout": "Layout", "layout": "Layout",
"left-stick": "Left stick", "left-stick": "Left stick",
"load-failed-message": "Failed to run Better xCloud",
"loading-screen": "Loading screen", "loading-screen": "Loading screen",
"local-co-op": "Local co-op", "local-co-op": "Local co-op",
"low-power": "Low power", "low-power": "Low power",
@ -198,22 +201,22 @@ const Texts = {
(e: any) => `Recommended settings for ${e.device}`, (e: any) => `Recommended settings for ${e.device}`,
, ,
, ,
, (e: any) => `Empfohlene Einstellungen für ${e.device}`,
, ,
(e: any) => `Ajustes recomendados para ${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) => `Configurazioni consigliate per ${e.device}`,
, (e: any) => `${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) => `Рекомендовані налаштування для ${e.device}`,
(e: any) => `Cấu hình được đề xuất cho ${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", "reduce-animations": "Reduce UI animations",
"region": "Region", "region": "Region",
@ -228,7 +231,6 @@ const Texts = {
"rocket-always-show": "Always show", "rocket-always-show": "Always show",
"rocket-animation": "Rocket animation", "rocket-animation": "Rocket animation",
"rocket-hide-queue": "Hide when queuing", "rocket-hide-queue": "Hide when queuing",
"safari-failed-message": "Failed to run Better xCloud. Retrying, please wait...",
"saturation": "Saturation", "saturation": "Saturation",
"save": "Save", "save": "Save",
"screen": "Screen", "screen": "Screen",
@ -319,6 +321,7 @@ const Texts = {
], ],
"touch-controller": "Touch controller", "touch-controller": "Touch controller",
"transparent-background": "Transparent background", "transparent-background": "Transparent background",
"true-achievements": "TrueAchievements",
"ui": "UI", "ui": "UI",
"unexpected-behavior": "May cause unexpected behavior", "unexpected-behavior": "May cause unexpected behavior",
"united-states": "United States", "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; 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;
}
}