mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-06-29 10:51:44 +02:00
Compare commits
43 Commits
Author | SHA1 | Date | |
---|---|---|---|
714178a82d | |||
5c2c13e0e6 | |||
3f423325b9 | |||
870a205ead | |||
421af05882 | |||
756d105f74 | |||
4d90ebca68 | |||
1297230192 | |||
a45d0f8b98 | |||
821904066b | |||
15b7869e5d | |||
2ed4e23c87 | |||
e952bf07c8 | |||
8d44dab04d | |||
6a792548fa | |||
29f6413306 | |||
53d67616c3 | |||
03ad02bd4d | |||
110106aa97 | |||
7310700dbb | |||
5a0ef88237 | |||
a6e358479a | |||
4b02fec8ac | |||
93e3f1fa49 | |||
ae9a1a68d4 | |||
adf6b05c10 | |||
e0489d30bb | |||
9f46eca956 | |||
4888c399f0 | |||
e372db8dd9 | |||
5ba4a669e6 | |||
26b28564cc | |||
ad0be634d2 | |||
6f460302cf | |||
24f0cf18d9 | |||
2df8274233 | |||
a095370ab8 | |||
339447d29c | |||
efe0caf02f | |||
6daabea288 | |||
772a642283 | |||
675fc8431c | |||
9a97053662 |
13
build.ts
13
build.ts
@ -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();
|
||||
|
2
dist/better-xcloud.meta.js
vendored
2
dist/better-xcloud.meta.js
vendored
@ -1,5 +1,5 @@
|
||||
// ==UserScript==
|
||||
// @name Better xCloud
|
||||
// @namespace https://github.com/redphx
|
||||
// @version 5.6.0
|
||||
// @version 5.7.2
|
||||
// ==/UserScript==
|
||||
|
648
dist/better-xcloud.user.js
vendored
648
dist/better-xcloud.user.js
vendored
File diff suppressed because one or more lines are too long
@ -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"
|
||||
}
|
||||
}
|
||||
|
65
src/assets/css/guide-menu.styl
Normal file
65
src/assets/css/guide-menu.styl
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
3
src/assets/svg/power.svg
Normal 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 |
3
src/assets/svg/speaker-slash.svg
Normal file
3
src/assets/svg/speaker-slash.svg
Normal 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 |
3
src/assets/svg/true-achievements.svg
Normal file
3
src/assets/svg/true-achievements.svg
Normal 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 |
129
src/index.ts
129
src/index.ts
@ -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();
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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 {
|
||||
|
54
src/modules/game-bar/action-speaker.ts
Normal file
54
src/modules/game-bar/action-speaker.ts
Normal 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';
|
||||
}
|
||||
}
|
@ -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', {},
|
||||
|
30
src/modules/game-bar/action-true-achievements.ts
Normal file
30
src/modules/game-bar/action-true-achievements.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
46
src/types/index.d.ts
vendored
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
||||
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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",
|
||||
|
150
src/utils/true-achievements.ts
Normal file
150
src/utils/true-achievements.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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
25
src/utils/xbox-api.ts
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user