From 1d55026c6d8a07234772a1152f031ed27e9b3ee8 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Sun, 14 Jul 2024 16:44:18 +0700 Subject: [PATCH] Add option to show wait time in game card --- src/assets/css/root.styl | 32 ++++++++++++ src/index.ts | 12 ++--- src/modules/patcher.ts | 2 +- src/modules/ui/game-tile.ts | 85 +++++++++++++++++++++++++++++++ src/modules/ui/global-settings.ts | 1 + src/types/index.d.ts | 13 ++++- src/utils/global.ts | 4 +- src/utils/html.ts | 10 ++++ src/utils/preferences.ts | 8 ++- src/utils/translation.ts | 1 + src/utils/xcloud-api.ts | 79 ++++++++++++++++++++++++++++ src/utils/xcloud-interceptor.ts | 7 +++ 12 files changed, 242 insertions(+), 12 deletions(-) create mode 100644 src/modules/ui/game-tile.ts create mode 100644 src/utils/xcloud-api.ts diff --git a/src/assets/css/root.styl b/src/assets/css/root.styl index 2998312..d84110e 100644 --- a/src/assets/css/root.styl +++ b/src/assets/css/root.styl @@ -132,3 +132,35 @@ div[class*=SupportedInputsBadge] { display: none; } } + + +.bx-game-tile-wait-time { + position: absolute; + top: 0; + left: 0; + z-index: 1; + background: #0000008c; + display: none; + border-radius: 0 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; + margin-right: 2px; + } + + span { + display: inline-block; + height: 16px; + line-height: 16px; + font-size: 12px; + font-weight: bold; + } +} diff --git a/src/index.ts b/src/index.ts index 46e9b20..1115dbc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { StreamSettings } from "./modules/stream/stream-settings"; import { updateVideoPlayer } from "./modules/stream/stream-settings-utils"; import { UiSection } from "./enums/ui-sections"; import { HeaderSection } from "./modules/ui/header"; +import { GameTile } from "./modules/ui/game-tile"; // Handle login page @@ -186,14 +187,6 @@ window.addEventListener(BxEvent.STREAM_ERROR_PAGE, e => { BxEvent.dispatch(window, BxEvent.STREAM_STOPPED); }); -window.addEventListener(BxEvent.NAVIGATION_FOCUS_CHANGED, e => { - const $elm = (e as any).element; - if (($elm.tagName === 'BUTTON' && $elm.className.includes('MruGameCard')) || ($elm.tagName === 'A' && $elm.className.includes('GameCard'))) { - console.dir($elm); - } -}); - - function unload() { if (!STATES.isPlaying) { return; @@ -351,6 +344,9 @@ function main() { // Preload Remote Play getPref(PrefKey.REMOTE_PLAY_ENABLED) && BX_FLAGS.PreloadRemotePlay && RemotePlay.preload(); + + // Show wait time in game card + getPref(PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME) && GameTile.setup(); } main(); diff --git a/src/modules/patcher.ts b/src/modules/patcher.ts index 28d7089..05a34ab 100644 --- a/src/modules/patcher.ts +++ b/src/modules/patcher.ts @@ -810,7 +810,7 @@ let PATCH_ORDERS: PatchArray = [ 'enableTvRoutes', 'overrideStorageGetSettings', - // 'patchSetCurrentlyFocusedInteractable', + getPref(PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME) && 'patchSetCurrentlyFocusedInteractable', getPref(PrefKey.UI_LAYOUT) !== 'default' && 'websiteLayout', getPref(PrefKey.LOCAL_CO_OP_ENABLED) && 'supportLocalCoOp', diff --git a/src/modules/ui/game-tile.ts b/src/modules/ui/game-tile.ts new file mode 100644 index 0000000..5fab72e --- /dev/null +++ b/src/modules/ui/game-tile.ts @@ -0,0 +1,85 @@ +import { BxEvent } from "@/utils/bx-event"; +import { BxIcon } from "@/utils/bx-icon"; +import { CE, createSvgIcon, getReactProps } from "@/utils/html"; +import { XcloudApi } from "@/utils/xcloud-api"; + +export class GameTile { + static #timeout: number | null; + + static #secondsToHms(seconds: number) { + let h = Math.floor(seconds / 3600); + seconds %= 3600; + let m = Math.floor(seconds / 60); + let s = seconds % 60; + + const output = []; + h > 0 && output.push(`${h}h`); + m > 0 && output.push(`${m}m`); + output.push(`${s}s`); + + return output.join(' '); + } + + static async #showWaitTime($elm: HTMLElement, productId: string) { + let totalWaitTime; + + const api = XcloudApi.getInstance(); + const info = await api.getTitleInfo(productId); + if (info) { + const waitTime = await api.getWaitTime(info.titleId); + if (waitTime) { + totalWaitTime = waitTime.estimatedTotalWaitTimeInSeconds || 0; + } + } + + if (totalWaitTime && totalWaitTime == 10 && $elm.isConnected) { + const $div = CE('div', {'class': 'bx-game-tile-wait-time'}, + createSvgIcon(BxIcon.PLAYTIME), + CE('span', {}, GameTile.#secondsToHms(totalWaitTime)), + ); + $elm.insertAdjacentElement('afterbegin', $div); + } + } + + 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); + } + }, 1000); + } + + 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; + } + } + } + } catch (e) {} + + productId && GameTile.requestWaitTime($elm, productId); + }); + } +} diff --git a/src/modules/ui/global-settings.ts b/src/modules/ui/global-settings.ts index 1f487c3..9bb1fec 100644 --- a/src/modules/ui/global-settings.ts +++ b/src/modules/ui/global-settings.ts @@ -90,6 +90,7 @@ const SETTINGS_UI = { items: [ PrefKey.UI_LAYOUT, PrefKey.UI_HOME_CONTEXT_MENU_DISABLED, + PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME, PrefKey.CONTROLLER_SHOW_CONNECTION_STATUS, PrefKey.STREAM_SIMPLIFY_MENU, PrefKey.SKIP_SPLASH_VIDEO, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 5f501a3..055fa51 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -25,10 +25,13 @@ interface NavigatorBattery extends Navigator { type BxStates = { supportedRegion: boolean; + serverRegions: any; + selectedRegion: any; + gsToken: string; + isPlaying: boolean; appContext: any | null; - serverRegions: any; browser: { capabilities: { @@ -71,6 +74,8 @@ type BxStates = { type DualEnum = {[index: string]: number} & {[index: number]: string}; type XcloudTitleInfo = { + titleId: string, + details: { productId: string; supportedInputTypes: InputType[]; @@ -88,6 +93,12 @@ type XcloudTitleInfo = { }; }; +type XcloudWaitTimeInfo = Partial<{ + estimatedAllocationTimeInSeconds: number, + estimatedProvisioningTimeInSeconds: number, + estimatedTotalWaitTimeInSeconds: number, +}>; + declare module '*.js'; declare module '*.svg'; declare module '*.styl'; diff --git a/src/utils/global.ts b/src/utils/global.ts index 0a8da91..3551466 100644 --- a/src/utils/global.ts +++ b/src/utils/global.ts @@ -14,10 +14,12 @@ const userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport; export const STATES: BxStates = { supportedRegion: true, + serverRegions: {}, + selectedRegion: {}, + gsToken: '', isPlaying: false, appContext: {}, - serverRegions: {}, browser: { capabilities: { diff --git a/src/utils/html.ts b/src/utils/html.ts index 42259ac..f970248 100644 --- a/src/utils/html.ts +++ b/src/utils/html.ts @@ -104,6 +104,16 @@ export const createButton = (options: BxButton): T => { return $btn as T; } +export function getReactProps($elm: HTMLElement): any | null { + for (const key in $elm) { + if (key.startsWith('__reactProps')) { + return ($elm as any)[key]; + } + } + + return null; +} + export function escapeHtml(html: string): string { const text = document.createTextNode(html); const $span = document.createElement('span'); diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts index f764141..58afd78 100644 --- a/src/utils/preferences.ts +++ b/src/utils/preferences.ts @@ -1,5 +1,5 @@ import { CE } from "@utils/html"; -import { SUPPORTED_LANGUAGES, t, ut } from "@utils/translation"; +import { SUPPORTED_LANGUAGES, t} from "@utils/translation"; import { SettingElement, SettingElementType } from "@utils/settings"; import { UserAgent } from "@utils/user-agent"; import { StreamStat } from "@modules/stream/stream-stats"; @@ -76,6 +76,7 @@ export enum PrefKey { UI_HIDE_SECTIONS = 'ui_hide_sections', UI_HOME_CONTEXT_MENU_DISABLED = 'ui_home_context_menu_disabled', + UI_GAME_CARD_SHOW_WAIT_TIME = 'ui_game_card_show_wait_time', VIDEO_PLAYER_TYPE = 'video_player_type', VIDEO_PROCESSING = 'video_processing', @@ -581,6 +582,11 @@ export class Preferences { }, }, + [PrefKey.UI_GAME_CARD_SHOW_WAIT_TIME]: { + label: t('show-wait-time-in-game-card'), + default: false, + }, + [PrefKey.BLOCK_SOCIAL_FEATURES]: { label: t('disable-social-features'), default: false, diff --git a/src/utils/translation.ts b/src/utils/translation.ts index b89b8bd..f052af5 100644 --- a/src/utils/translation.ts +++ b/src/utils/translation.ts @@ -212,6 +212,7 @@ const Texts = { "show-stats-on-startup": "Show stats when starting the game", "show-touch-controller": "Show touch controller", "show-wait-time": "Show the estimated wait time", + "show-wait-time-in-game-card": "Show wait time in game card", "simplify-stream-menu": "Simplify Stream's menu", "skip-splash-video": "Skip Xbox splash video", "slow": "Slow", diff --git a/src/utils/xcloud-api.ts b/src/utils/xcloud-api.ts new file mode 100644 index 0000000..2c96b65 --- /dev/null +++ b/src/utils/xcloud-api.ts @@ -0,0 +1,79 @@ +import { NATIVE_FETCH } from "./bx-flags"; +import { STATES } from "./global"; + +export class XcloudApi { + private static instance: XcloudApi; + + public static getInstance(): XcloudApi { + if (!XcloudApi.instance) { + XcloudApi.instance = new XcloudApi(); + } + + return XcloudApi.instance; + } + + #CACHE_TITLES: {[key: string]: XcloudTitleInfo} = {}; + #CACHE_WAIT_TIME: {[key: string]: XcloudWaitTimeInfo} = {}; + + async getTitleInfo(id: string): Promise { + if (id in this.#CACHE_TITLES) { + return this.#CACHE_TITLES[id]; + } + + const baseUri = STATES.selectedRegion.baseUri; + if (!baseUri || !STATES.gsToken) { + return null; + } + + let json; + try { + const response = await NATIVE_FETCH(`${baseUri}/v2/titles`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${STATES.gsToken}`, + 'Content-Type': 'application/json', + }, + + // format the data + body: JSON.stringify({ + alternateIds: [id], + alternateIdType: 'productId', + }), + }); + + json = (await response.json()).results[0]; + } catch (e) { + json = {} + } + this.#CACHE_TITLES[id] = json; + return json; + } + + async getWaitTime(id: string): Promise { + if (id in this.#CACHE_WAIT_TIME) { + return this.#CACHE_WAIT_TIME[id]; + } + + const baseUri = STATES.selectedRegion.baseUri; + if (!baseUri || !STATES.gsToken) { + return null; + } + + let json; + try { + const response = await NATIVE_FETCH(`${baseUri}/v1/waittime/${id}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${STATES.gsToken}`, + }, + }); + + json = await response.json(); + } catch (e) { + json = {}; + } + + this.#CACHE_WAIT_TIME[id] = json; + return json; + } +} diff --git a/src/utils/xcloud-interceptor.ts b/src/utils/xcloud-interceptor.ts index 2c5b9d8..79aaa80 100644 --- a/src/utils/xcloud-interceptor.ts +++ b/src/utils/xcloud-interceptor.ts @@ -52,6 +52,10 @@ class XcloudInterceptor { const regionName = region.name as keyof typeof serverEmojis; let shortName = region.name; + if (region.isDefault) { + STATES.selectedRegion = Object.assign({}, region); + } + let match = serverRegex.exec(region.baseUri); if (match) { shortName = match[1]; @@ -72,8 +76,11 @@ class XcloudInterceptor { tmp.isDefault = true; obj.offeringSettings.regions = [tmp]; + STATES.selectedRegion = tmp; } + STATES.gsToken = obj.gsToken; + response.json = () => Promise.resolve(obj); return response; }