From a6e358479aff55861dd635e5f3b2e8d25d3a8c2e Mon Sep 17 00:00:00 2001
From: redphx <96280+redphx@users.noreply.github.com>
Date: Tue, 3 Sep 2024 16:56:58 +0700
Subject: [PATCH] Integrate TrueAchievements
---
src/assets/css/guide-menu.styl | 22 +++--
src/assets/css/root.styl | 7 ++
src/assets/svg/power.svg | 3 +
src/assets/svg/true-achievements.svg | 3 +
src/index.ts | 76 ++++++++++++-----
src/modules/game-bar/action-microphone.ts | 8 +-
src/modules/game-bar/action-screenshot.ts | 16 ++--
.../game-bar/action-true-achievements.ts | 30 +++++++
src/modules/game-bar/game-bar.ts | 4 +-
src/modules/mkb/mkb-handler.ts | 2 +-
src/modules/mkb/native-mkb-handler.ts | 2 +-
src/modules/patcher.ts | 2 +-
src/modules/ui/guide-menu.ts | 66 +++++++++------
src/modules/ui/ui.ts | 2 +-
src/types/index.d.ts | 46 ++++++++++-
src/utils/bx-icon.ts | 4 +
src/utils/html.ts | 2 +
src/utils/screenshot.ts | 4 +-
src/utils/translation.ts | 1 +
src/utils/true-achievements.ts | 81 +++++++++++++++++++
src/utils/utils.ts | 9 +++
src/utils/xbox-api.ts | 25 ++++++
22 files changed, 344 insertions(+), 71 deletions(-)
create mode 100644 src/assets/svg/power.svg
create mode 100644 src/assets/svg/true-achievements.svg
create mode 100644 src/modules/game-bar/action-true-achievements.ts
create mode 100644 src/utils/true-achievements.ts
create mode 100644 src/utils/xbox-api.ts
diff --git a/src/assets/css/guide-menu.styl b/src/assets/css/guide-menu.styl
index 8335d47..fc2210a 100644
--- a/src/assets/css/guide-menu.styl
+++ b/src/assets/css/guide-menu.styl
@@ -1,11 +1,19 @@
-.bx-guide-home-buttons[data-is-playing="true"] {
- button[data-state='normal'] {
- display: none;
+.bx-guide-home-buttons {
+ > div {
+ display: flex;
+ flex-direction: row;
+ gap: 12px;
}
-}
-.bx-guide-home-buttons[data-is-playing="false"] {
- button[data-state='playing'] {
- display: none;
+ &[data-is-playing="true"] {
+ button[data-state='normal'] {
+ display: none;
+ }
+ }
+
+ &[data-is-playing="false"] {
+ button[data-state='playing'] {
+ display: none;
+ }
}
}
diff --git a/src/assets/css/root.styl b/src/assets/css/root.styl
index b974bd4..c0ec2d3 100644
--- a/src/assets/css/root.styl
+++ b/src/assets/css/root.styl
@@ -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;
}
diff --git a/src/assets/svg/power.svg b/src/assets/svg/power.svg
new file mode 100644
index 0000000..78dde0a
--- /dev/null
+++ b/src/assets/svg/power.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/svg/true-achievements.svg b/src/assets/svg/true-achievements.svg
new file mode 100644
index 0000000..ff725a1
--- /dev/null
+++ b/src/assets/svg/true-achievements.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/index.ts b/src/index.ts
index 345c9ce..bd77bfb 100644
--- a/src/index.ts
+++ b/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";
@@ -39,6 +39,8 @@ 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";
+import { TrueAchievements } from "./utils/true-achievements";
// Handle login page
@@ -198,15 +200,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\/(?[^\/]+)\/(?\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';
}
});
@@ -252,6 +249,38 @@ window.addEventListener(BxEvent.XCLOUD_RENDERING_COMPONENT, e => {
}
});
+// 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;
@@ -288,7 +317,7 @@ window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, e => {
function observeRootDialog($root: HTMLElement) {
- let currentShown = false;
+ let beingShown = false;
const observer = new MutationObserver(mutationList => {
for (const mutation of mutationList) {
@@ -296,21 +325,28 @@ function observeRootDialog($root: HTMLElement) {
continue;
}
+ console.log('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++);
+ // Achievement Details page
+ const $achievDetailPage = $addedElm.querySelector('div[class*=AchievementDetailPage]');
+ if ($achievDetailPage) {
+ TrueAchievements.injectAchievementDetailPage($achievDetailPage as HTMLElement);
+ } else {
+ // 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});
+ if (index === 0) {
+ BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_MENU_SHOWN, {where: GuideMenuTab.HOME});
+ }
}
}
}
@@ -319,8 +355,8 @@ function observeRootDialog($root: HTMLElement) {
}
const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false;
- if (shown !== currentShown) {
- currentShown = shown;
+ if (shown !== beingShown) {
+ beingShown = shown;
BxEvent.dispatch(window, shown ? BxEvent.XCLOUD_DIALOG_SHOWN : BxEvent.XCLOUD_DIALOG_DISMISSED);
}
}
diff --git a/src/modules/game-bar/action-microphone.ts b/src/modules/game-bar/action-microphone.ts
index 6037a48..3010749 100644
--- a/src/modules/game-bar/action-microphone.ts
+++ b/src/modules/game-bar/action-microphone.ts
@@ -15,11 +15,11 @@ export class MicrophoneAction extends BaseGameBarAction {
super();
const onClick = (e: Event) => {
- BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
+ BxEvent.dispatch(window, BxEvent.GAME_BAR_ACTION_ACTIVATED);
- const enabled = MicrophoneShortcut.toggle(false);
- this.$content.setAttribute('data-enabled', enabled.toString());
- };
+ const enabled = MicrophoneShortcut.toggle(false);
+ this.$content.setAttribute('data-enabled', enabled.toString());
+ };
const $btnDefault = createButton({
style: ButtonStyle.GHOST,
diff --git a/src/modules/game-bar/action-screenshot.ts b/src/modules/game-bar/action-screenshot.ts
index da06056..9b92198 100644
--- a/src/modules/game-bar/action-screenshot.ts
+++ b/src/modules/game-bar/action-screenshot.ts
@@ -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 {
diff --git a/src/modules/game-bar/action-true-achievements.ts b/src/modules/game-bar/action-true-achievements.ts
new file mode 100644
index 0000000..08a0101
--- /dev/null
+++ b/src/modules/game-bar/action-true-achievements.ts
@@ -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;
+ }
+}
diff --git a/src/modules/game-bar/game-bar.ts b/src/modules/game-bar/game-bar.ts
index fc1d7f4..b7be891 100644
--- a/src/modules/game-bar/game-bar.ts
+++ b/src/modules/game-bar/game-bar.ts
@@ -8,6 +8,7 @@ import { STATES } from "@utils/global";
import { MicrophoneAction } from "./action-microphone";
import { PrefKey } from "@/enums/pref-keys";
import { getPref, StreamTouchController } from "@/utils/settings-storages/global-settings-storage";
+import { TrueAchievementsAction } from "./action-true-achievements";
export class GameBar {
@@ -42,6 +43,7 @@ export class GameBar {
this.actions = [
new ScreenshotAction(),
...(STATES.userAgent.capabilities.touch && (getPref(PrefKey.STREAM_TOUCH_CONTROLLER) !== StreamTouchController.OFF) ? [new TouchControlAction()] : []),
+ new TrueAchievementsAction(),
new MicrophoneAction(),
];
@@ -92,7 +94,7 @@ export class GameBar {
// Toggle Game bar
const mode = (e as any).mode;
- mode !== 'None' ? this.disable() : this.enable();
+ mode !== 'none' ? this.disable() : this.enable();
}).bind(this));
}
diff --git a/src/modules/mkb/mkb-handler.ts b/src/modules/mkb/mkb-handler.ts
index 6a371f0..22b6d99 100644
--- a/src/modules/mkb/mkb-handler.ts
+++ b/src/modules/mkb/mkb-handler.ts
@@ -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');
diff --git a/src/modules/mkb/native-mkb-handler.ts b/src/modules/mkb/native-mkb-handler.ts
index a292fdb..2023f91 100644
--- a/src/modules/mkb/native-mkb-handler.ts
+++ b/src/modules/mkb/native-mkb-handler.ts
@@ -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');
diff --git a/src/modules/patcher.ts b/src/modules/patcher.ts
index 1466f27..7865e97 100644
--- a/src/modules/patcher.ts
+++ b/src/modules/patcher.ts
@@ -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;
diff --git a/src/modules/ui/guide-menu.ts b/src/modules/ui/guide-menu.ts
index 47e5ab1..17c79b9 100644
--- a/src/modules/ui/guide-menu.ts
+++ b/src/modules/ui/guide-menu.ts
@@ -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,31 +26,19 @@ export class GuideMenu {
},
}),
- appSettings: createButton({
- label: t('app-settings'),
- style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
- onClick: e => {
- // Close all xCloud's dialogs
- window.BX_EXPOSED.dialogRoutes.closeAll();
-
- AppInterface.openAppSettings && AppInterface.openAppSettings();
- },
- }),
-
closeApp: createButton({
- label: t('close-app'),
+ icon: BxIcon.POWER,
+ title: t('close-app'),
style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.DANGER,
onClick: e => {
AppInterface.closeApp();
},
- attributes: {
- 'data-state': 'normal',
- },
}),
reloadPage: createButton({
- label: t('reload-page'),
- style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
+ icon: BxIcon.REFRESH,
+ title: t('reload-page'),
+ style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.GHOST,
onClick: e => {
if (STATES.isPlaying) {
confirm(t('confirm-reload-stream')) && window.location.reload();
@@ -62,15 +52,30 @@ export class GuideMenu {
}),
backToHome: createButton({
- label: t('back-to-home'),
- style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
+ icon: BxIcon.HOME,
+ title: t('back-to-home'),
+ style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE | ButtonStyle.GHOST,
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',
},
}),
+
+ trueAchievements: createButton({
+ label: t('true-achievements'),
+ style: ButtonStyle.FULL_WIDTH | ButtonStyle.FOCUSABLE,
+ onClick: e => {
+ TrueAchievements.open(false);
+
+ // Close all xCloud's dialogs
+ window.BX_EXPOSED.dialogRoutes.closeAll();
+ },
+ }),
}
static #$renderedButtons: HTMLElement;
@@ -86,13 +91,28 @@ export class GuideMenu {
const buttons = [
GuideMenu.#BUTTONS.scriptSettings,
- GuideMenu.#BUTTONS.reloadPage,
- GuideMenu.#BUTTONS.backToHome,
- AppInterface && GuideMenu.#BUTTONS.closeApp,
+ GuideMenu.#BUTTONS.trueAchievements,
+ [
+ GuideMenu.#BUTTONS.backToHome,
+ GuideMenu.#BUTTONS.reloadPage,
+ AppInterface && GuideMenu.#BUTTONS.closeApp,
+ ],
];
for (const $button of buttons) {
- $button && $div.appendChild($button);
+ 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;
diff --git a/src/modules/ui/ui.ts b/src/modules/ui/ui.ts
index d50d80e..2c3d6a6 100644
--- a/src/modules/ui/ui.ts
+++ b/src/modules/ui/ui.ts
@@ -11,7 +11,7 @@ export function localRedirect(path: string) {
const $anchor = CE('a', {
href: url,
- class: 'bx-hidden bx-offscreen'
+ class: 'bx-hidden bx-offscreen',
}, '');
$anchor.addEventListener('click', e => {
// Remove element after clicking on it
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 9fca0ce..0df97ec 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -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;
+ }
+};
diff --git a/src/utils/bx-icon.ts b/src/utils/bx-icon.ts
index b76e594..b115a68 100644
--- a/src/utils/bx-icon.ts
+++ b/src/utils/bx-icon.ts
@@ -1,4 +1,5 @@
import iconBetterXcloud from "@assets/svg/better-xcloud.svg" with { type: "text" };
+import iconTrueAchievements from "@assets/svg/true-achievements.svg" with { type: "text" };
import iconClose from "@assets/svg/close.svg" with { type: "text" };
import iconCommand from "@assets/svg/command.svg" with { type: "text" };
import iconController from "@assets/svg/controller.svg" with { type: "text" };
@@ -9,6 +10,7 @@ import iconDisplay from "@assets/svg/display.svg" with { type: "text" };
import iconHome from "@assets/svg/home.svg" with { type: "text" };
import iconNativeMkb from "@assets/svg/native-mkb.svg" with { type: "text" };
import iconNew from "@assets/svg/new.svg" with { type: "text" };
+import iconPower from "@assets/svg/power.svg" with { type: "text" };
import iconQuestion from "@assets/svg/question.svg" with { type: "text" };
import iconRefresh from "@assets/svg/refresh.svg" with { type: "text" };
import iconRemotePlay from "@assets/svg/remote-play.svg" with { type: "text" };
@@ -37,6 +39,7 @@ import iconUpload from "@assets/svg/upload.svg" with { type: "text" };
export const BxIcon = {
BETTER_XCLOUD: iconBetterXcloud,
+ TRUE_ACHIEVEMENTS: iconTrueAchievements,
STREAM_SETTINGS: iconStreamSettings,
STREAM_STATS: iconStreamStats,
CLOSE: iconClose,
@@ -50,6 +53,7 @@ export const BxIcon = {
COPY: iconCopy,
TRASH: iconTrash,
CURSOR_TEXT: iconCursorText,
+ POWER: iconPower,
QUESTION: iconQuestion,
REFRESH: iconRefresh,
VIRTUAL_CONTROLLER: iconVirtualController,
diff --git a/src/utils/html.ts b/src/utils/html.ts
index d158791..9a7b6c6 100644
--- a/src/utils/html.ts
+++ b/src/utils/html.ts
@@ -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 = {
diff --git a/src/utils/screenshot.ts b/src/utils/screenshot.ts
index e1f7243..b68e5fb 100644
--- a/src/utils/screenshot.ts
+++ b/src/utils/screenshot.ts
@@ -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('a', {
- 'download': `${currentStream.titleId}-${now}.png`,
+ 'download': `${currentStream.titleSlug}-${now}.png`,
'href': URL.createObjectURL(blob!),
});
$anchor.click();
diff --git a/src/utils/translation.ts b/src/utils/translation.ts
index 6b956be..bb27696 100644
--- a/src/utils/translation.ts
+++ b/src/utils/translation.ts
@@ -321,6 +321,7 @@ const Texts = {
],
"touch-controller": "Touch controller",
"transparent-background": "Transparent background",
+ "true-achievements": "TrueAchievements",
"ui": "UI",
"unexpected-behavior": "May cause unexpected behavior",
"united-states": "United States",
diff --git a/src/utils/true-achievements.ts b/src/utils/true-achievements.ts
new file mode 100644
index 0000000..289dcb3
--- /dev/null
+++ b/src/utils/true-achievements.ts
@@ -0,0 +1,81 @@
+import { BX_FLAGS } from "./bx-flags";
+import { AppInterface, STATES } from "./global";
+import { ButtonStyle, CE, createButton, getReactProps } from "./html";
+import { t } from "./translation";
+
+export class TrueAchievements {
+ private static $taLink = createButton({
+ label: t('true-achievements'),
+ url: 'https://www.trueachievements.com',
+ style: ButtonStyle.FOCUSABLE | ButtonStyle.GHOST | ButtonStyle.FULL_WIDTH | ButtonStyle.NORMAL_LINK,
+ onClick: e => {
+ e.preventDefault();
+
+ const dataset = TrueAchievements.$taLink.dataset;
+ TrueAchievements.open(true, dataset.xboxTitleId, dataset.id);
+ },
+ }) as HTMLAnchorElement;
+
+ private static $hiddenLink = CE('a', {
+ target: '_blank',
+ });
+
+ 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.$taLink.dataset.xboxTitleId = xboxTitleId;
+ TrueAchievements.$taLink.dataset.id = id;
+
+ TrueAchievements.$taLink.href = `https://www.trueachievements.com/deeplink/${xboxTitleId}/${id}`;
+ $parent.appendChild(TrueAchievements.$taLink);
+ }
+ } catch (e) {};
+ }
+
+ static open(override: boolean, xboxTitleId?: number | string, id?: number | string) {
+ if (!xboxTitleId) {
+ xboxTitleId = STATES.currentStream.xboxTitleId || STATES.currentStream.titleInfo?.details.xboxTitleId;
+ }
+
+ if (AppInterface && AppInterface.openTrueAchievementsLink) {
+ AppInterface.openTrueAchievementsLink(override, xboxTitleId?.toString(), id?.toString());
+ return;
+ }
+
+ let url = 'https://www.trueachievements.com';
+ if (xboxTitleId) {
+ if (id) {
+ url += `/deeplink/${xboxTitleId}/${id}`;
+ } else {
+ url += `/deeplink/${xboxTitleId}`;
+ }
+ }
+
+ TrueAchievements.$hiddenLink.href = url;
+ TrueAchievements.$hiddenLink.click();
+ }
+}
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index 8f265f7..3c8c7bb 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -110,3 +110,12 @@ export async function copyToClipboard(text: string, showToast=true): Promise = {};
+
+ static async getProductTitle(xboxTitleId: number | string): Promise {
+ 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;
+ }
+}