mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-07-05 13:51:43 +02:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
55a56837c8 | |||
df713136d8 | |||
29dfdaf72e | |||
04cf66a466 | |||
1d55026c6d | |||
fcfecf7ff9 | |||
5e22bf097a | |||
542079d53e |
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.2.0
|
||||
// @version 5.3.0
|
||||
// ==/UserScript==
|
||||
|
166
dist/better-xcloud.user.js
vendored
166
dist/better-xcloud.user.js
vendored
File diff suppressed because one or more lines are too long
@ -50,6 +50,7 @@
|
||||
display: block;
|
||||
flex: 1;
|
||||
text-transform: none;
|
||||
margin-right: 10px;
|
||||
|
||||
span {
|
||||
color: #5dc21e !important;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
12
src/index.ts
12
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();
|
||||
|
@ -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',
|
||||
|
87
src/modules/ui/game-tile.ts
Normal file
87
src/modules/ui/game-tile.ts
Normal file
@ -0,0 +1,87 @@
|
||||
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`);
|
||||
if (s > 0 || output.length === 0) {
|
||||
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.estimatedAllocationTimeInSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof totalWaitTime === 'number' && $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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -38,6 +38,7 @@ export class HeaderSection {
|
||||
);
|
||||
|
||||
static #observer: MutationObserver;
|
||||
static #timeout: number | null;
|
||||
|
||||
static #injectSettingsButton($parent?: HTMLElement) {
|
||||
if (!$parent) {
|
||||
@ -60,9 +61,7 @@ export class HeaderSection {
|
||||
}
|
||||
|
||||
static checkHeader() {
|
||||
const $button = document.querySelector('.bx-header-settings-button');
|
||||
|
||||
if (!$button) {
|
||||
if (!HeaderSection.#$buttonsWrapper.isConnected) {
|
||||
const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]');
|
||||
HeaderSection.#injectSettingsButton($rightHeader as HTMLElement);
|
||||
}
|
||||
@ -78,12 +77,10 @@ export class HeaderSection {
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout: number | null;
|
||||
|
||||
HeaderSection.#observer && HeaderSection.#observer.disconnect();
|
||||
HeaderSection.#observer = new MutationObserver(mutationList => {
|
||||
timeout && clearTimeout(timeout);
|
||||
timeout = window.setTimeout(HeaderSection.checkHeader, 2000);
|
||||
HeaderSection.#timeout && clearTimeout(HeaderSection.#timeout);
|
||||
HeaderSection.#timeout = window.setTimeout(HeaderSection.checkHeader, 2000);
|
||||
});
|
||||
HeaderSection.#observer.observe($header, {subtree: true, childList: true});
|
||||
|
||||
|
13
src/types/index.d.ts
vendored
13
src/types/index.d.ts
vendored
@ -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';
|
||||
|
@ -14,10 +14,12 @@ const userAgentHasTouchSupport = !isTv && !isVr && browserHasTouchSupport;
|
||||
|
||||
export const STATES: BxStates = {
|
||||
supportedRegion: true,
|
||||
serverRegions: {},
|
||||
selectedRegion: {},
|
||||
gsToken: '',
|
||||
|
||||
isPlaying: false,
|
||||
appContext: {},
|
||||
serverRegions: {},
|
||||
|
||||
browser: {
|
||||
capabilities: {
|
||||
|
@ -104,6 +104,16 @@ export const createButton = <T=HTMLButtonElement>(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');
|
||||
|
@ -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',
|
||||
@ -125,8 +126,8 @@ export class Preferences {
|
||||
default: 'default',
|
||||
},
|
||||
[PrefKey.SERVER_BYPASS_RESTRICTION]: {
|
||||
label: ut('Bypass region restriction'),
|
||||
note: ut('⚠️ Use this at your own risk'),
|
||||
label: t('bypass-region-restriction'),
|
||||
note: t('use-this-at-your-own-risk'),
|
||||
default: false,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
@ -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",
|
||||
|
79
src/utils/xcloud-api.ts
Normal file
79
src/utils/xcloud-api.ts
Normal file
@ -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<XcloudTitleInfo | null> {
|
||||
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<XcloudWaitTimeInfo | null> {
|
||||
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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user