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; + } +}