// ==UserScript== // @name Better xCloud // @namespace https://github.com/redphx // @version 1.14.1 // @description Improve Xbox Cloud Gaming (xCloud) experience // @author redphx // @license MIT // @match https://www.xbox.com/*/play* // @run-at document-start // @grant none // @updateURL https://raw.githubusercontent.com/redphx/better-xcloud/main/better-xcloud.meta.js // @downloadURL https://github.com/redphx/better-xcloud/releases/latest/download/better-xcloud.user.js // ==/UserScript== 'use strict'; const SCRIPT_VERSION = '1.14.1'; const SCRIPT_HOME = 'https://github.com/redphx/better-xcloud'; console.log(`[Better xCloud] readyState: ${document.readyState}`); // Quickly create a tree of elements without having to use innerHTML function createElement(elmName, props = {}) { let $elm; const hasNs = 'xmlns' in props; if (hasNs) { $elm = document.createElementNS(props.xmlns, elmName); } else { $elm = document.createElement(elmName); } for (let key in props) { if (key === 'xmlns') { continue; } if (!props.hasOwnProperty(key) || $elm.hasOwnProperty(key)) { continue; } if (hasNs) { $elm.setAttributeNS(null, key, props[key]); } else { $elm.setAttribute(key, props[key]); } } for (let i = 2, size = arguments.length; i < size; i++) { const arg = arguments[i]; const argType = typeof arg; if (argType === 'string' || argType === 'number') { $elm.textContent = arg; } else if (arg) { $elm.appendChild(arg); } } return $elm; } const ENABLE_SAFARI_WORKAROUND = true; if (ENABLE_SAFARI_WORKAROUND && document.readyState !== 'loading') { // Stop loading window.stop(); // Show the reloading overlay const $elm = createElement('div', {'class': 'better-xcloud-reload-overlay'}, 'Failed to run Better xCloud. Retrying, please wait...'); const css = ` .better-xcloud-reload-overlay { position: fixed; top: 0; background: #000000cc; z-index: 9999; width: 100%; line-height: 100vh; color: #fff; text-align: center; font-weight: 400; font-family: "Segoe UI", SegoeUI, "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1.3rem; } `; document.documentElement.appendChild(createElement('style', {}, css)); document.documentElement.appendChild($elm); // Reload the page window.location.reload(true); // Stop processing the script throw new Error('[Better xCloud] Executing workaround for Safari'); } // Automatically reload the page when running into the "We are sorry..." error message window.addEventListener('load', e => { setTimeout(() => { if (document.body.classList.contains('legacyBackground')) { // Has error message -> reload page window.stop(); window.location.reload(true); } }, 2000); }); const SERVER_REGIONS = {}; var STREAM_WEBRTC; var $STREAM_VIDEO; var $SCREENSHOT_CANVAS; var GAME_TITLE_ID; var APP_CONTEXT; const HAS_TOUCH_SUPPORT = ('ontouchstart' in window || navigator.maxTouchPoints > 0); // Credit: https://phosphoricons.com const ICON_VIDEO_SETTINGS = ''; const ICON_STREAM_STATS = ''; const ICON_SCREENSHOT_B64 = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDMyIDMyIiBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMjguMzA4IDUuMDM4aC00LjI2NWwtMi4wOTctMy4xNDVhMS4yMyAxLjIzIDAgMCAwLTEuMDIzLS41NDhoLTkuODQ2YTEuMjMgMS4yMyAwIDAgMC0xLjAyMy41NDhMNy45NTYgNS4wMzhIMy42OTJBMy43MSAzLjcxIDAgMCAwIDAgOC43MzF2MTcuMjMxYTMuNzEgMy43MSAwIDAgMCAzLjY5MiAzLjY5MmgyNC42MTVBMy43MSAzLjcxIDAgMCAwIDMyIDI1Ljk2MlY4LjczMWEzLjcxIDMuNzEgMCAwIDAtMy42OTItMy42OTJ6bS02Ljc2OSAxMS42OTJjMCAzLjAzOS0yLjUgNS41MzgtNS41MzggNS41MzhzLTUuNTM4LTIuNS01LjUzOC01LjUzOCAyLjUtNS41MzggNS41MzgtNS41MzggNS41MzggMi41IDUuNTM4IDUuNTM4eiIvPjwvc3ZnPgo='; class TitlesInfo { static #INFO = {}; static get(titleId) { return TitlesInfo.#INFO[titleId]; } static update(titleId, info) { TitlesInfo.#INFO[titleId] = TitlesInfo.#INFO[titleId] || {}; Object.assign(TitlesInfo.#INFO[titleId], info); } static saveFromTitleInfo(titleInfo) { const details = titleInfo.details; TitlesInfo.#INFO[details.productId] = { titleId: titleInfo.titleId, // Has more than one input type -> must have touch support hasTouchSupport: (details.supportedInputTypes.length > 1), }; } static saveFromCatalogInfo(catalogInfo) { const titleId = catalogInfo.StoreId; TitlesInfo.update(titleId, { imageHero: catalogInfo.Image_Hero ? catalogInfo.Image_Hero.URL : '', }); } static hasTouchSupport(titleId) { const gameInfo = TitlesInfo.#INFO[titleId] || {}; return !!gameInfo.hasTouchSupport; } static requestCatalogInfo(titleId, callback) { const url = `https://catalog.gamepass.com/v3/products?market=${APP_CONTEXT.marketInfo.market}&language=${APP_CONTEXT.marketInfo.locale}&hydration=RemoteHighSapphire0`; const appVersion = document.querySelector('meta[name=gamepass-app-version]').content; fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Ms-Cv': APP_CONTEXT.telemetryInfo.initialCv, 'Calling-App-Name': 'Xbox Cloud Gaming Web', 'Calling-App-Version': appVersion, }, body: JSON.stringify({ Products: [titleId], }), }).then(resp => { callback && callback(TitlesInfo.get(titleId)); }); } } class LoadingScreen { static #$bgStyle; static #$waitTimeBox; static #waitTimeInterval; static #orgWebTitle; static #secondsToString(seconds) { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); const mDisplay = m > 0 ? `${m}m`: ''; const sDisplay = `${s}s`.padStart(s >=0 ? 3 : 4, '0'); return mDisplay + sDisplay; } static setup() { // Get titleId from location const match = window.location.pathname.match(/\/launch\/[^\/]+\/([\w\d]+)/); if (!match) { return; } if (!LoadingScreen.#$bgStyle) { const $bgStyle = createElement('style'); document.documentElement.appendChild($bgStyle); LoadingScreen.#$bgStyle = $bgStyle; } const titleId = match[1]; const titleInfo = TitlesInfo.get(titleId); if (titleInfo && titleInfo.imageHero) { LoadingScreen.#setBackground(titleInfo.imageHero); } else { TitlesInfo.requestCatalogInfo(titleId, info => { info && info.imageHero && LoadingScreen.#setBackground(info.imageHero); }); } if (PREFS.get(Preferences.UI_LOADING_SCREEN_ROCKET) === 'hide') { LoadingScreen.#hideRocket(); } } static #hideRocket() { let $bgStyle = LoadingScreen.#$bgStyle; const css = ` #game-stream div[class*=RocketAnimation-module__container] > svg { display: none; } `; $bgStyle.textContent += css; } static #setBackground(imageUrl) { // Setup style tag let $bgStyle = LoadingScreen.#$bgStyle; // Limit max width to reduce image size imageUrl = imageUrl + '?w=1920'; const css = ` #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; background-size: cover !important; } #game-stream rect[width="800"] { transition: opacity 0.3s ease-in-out !important; } `; $bgStyle.textContent += css; const bg = new Image(); bg.onload = e => { $bgStyle.textContent += ` #game-stream rect[width="800"] { opacity: 0 !important; } `; }; bg.src = imageUrl; } static setupWaitTime(waitTime) { const CE = createElement; // Hide rocket when queing if (PREFS.get(Preferences.UI_LOADING_SCREEN_ROCKET) === 'hide-queue') { LoadingScreen.#hideRocket(); } let secondsLeft = waitTime; let $countDown; let $estimated; LoadingScreen.#orgWebTitle = document.title; const endDate = new Date(); const timeZoneOffsetSeconds = endDate.getTimezoneOffset() * 60; endDate.setSeconds(endDate.getSeconds() + waitTime - timeZoneOffsetSeconds); let endDateStr = endDate.toISOString().slice(0, 19); endDateStr = endDateStr.substring(0, 10) + ' ' + endDateStr.substring(11, 19); endDateStr += ` (${LoadingScreen.#secondsToString(waitTime)})`; let estimatedWaitTime = LoadingScreen.#secondsToString(waitTime); let $waitTimeBox = LoadingScreen.#$waitTimeBox; if (!$waitTimeBox) { $waitTimeBox = CE('div', {'class': 'better-xcloud-wait-time-box'}, CE('label', {}, 'Estimated finish time'), $estimated = CE('span', {'class': 'better-xcloud-wait-time-estimated'}), CE('label', {}, 'Countdown'), $countDown = CE('span', {'class': 'better-xcloud-wait-time-countdown'}), ); document.documentElement.appendChild($waitTimeBox); LoadingScreen.#$waitTimeBox = $waitTimeBox; } else { $waitTimeBox.classList.remove('better-xcloud-gone'); $estimated = $waitTimeBox.querySelector('.better-xcloud-wait-time-estimated'); $countDown = $waitTimeBox.querySelector('.better-xcloud-wait-time-countdown'); } $estimated.textContent = endDateStr; $countDown.textContent = LoadingScreen.#secondsToString(secondsLeft); document.title = `[${$countDown.textContent}] ${LoadingScreen.#orgWebTitle}`; LoadingScreen.#waitTimeInterval = setInterval(() => { secondsLeft--; $countDown.textContent = LoadingScreen.#secondsToString(secondsLeft); document.title = `[${$countDown.textContent}] ${LoadingScreen.#orgWebTitle}`; if (secondsLeft <= 0) { LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval); LoadingScreen.#waitTimeInterval = null; } }, 1000); } static hide() { LoadingScreen.#orgWebTitle && (document.title = LoadingScreen.#orgWebTitle); LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('better-xcloud-gone'); const $rocketBg = document.querySelector('#game-stream rect[width="800"]'); $rocketBg && $rocketBg.addEventListener('transitionend', e => { LoadingScreen.#$bgStyle.textContent += ` #game-stream { background: #000 !important; } `; }); LoadingScreen.#$bgStyle.textContent += ` #game-stream rect[width="800"] { opacity: 1 !important; } `; } static reset() { LoadingScreen.#$waitTimeBox && LoadingScreen.#$waitTimeBox.classList.add('better-xcloud-gone'); LoadingScreen.#$bgStyle && (LoadingScreen.#$bgStyle.textContent = ''); LoadingScreen.#waitTimeInterval && clearInterval(LoadingScreen.#waitTimeInterval); LoadingScreen.#waitTimeInterval = null; } } class TouchController { static get #EVENT_SHOW_CONTROLLER() { return new MessageEvent('message', { data: '{"content":"{\\"layoutId\\":\\"\\"}","target":"/streaming/touchcontrols/showlayoutv2","type":"Message"}', origin: 'better-xcloud', }); } static get #EVENT_HIDE_CONTROLLER() { return new MessageEvent('message', { data: '{"content":"","target":"/streaming/touchcontrols/hide","type":"Message"}', origin: 'better-xcloud', }); } static #$bar; static #enable = false; static #showing = false; static #dataChannel; static enable() { TouchController.#enable = true; } static disable() { TouchController.#enable = false; } static isEnabled() { return TouchController.#enable; } static #show() { TouchController.#dispatchMessage(TouchController.#EVENT_SHOW_CONTROLLER); TouchController.#showing = true; } static #hide() { TouchController.#dispatchMessage(TouchController.#EVENT_HIDE_CONTROLLER); TouchController.#showing = false; } static #toggleVisibility() { if (!TouchController.#dataChannel) { return; } TouchController.#showing ? TouchController.#hide() : TouchController.#show(); } static enableBar() { TouchController.#$bar && TouchController.#$bar.setAttribute('data-showing', true); } static reset() { TouchController.#enable = false; TouchController.#showing = false; TouchController.#dataChannel = null; TouchController.#$bar && TouchController.#$bar.removeAttribute('data-showing'); } static #dispatchMessage(msg) { TouchController.#dataChannel && setTimeout(() => { TouchController.#dataChannel.dispatchEvent(msg); }, 10); } static setup() { const $style = document.createElement('style'); document.documentElement.appendChild($style); const $bar = createElement('div', {'id': 'better-xcloud-touch-controller-bar'}); document.documentElement.appendChild($bar); // Setup double-tap event let clickTimeout; $bar.addEventListener('mousedown', e => { clickTimeout && clearTimeout(clickTimeout); if (clickTimeout) { // Double-clicked clickTimeout = null; TouchController.#toggleVisibility(); return; } clickTimeout = setTimeout(() => { clickTimeout = null; }, 400); }); TouchController.#$bar = $bar; const PREF_STYLE_STANDARD = PREFS.get(Preferences.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD); const PREF_STYLE_CUSTOM = PREFS.get(Preferences.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM); RTCPeerConnection.prototype.orgCreateDataChannel = RTCPeerConnection.prototype.createDataChannel; RTCPeerConnection.prototype.createDataChannel = function() { // Apply touch controller's style const $babylonCanvas = document.getElementById('babylon-canvas'); let filter = ''; if (TouchController.#enable) { if (PREF_STYLE_STANDARD === 'white') { filter = 'grayscale(1) brightness(2)'; } else if (PREF_STYLE_STANDARD === 'muted') { filter = 'sepia(0.5)'; } } else if (PREF_STYLE_CUSTOM === 'muted') { filter = 'sepia(0.5)'; } if (filter) { $style.textContent = ` #babylon-canvas { filter: ${filter} !important; } `; } const dataChannel = this.orgCreateDataChannel.apply(this, arguments); if (!TouchController.#enable) { return dataChannel; } TouchController.#dataChannel = dataChannel; // Fix sometimes the touch controller doesn't show at the beginning dataChannel.addEventListener('open', e => { setTimeout(TouchController.#show, 1000); }); dataChannel.addEventListener('message', msg => { if (msg.origin === 'better-xcloud' || typeof msg.data !== 'string') { return; } // Dispatch a message to display generic touch controller if (msg.data.includes('touchcontrols/showtitledefault')) { TouchController.#show(); } }); return dataChannel; }; } } class MouseCursorHider { static #timeout; static #cursorVisible = true; static show() { document.body && (document.body.style.cursor = 'unset'); MouseCursorHider.#cursorVisible = true; } static hide() { document.body && (document.body.style.cursor = 'none'); MouseCursorHider.#timeout = null; MouseCursorHider.#cursorVisible = false; } static onMouseMove(e) { // Toggle cursor !MouseCursorHider.#cursorVisible && MouseCursorHider.show(); // Setup timeout MouseCursorHider.#timeout && clearTimeout(MouseCursorHider.#timeout); MouseCursorHider.#timeout = setTimeout(MouseCursorHider.hide, 3000); } static start() { MouseCursorHider.show(); document.addEventListener('mousemove', MouseCursorHider.onMouseMove); } static stop() { MouseCursorHider.#timeout && clearTimeout(MouseCursorHider.#timeout); document.removeEventListener('mousemove', MouseCursorHider.onMouseMove); MouseCursorHider.show(); } } class StreamBadges { static get BADGE_PLAYTIME() { return 'playtime'; }; static get BADGE_BATTERY() { return 'battery'; }; static get BADGE_IN() { return 'in'; }; static get BADGE_OUT() { return 'out'; }; static get BADGE_SERVER() { return 'server'; }; static get BADGE_VIDEO() { return 'video'; }; static get BADGE_AUDIO() { return 'audio'; }; static get BADGE_BREAK() { return 'break'; }; static ipv6 = false; static resolution = null; static video = null; static audio = null; static fps = 0; static region = ''; static startBatteryLevel = 100; static startTimestamp = 0; static #cachedDoms = {}; static #interval; static get #REFRESH_INTERVAL() { return 3000; }; static #renderBadge(name, value, color) { const CE = createElement; if (name === StreamBadges.BADGE_BREAK) { return CE('div', {'style': 'display: block'}); } let $badge; if (StreamBadges.#cachedDoms[name]) { $badge = StreamBadges.#cachedDoms[name]; $badge.lastElementChild.textContent = value; return $badge; } $badge = CE('div', {'class': 'better-xcloud-badge'}, CE('span', {'class': 'better-xcloud-badge-name'}, name), CE('span', {'class': 'better-xcloud-badge-value', 'style': `background-color: ${color}`}, value)); if (name === StreamBadges.BADGE_BATTERY) { $badge.classList.add('better-xcloud-badge-battery'); } StreamBadges.#cachedDoms[name] = $badge; return $badge; } static async #updateBadges(forceUpdate) { if (!forceUpdate && !document.querySelector('.better-xcloud-badges')) { StreamBadges.#stop(); return; } // Playtime let now = +new Date; const diffSeconds = Math.ceil((now - StreamBadges.startTimestamp) / 1000); const playtime = StreamBadges.#secondsToHm(diffSeconds); // Battery let batteryLevel = '100%'; let batteryLevelInt = 100; let isCharging = false; if (navigator.getBattery) { try { const bm = await navigator.getBattery(); isCharging = bm.charging; batteryLevelInt = Math.round(bm.level * 100); batteryLevel = `${batteryLevelInt}%`; if (batteryLevelInt != StreamBadges.startBatteryLevel) { const diffLevel = Math.round(batteryLevelInt - StreamBadges.startBatteryLevel); const sign = diffLevel > 0 ? '+' : ''; batteryLevel += ` (${sign}${diffLevel}%)`; } } catch(e) {} } const stats = await STREAM_WEBRTC.getStats(); let totalIn = 0; let totalOut = 0; stats.forEach(stat => { if (stat.type === 'candidate-pair' && stat.state == 'succeeded') { totalIn += stat.bytesReceived; totalOut += stat.bytesSent; } }); const badges = { [StreamBadges.BADGE_IN]: totalIn ? StreamBadges.#humanFileSize(totalIn) : null, [StreamBadges.BADGE_OUT]: totalOut ? StreamBadges.#humanFileSize(totalOut) : null, [StreamBadges.BADGE_PLAYTIME]: playtime, [StreamBadges.BADGE_BATTERY]: batteryLevel, }; for (let name in badges) { const value = badges[name]; if (value === null) { continue; } const $elm = StreamBadges.#cachedDoms[name]; $elm && ($elm.lastElementChild.textContent = value); if (name === StreamBadges.BADGE_BATTERY) { // Show charging status $elm.setAttribute('data-charging', isCharging); if (StreamBadges.startBatteryLevel === 100 && batteryLevelInt === 100) { $elm.style.display = 'none'; } else { $elm.style = ''; } } } } static #stop() { StreamBadges.#interval && clearInterval(StreamBadges.#interval); StreamBadges.#interval = null; } static #secondsToHm(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor(seconds % 3600 / 60) + 1; const hDisplay = h > 0 ? `${h}h`: ''; const mDisplay = m > 0 ? `${m}m`: ''; return hDisplay + mDisplay; } // https://stackoverflow.com/a/20732091 static #humanFileSize(size) { let i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; } static async render() { // Video let video = ''; if (StreamBadges.resolution) { video = `${StreamBadges.resolution.height}p`; } if (StreamBadges.video) { video && (video += '/'); video += StreamBadges.video.codec; if (StreamBadges.video.profile) { let profile = StreamBadges.video.profile; profile = profile.startsWith('4d') ? 'High' : (profile.startsWith('42') ? 'Normal' : profile); video += ` (${profile})`; } } // Audio let audio; if (StreamBadges.audio) { audio = StreamBadges.audio.codec; const bitrate = StreamBadges.audio.bitrate / 1000; audio += ` (${bitrate} kHz)`; } // Battery let batteryLevel = ''; if (navigator.getBattery) { batteryLevel = '100%'; } // Server + Region let server = StreamBadges.region; server += '@' + (StreamBadges.ipv6 ? 'IPv6' : 'IPv4'); const BADGES = [ [StreamBadges.BADGE_PLAYTIME, '1m', '#ff004d'], [StreamBadges.BADGE_BATTERY, batteryLevel, '#00b543'], [StreamBadges.BADGE_IN, StreamBadges.#humanFileSize(0), '#29adff'], [StreamBadges.BADGE_OUT, StreamBadges.#humanFileSize(0), '#ff77a8'], [StreamBadges.BADGE_BREAK], [StreamBadges.BADGE_SERVER, server, '#ff6c24'], video ? [StreamBadges.BADGE_VIDEO, video, '#742f29'] : null, audio ? [StreamBadges.BADGE_AUDIO, audio, '#5f574f'] : null, ]; const $wrapper = createElement('div', {'class': 'better-xcloud-badges'}); BADGES.forEach(item => item && $wrapper.appendChild(StreamBadges.#renderBadge(...item))); await StreamBadges.#updateBadges(true); StreamBadges.#stop(); StreamBadges.#interval = setInterval(StreamBadges.#updateBadges, StreamBadges.#REFRESH_INTERVAL); return $wrapper; } } class StreamStats { static get PING() { return 'ping'; } static get FPS() { return 'fps'; } static get BITRATE() { return 'btr'; } static get DECODE_TIME() { return 'dt'; } static get PACKETS_LOST() { return 'pl'; } static get FRAMES_LOST() { return 'fl'; } static #interval; static #updateInterval = 1000; static #$container; static #$fps; static #$ping; static #$dt; static #$pl; static #$fl; static #$br; static #$settings; static #lastStat; static #quickGlanceObserver; static start(glancing=false) { if (!StreamStats.isHidden() || (glancing && StreamStats.isGlancing())) { return; } StreamStats.#$container.classList.remove('better-xcloud-gone'); StreamStats.#$container.setAttribute('data-display', glancing ? 'glancing' : 'fixed'); StreamStats.#interval = setInterval(StreamStats.update, StreamStats.#updateInterval); } static stop(glancing=false) { if (glancing && !StreamStats.isGlancing()) { return; } clearInterval(StreamStats.#interval); StreamStats.#interval = null; StreamStats.#lastStat = null; StreamStats.#$container.removeAttribute('data-display'); StreamStats.#$container.classList.add('better-xcloud-gone'); } static toggle() { if (StreamStats.isGlancing()) { StreamStats.#$container.setAttribute('data-display', 'fixed'); } else { StreamStats.isHidden() ? StreamStats.start() : StreamStats.stop(); } } static onStoppedPlaying() { StreamStats.stop(); StreamStats.quickGlanceStop(); StreamStats.hideSettingsUi(); } static isHidden = () => StreamStats.#$container.classList.contains('better-xcloud-gone'); static isGlancing = () => StreamStats.#$container.getAttribute('data-display') === 'glancing'; static quickGlanceSetup() { if (StreamStats.#quickGlanceObserver) { return; } const $uiContainer = document.querySelector('div[data-testid=ui-container]'); StreamStats.#quickGlanceObserver = new MutationObserver((mutationList, observer) => { for (let record of mutationList) { if (record.attributeName && record.attributeName === 'aria-expanded') { const expanded = record.target.ariaExpanded; if (expanded === 'true') { StreamStats.isHidden() && StreamStats.start(true); } else { StreamStats.stop(true); } } } }); StreamStats.#quickGlanceObserver.observe($uiContainer, { attributes: true, attributeFilter: ['aria-expanded'], subtree: true, }); } static quickGlanceStop() { StreamStats.#quickGlanceObserver && StreamStats.#quickGlanceObserver.disconnect(); StreamStats.#quickGlanceObserver = null; } static update() { if (StreamStats.isHidden() || !STREAM_WEBRTC) { StreamStats.onStoppedPlaying(); return; } const PREF_STATS_CONDITIONAL_FORMATTING = PREFS.get(Preferences.STATS_CONDITIONAL_FORMATTING); STREAM_WEBRTC.getStats().then(stats => { stats.forEach(stat => { let grade = ''; if (stat.type === 'inbound-rtp' && stat.kind === 'video') { // FPS StreamStats.#$fps.textContent = stat.framesPerSecond || 0; // Packets Lost const packetsLost = stat.packetsLost; const packetsReceived = stat.packetsReceived; const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2); StreamStats.#$pl.textContent = `${packetsLost} (${packetsLostPercentage}%)`; // Frames Dropped const framesDropped = stat.framesDropped; const framesReceived = stat.framesReceived; const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2); StreamStats.#$fl.textContent = `${framesDropped} (${framesDroppedPercentage}%)`; if (StreamStats.#lastStat) { const lastStat = StreamStats.#lastStat; // Bitrate const timeDiff = stat.timestamp - lastStat.timestamp; const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000; StreamStats.#$br.textContent = `${bitrate.toFixed(2)} Mbps`; // Decode time const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime; const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000; StreamStats.#$dt.textContent = `${currentDecodeTime.toFixed(2)}ms`; if (PREF_STATS_CONDITIONAL_FORMATTING) { grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : ''; } StreamStats.#$dt.setAttribute('data-grade', grade); } StreamStats.#lastStat = stat; } else if (stat.type === 'candidate-pair' && stat.state === 'succeeded') { // Round Trip Time const roundTripTime = typeof stat.currentRoundTripTime !== 'undefined' ? stat.currentRoundTripTime * 1000 : '???'; StreamStats.#$ping.textContent = roundTripTime; if (PREF_STATS_CONDITIONAL_FORMATTING) { grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : ''; } StreamStats.#$ping.setAttribute('data-grade', grade); } }); }); } static #refreshStyles() { const PREF_ITEMS = PREFS.get(Preferences.STATS_ITEMS); const PREF_POSITION = PREFS.get(Preferences.STATS_POSITION); const PREF_TRANSPARENT = PREFS.get(Preferences.STATS_TRANSPARENT); const PREF_OPACITY = PREFS.get(Preferences.STATS_OPACITY); const PREF_TEXT_SIZE = PREFS.get(Preferences.STATS_TEXT_SIZE); StreamStats.#$container.setAttribute('data-stats', '[' + PREF_ITEMS.join('][') + ']'); StreamStats.#$container.setAttribute('data-position', PREF_POSITION); StreamStats.#$container.setAttribute('data-transparent', PREF_TRANSPARENT); StreamStats.#$container.style.opacity = PREF_OPACITY + '%'; StreamStats.#$container.style.fontSize = PREF_TEXT_SIZE; } static hideSettingsUi() { StreamStats.#$settings.style.display = 'none'; if (StreamStats.isGlancing() && !PREFS.get(Preferences.STATS_QUICK_GLANCE)) { StreamStats.stop(); } } static #toggleSettingsUi() { const display = StreamStats.#$settings.style.display; StreamStats.#$settings.style.display = display === 'block' ? 'none' : 'block'; } static render() { if (StreamStats.#$container) { return; } const CE = createElement; const STATS = { [StreamStats.PING]: (StreamStats.#$ping = CE('span', {}, '0')), [StreamStats.FPS]: (StreamStats.#$fps = CE('span', {}, '0')), [StreamStats.BITRATE]: (StreamStats.#$br = CE('span', {}, '0 Mbps')), [StreamStats.DECODE_TIME]: (StreamStats.#$dt = CE('span', {}, '0ms')), [StreamStats.PACKETS_LOST]: (StreamStats.#$pl = CE('span', {}, '0 (0.00%)')), [StreamStats.FRAMES_LOST]: (StreamStats.#$fl = CE('span', {}, '0 (0.00%)')), }; const $barFragment = document.createDocumentFragment(); for (let statKey in STATS) { const $div = CE('div', {'class': `better-xcloud-stat-${statKey}`}, CE('label', {}, statKey.toUpperCase()), STATS[statKey]); $barFragment.appendChild($div); } StreamStats.#$container = CE('div', {'class': 'better-xcloud-stats-bar better-xcloud-gone'}, $barFragment); let clickTimeout; StreamStats.#$container.addEventListener('mousedown', e => { clearTimeout(clickTimeout); if (clickTimeout) { // Double-clicked clickTimeout = null; StreamStats.#toggleSettingsUi(); return; } clickTimeout = setTimeout(() => { clickTimeout = null; }, 400); }); document.documentElement.appendChild(StreamStats.#$container); const refreshFunc = e => { StreamStats.#refreshStyles() }; let $close; const STATS_UI = { [Preferences.STATS_SHOW_WHEN_PLAYING]: { 'label': 'Show stats when starting the game', }, [Preferences.STATS_QUICK_GLANCE]: { 'label': 'Enable "Quick Glance" mode', 'onChange': e => { e.target.checked ? StreamStats.quickGlanceSetup() : StreamStats.quickGlanceStop(); }, }, [Preferences.STATS_ITEMS]: { 'label': 'Stats', 'onChange': refreshFunc, }, [Preferences.STATS_POSITION]: { 'label': 'Position', 'onChange': refreshFunc, }, [Preferences.STATS_TEXT_SIZE]: { 'label': 'Text size', 'onChange': refreshFunc, }, [Preferences.STATS_OPACITY]: { 'label': 'Opacity (50-100%)', 'onChange': refreshFunc, }, [Preferences.STATS_TRANSPARENT]: { 'label': 'Transparent background', 'onChange': refreshFunc, }, [Preferences.STATS_CONDITIONAL_FORMATTING]: { 'label': 'Conditional formatting text color', 'onChange': refreshFunc, }, }; const $fragment = document.createDocumentFragment(); for (let settingKey in STATS_UI) { const setting = STATS_UI[settingKey]; $fragment.appendChild(CE('div', {}, CE('label', {'for': `xcloud_setting_${settingKey}`}, setting.label), PREFS.toElement(settingKey, setting.onChange) )); } StreamStats.#$settings = CE('div', {'class': 'better-xcloud-stats-settings'}, CE('b', {}, 'Stream Stats Settings'), $fragment, $close = CE('button', {}, 'Close')); $close.addEventListener('click', e => StreamStats.hideSettingsUi()); document.documentElement.appendChild(StreamStats.#$settings); StreamStats.#refreshStyles(); } } class UserAgent { static get PROFILE_EDGE_WINDOWS() { return 'edge-windows'; } static get PROFILE_SAFARI_MACOS() { return 'safari-macos'; } static get PROFILE_SMARTTV_TIZEN() { return 'smarttv-tizen'; } static get PROFILE_DEFAULT() { return 'default'; } static get PROFILE_CUSTOM() { return 'custom'; } static #USER_AGENTS = { [UserAgent.PROFILE_EDGE_WINDOWS]: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.188', [UserAgent.PROFILE_SAFARI_MACOS]: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.2 Safari/605.1.1', [UserAgent.PROFILE_SMARTTV_TIZEN]: 'Mozilla/5.0 (SMART-TV; LINUX; Tizen 7.0) AppleWebKit/537.36 (KHTML, like Gecko) 94.0.4606.31/7.0 TV Safari/537.36', } static getDefault() { return window.navigator.orgUserAgent || window.navigator.userAgent; } static get(profile) { const defaultUserAgent = UserAgent.getDefault(); if (profile === UserAgent.PROFILE_CUSTOM) { return PREFS.get(Preferences.USER_AGENT_CUSTOM, ''); } return UserAgent.#USER_AGENTS[profile] || defaultUserAgent; } static isSafari(mobile=false) { const userAgent = (UserAgent.getDefault() || '').toLowerCase(); let result = userAgent.includes('safari') && !userAgent.includes('chrom'); if (result && mobile) { result = userAgent.includes('mobile'); } return result; } static spoof() { const profile = PREFS.get(Preferences.USER_AGENT_PROFILE); if (profile === UserAgent.PROFILE_DEFAULT) { return; } const defaultUserAgent = window.navigator.userAgent; const userAgent = UserAgent.get(profile) || defaultUserAgent; // Clear data of navigator.userAgentData, force xCloud to detect browser based on navigator.userAgent Object.defineProperty(window.navigator, 'userAgentData', {}); // Override navigator.userAgent window.navigator.orgUserAgent = window.navigator.userAgent; Object.defineProperty(window.navigator, 'userAgent', { value: userAgent, }); return userAgent; } } class PreloadedState { static override() { Object.defineProperty(window, '__PRELOADED_STATE__', { configurable: true, get: () => { // Override User-Agent const userAgent = UserAgent.spoof(); if (userAgent) { this._state.appContext.requestInfo.userAgent = userAgent; } return this._state; }, set: (state) => { this._state = state; APP_CONTEXT = structuredClone(state.appContext); // Get a list of touch-supported games if (PREFS.get(Preferences.STREAM_TOUCH_CONTROLLER) === 'all') { let titles = {}; try { titles = state.xcloud.titles.data.titles; } catch (e) {} for (let id in titles) { TitlesInfo.saveFromTitleInfo(titles[id].data); } } } }); } } class Preferences { static get LAST_UPDATE_CHECK() { return 'version_last_check'; } static get LATEST_VERSION() { return 'version_latest'; } static get CURRENT_VERSION() { return 'version_current'; } static get SERVER_REGION() { return 'server_region'; } static get PREFER_IPV6_SERVER() { return 'prefer_ipv6_server'; } static get STREAM_TARGET_RESOLUTION() { return 'stream_target_resolution'; } static get STREAM_PREFERRED_LOCALE() { return 'stream_preferred_locale'; } static get USE_DESKTOP_CODEC() { return 'use_desktop_codec'; } static get USER_AGENT_PROFILE() { return 'user_agent_profile'; } static get USER_AGENT_CUSTOM() { return 'user_agent_custom'; } static get STREAM_HIDE_IDLE_CURSOR() { return 'stream_hide_idle_cursor';} static get STREAM_SIMPLIFY_MENU() { return 'stream_simplify_menu'; } static get STREAM_TOUCH_CONTROLLER() { return 'stream_touch_controller'; } static get STREAM_TOUCH_CONTROLLER_STYLE_STANDARD() { return 'stream_touch_controller_style_standard'; } static get STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM() { return 'stream_touch_controller_style_custom'; } static get SCREENSHOT_BUTTON_POSITION() { return 'screenshot_button_position'; } static get BLOCK_TRACKING() { return 'block_tracking'; } static get BLOCK_SOCIAL_FEATURES() { return 'block_social_features'; } static get DISABLE_BANDWIDTH_CHECKING() { return 'disable_bandwidth_checking'; } static get SKIP_SPLASH_VIDEO() { return 'skip_splash_video'; } static get HIDE_DOTS_ICON() { return 'hide_dots_icon'; } static get REDUCE_ANIMATIONS() { return 'reduce_animations'; } static get UI_LOADING_SCREEN_GAME_ART() { return 'ui_loading_screen_game_art'; } static get UI_LOADING_SCREEN_WAIT_TIME() { return 'ui_loading_screen_wait_time'; } static get UI_LOADING_SCREEN_ROCKET() { return 'ui_loading_screen_rocket'; } static get VIDEO_CLARITY() { return 'video_clarity'; } static get VIDEO_RATIO() { return 'video_ratio' } static get VIDEO_BRIGHTNESS() { return 'video_brightness'; } static get VIDEO_CONTRAST() { return 'video_contrast'; } static get VIDEO_SATURATION() { return 'video_saturation'; } static get AUDIO_MIC_ON_PLAYING() { return 'audio_mic_on_playing'; } static get STATS_ITEMS() { return 'stats_items'; }; static get STATS_SHOW_WHEN_PLAYING() { return 'stats_show_when_playing'; } static get STATS_QUICK_GLANCE() { return 'stats_quick_glance'; } static get STATS_POSITION() { return 'stats_position'; } static get STATS_TEXT_SIZE() { return 'stats_text_size'; } static get STATS_TRANSPARENT() { return 'stats_transparent'; } static get STATS_OPACITY() { return 'stats_opacity'; } static get STATS_CONDITIONAL_FORMATTING() { return 'stats_conditional_formatting'; } static SETTINGS = { [Preferences.LAST_UPDATE_CHECK]: { 'default': 0, }, [Preferences.LATEST_VERSION]: { 'default': '', }, [Preferences.CURRENT_VERSION]: { 'default': '', }, [Preferences.SERVER_REGION]: { 'default': 'default', }, [Preferences.STREAM_PREFERRED_LOCALE]: { 'default': 'default', 'options': { 'default': 'Default', 'ar-SA': '\u0627\u0644\u0639\u0631\u0628\u064a\u0629', 'cs-CZ': '\u010de\u0161tina', 'da-DK': 'dansk', 'de-DE': 'Deutsch', 'el-GR': '\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac', 'en-GB': 'English (United Kingdom)', 'en-US': 'English (United States)', 'es-ES': 'espa\xf1ol (Espa\xf1a)', 'es-MX': 'espa\xf1ol (Latinoam\xe9rica)', 'fi-FI': 'suomi', 'fr-FR': 'fran\xe7ais', 'he-IL': '\u05e2\u05d1\u05e8\u05d9\u05ea', 'hu-HU': 'magyar', 'it-IT': 'italiano', 'ja-JP': '\u65e5\u672c\u8a9e', 'ko-KR': '\ud55c\uad6d\uc5b4', 'nb-NO': 'norsk bokm\xe5l', 'nl-NL': 'Nederlands', 'pl-PL': 'polski', 'pt-BR': 'portugu\xeas (Brasil)', 'pt-PT': 'portugu\xeas (Portugal)', 'ru-RU': '\u0440\u0443\u0441\u0441\u043a\u0438\u0439', 'sk-SK': 'sloven\u010dina', 'sv-SE': 'svenska', 'tr-TR': 'T\xfcrk\xe7e', 'zh-CN': '\u4e2d\u6587(\u7b80\u4f53)', 'zh-TW': '\u4e2d\u6587 (\u7e41\u9ad4)', }, }, [Preferences.STREAM_TARGET_RESOLUTION]: { 'default': 'auto', 'options': { 'auto': 'Auto', '1080p': '1080p', '720p': '720p', }, }, [Preferences.USE_DESKTOP_CODEC]: { 'default': false, }, [Preferences.PREFER_IPV6_SERVER]: { 'default': false, }, [Preferences.DISABLE_BANDWIDTH_CHECKING]: { 'default': false, }, [Preferences.SCREENSHOT_BUTTON_POSITION]: { 'default': 'bottom-left', 'options': { 'bottom-left': 'Bottom Left', 'bottom-right': 'Bottom Right', 'none': 'Disable', }, }, [Preferences.SKIP_SPLASH_VIDEO]: { 'default': false, }, [Preferences.HIDE_DOTS_ICON]: { 'default': false, }, [Preferences.STREAM_TOUCH_CONTROLLER]: { 'default': 'default', 'options': { 'default': 'Default', 'all': 'All games', 'off': 'Off', }, }, [Preferences.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: { 'default': 'default', 'options': { 'default': 'Default colors', 'white': 'All white', 'muted': 'Muted colors', }, }, [Preferences.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: { 'default': 'default', 'options': { 'default': 'Default colors', 'muted': 'Muted colors', }, }, [Preferences.STREAM_SIMPLIFY_MENU]: { 'default': false, }, [Preferences.STREAM_HIDE_IDLE_CURSOR]: { 'default': false, }, [Preferences.REDUCE_ANIMATIONS]: { 'default': false, }, [Preferences.UI_LOADING_SCREEN_GAME_ART]: { 'default': true, }, [Preferences.UI_LOADING_SCREEN_WAIT_TIME]: { 'default': false, }, [Preferences.UI_LOADING_SCREEN_ROCKET]: { 'default': 'show', 'options': { 'show': 'Always show', 'hide-queue': 'Hide when queuing', 'hide': 'Always hide', }, }, [Preferences.BLOCK_SOCIAL_FEATURES]: { 'default': false, }, [Preferences.BLOCK_TRACKING]: { 'default': false, }, [Preferences.USER_AGENT_PROFILE]: { 'default': 'default', 'options': { [UserAgent.PROFILE_DEFAULT]: 'Default', [UserAgent.PROFILE_EDGE_WINDOWS]: 'Edge on Windows', [UserAgent.PROFILE_SAFARI_MACOS]: 'Safari on macOS', [UserAgent.PROFILE_SMARTTV_TIZEN]: 'Samsung Smart TV', [UserAgent.PROFILE_CUSTOM]: 'Custom', }, }, [Preferences.USER_AGENT_CUSTOM]: { 'default': '', }, [Preferences.VIDEO_CLARITY]: { 'default': 0, 'min': 0, 'max': 5, }, [Preferences.VIDEO_RATIO]: { 'default': 16, 'min': 16, 'max': 21, 'steps': 1, }, [Preferences.VIDEO_SATURATION]: { 'default': 100, 'min': 0, 'max': 150, }, [Preferences.VIDEO_CONTRAST]: { 'default': 100, 'min': 0, 'max': 150, }, [Preferences.VIDEO_BRIGHTNESS]: { 'default': 100, 'min': 0, 'max': 150, }, [Preferences.AUDIO_MIC_ON_PLAYING]: { 'default': false, }, [Preferences.STATS_ITEMS]: { 'default': [StreamStats.PING, StreamStats.FPS, StreamStats.BITRATE, StreamStats.DECODE_TIME, StreamStats.PACKETS_LOST, StreamStats.FRAMES_LOST], 'multiple_options': { [StreamStats.PING]: 'Ping', [StreamStats.FPS]: 'FPS', [StreamStats.BITRATE]: 'Bitrate', [StreamStats.DECODE_TIME]: 'Decode time', [StreamStats.PACKETS_LOST]: 'Packets lost', [StreamStats.FRAMES_LOST]: 'Frames lost', }, }, [Preferences.STATS_SHOW_WHEN_PLAYING]: { 'default': false, }, [Preferences.STATS_QUICK_GLANCE]: { 'default': false, }, [Preferences.STATS_POSITION]: { 'default': 'top-left', 'options': { 'top-left': 'Top Left', 'top-center': 'Top Center', 'top-right': 'Top Right', }, }, [Preferences.STATS_TEXT_SIZE]: { 'default': '0.9rem', 'options': { '0.9rem': 'Small', '1.0rem': 'Normal', '1.1rem': 'Large', }, }, [Preferences.STATS_TRANSPARENT]: { 'default': false, }, [Preferences.STATS_OPACITY]: { 'default': 80, 'min': 50, 'max': 100, }, [Preferences.STATS_CONDITIONAL_FORMATTING]: { 'default': false, }, } constructor() { this._storage = localStorage; this._key = 'better_xcloud'; let savedPrefs = this._storage.getItem(this._key); if (savedPrefs == null) { savedPrefs = '{}'; } savedPrefs = JSON.parse(savedPrefs); this._prefs = {}; for (let settingId in Preferences.SETTINGS) { if (!settingId) { alert('Undefined setting key'); console.log('Undefined setting key'); continue; } const setting = Preferences.SETTINGS[settingId]; if (settingId in savedPrefs) { this._prefs[settingId] = savedPrefs[settingId]; } else { this._prefs[settingId] = setting.default; } } } get(key, defaultValue=null) { if (typeof key === 'undefined') { debugger; return; } // Return "default" for STREAM_TOUCH_CONTROLLER pref when the browser doesn't support touch if (!HAS_TOUCH_SUPPORT && key === Preferences.STREAM_TOUCH_CONTROLLER) { return 'default'; } const value = this._prefs[key]; if (typeof value !== 'undefined' && value !== null && value !== '') { return value; } if (defaultValue !== null) { return defaultValue; } // Return default value return Preferences.SETTINGS[key].default; } set(key, value) { const config = Preferences.SETTINGS[key]; if (config) { if ('min' in config) { value = Math.max(config.min, value); } if ('max' in config) { value = Math.min(config.max, value); } if ('options' in config && !(value in config.options)) { value = config.default; } else if ('multiple_options' in config) { if (value.length) { const validOptions = Object.keys(config.multiple_options); value.forEach((item, idx) => { (validOptions.indexOf(item) === -1) && value.splice(idx, 1); }); } if (!value.length) { value = config.default; } } } this._prefs[key] = value; this._update_storage(); } _update_storage() { this._storage.setItem(this._key, JSON.stringify(this._prefs)); } toElement(key, onChange) { const CE = createElement; const setting = Preferences.SETTINGS[key]; const currentValue = PREFS.get(key); let $control; if ('options' in setting) { $control = CE('select', {'id': 'xcloud_setting_' + key}); for (let value in setting.options) { const label = setting.options[value]; const $option = CE('option', {value: value}, label); $control.appendChild($option); } $control.value = currentValue; $control.addEventListener('change', e => { PREFS.set(key, e.target.value); onChange && onChange(e); }); } else if ('multiple_options' in setting) { $control = CE('select', {'id': 'xcloud_setting_' + key, 'multiple': true}); for (let value in setting.multiple_options) { const label = setting.multiple_options[value]; const $option = CE('option', {value: value}, label); $option.selected = currentValue.indexOf(value) > -1; $option.addEventListener('mousedown', function(e) { e.preventDefault(); e.target.selected = !e.target.selected; const $parent = e.target.parentElement; $parent.focus(); $parent.dispatchEvent(new Event('change')); }); $control.appendChild($option); } $control.addEventListener('mousedown', e => { const $this = this; const orgScrollTop = $this.scrollTop; setTimeout(() => ($this.scrollTop = orgScrollTop), 0); }); $control.addEventListener('mousemove', e => e.preventDefault()); // $control.value = currentValue; $control.addEventListener('change', e => { const values = Array.from(e.target.selectedOptions).map(e => e.value); PREFS.set(key, values); onChange && onChange(e); }); } else if (typeof setting.default === 'number') { $control = CE('input', {'type': 'number', 'min': setting.min, 'max': setting.max}); $control.value = currentValue; $control.addEventListener('change', e => { let value = Math.max(setting.min, Math.min(setting.max, parseInt(e.target.value))); e.target.value = value; PREFS.set(key, e.target.value); onChange && onChange(e); }); } else { $control = CE('input', {'type': 'checkbox'}); $control.checked = currentValue; $control.addEventListener('change', e => { PREFS.set(key, e.target.checked); onChange && onChange(e); }); } $control.id = `xcloud_setting_${key}`; return $control; } toNumberStepper(key, onChange, suffix='', disabled=false) { const setting = Preferences.SETTINGS[key] let value = PREFS.get(key); let $text, $decBtn, $incBtn; const MIN = setting.min; const MAX= setting.max; const STEPS = Math.max(setting.steps || 1, 1); const CE = createElement; const $wrapper = CE('div', {}, $decBtn = CE('button', {'data-type': 'dec'}, '-'), $text = CE('span', {}, value + suffix), $incBtn = CE('button', {'data-type': 'inc'}, '+'), ); if (disabled) { $incBtn.disabled = true; $incBtn.classList.add('better-xcloud-hidden'); $decBtn.disabled = true; $decBtn.classList.add('better-xcloud-hidden'); return $wrapper; } let interval; let isHolding = false; const onClick = e => { if (isHolding) { e.preventDefault(); isHolding = false; return; } const btnType = e.target.getAttribute('data-type'); if (btnType === 'dec') { value = Math.max(MIN, value - STEPS); } else { value = Math.min(MAX, value + STEPS); } $text.textContent = value + suffix; PREFS.set(key, value); isHolding = false; onChange && onChange(); } const onMouseDown = e => { isHolding = true; const args = arguments; interval = setInterval(() => { const event = new Event('click'); event.arguments = args; e.target.dispatchEvent(event); }, 200); }; const onMouseUp = e => { clearInterval(interval); isHolding = false; }; $decBtn.addEventListener('click', onClick); $decBtn.addEventListener('mousedown', onMouseDown); $decBtn.addEventListener('mouseup', onMouseUp); $decBtn.addEventListener('touchstart', onMouseDown); $decBtn.addEventListener('touchend', onMouseUp); $incBtn.addEventListener('click', onClick); $incBtn.addEventListener('mousedown', onMouseDown); $incBtn.addEventListener('mouseup', onMouseUp); $incBtn.addEventListener('touchstart', onMouseDown); $incBtn.addEventListener('touchend', onMouseUp); return $wrapper; } } const PREFS = new Preferences(); function checkForUpdate() { const CHECK_INTERVAL_SECONDS = 4 * 3600; // check every 4 hours const currentVersion = PREFS.get(Preferences.CURRENT_VERSION, ''); const lastCheck = PREFS.get(Preferences.LAST_UPDATE_CHECK, 0); const now = Math.round((+new Date) / 1000); if (currentVersion === SCRIPT_VERSION && now - lastCheck < CHECK_INTERVAL_SECONDS) { return; } // Start checking PREFS.set(Preferences.LAST_UPDATE_CHECK, now); fetch('https://api.github.com/repos/redphx/better-xcloud/releases/latest') .then(response => response.json()) .then(json => { // Store the latest version PREFS.set(Preferences.LATEST_VERSION, json.tag_name.substring(1)); PREFS.set(Preferences.CURRENT_VERSION, SCRIPT_VERSION); }); } function addCss() { let css = ` .better-xcloud-settings-button { background-color: transparent; border: none; color: white; font-weight: bold; line-height: 30px; border-radius: 4px; padding: 8px; } .better-xcloud-settings-button:hover, .better-xcloud-settings-button:focus { background-color: #515863; } .better-xcloud-settings-button[data-update-available]::after { content: ' 🌟'; } .better_xcloud_settings { background-color: #151515; user-select: none; -webkit-user-select: none; color: #fff; font-family: "Segoe UI", Arial, Helvetica, sans-serif } .better-xcloud-gone { display: none !important; } .better-xcloud-hidden { visibility: hidden !important; } .better-xcloud-settings-wrapper { width: 450px; margin: auto; padding: 12px 6px; } .better-xcloud-settings-wrapper *:focus { outline: none !important; } .better-xcloud-settings-wrapper .better-xcloud-settings-title-wrapper { display: flex; } .better-xcloud-settings-wrapper a.better-xcloud-settings-title { font-family: Bahnschrift, Arial, Helvetica, sans-serif; font-size: 1.4rem; text-decoration: none; font-weight: bold; display: block; margin-bottom: 8px; color: #5dc21e; flex: 1; } .better-xcloud-settings-group-label { font-weight: bold; display: block; font-size: 1.1rem; } @media (hover: hover) { .better-xcloud-settings-wrapper a.better-xcloud-settings-title:hover { color: #83f73a; } } .better-xcloud-settings-wrapper a.better-xcloud-settings-title:focus { color: #83f73a; } .better-xcloud-settings-wrapper a.better-xcloud-settings-update { display: none; color: #ff834b; text-decoration: none; } @media (hover: hover) { .better-xcloud-settings-wrapper a.better-xcloud-settings-update:hover { color: #ff9869; text-decoration: underline; } } .better-xcloud-settings-wrapper a.better-xcloud-settings-update:focus { color: #ff9869; text-decoration: underline; } .better-xcloud-settings-row { display: flex; margin-bottom: 8px; padding: 2px 4px; } .better-xcloud-settings-row label { flex: 1; align-self: center; margin-bottom: 0; padding-left: 10px; } @media not (hover: hover) { .better-xcloud-settings-row:focus-within { background-color: #242424; } } .better-xcloud-settings-row input { align-self: center; } .better-xcloud-settings-reload-button { padding: 8px 32px; margin: 10px auto 0; border: none; border-radius: 4px; display: block; background-color: #044e2a; text-align: center; color: white; text-transform: uppercase; font-family: Bahnschrift, Arial, Helvetica, sans-serif; font-weight: 400; line-height: 24px; } @media (hover: hover) { .better-xcloud-settings-reload-button:hover { background-color: #00753c; } } .better-xcloud-settings-reload-button:focus { background-color: #00753c; } .better-xcloud-settings-reload-button:active { background-color: #00753c; } .better-xcloud-settings-app-version { margin-top: 10px; text-align: center; color: #747474; font-size: 12px; } .better-xcloud-settings-custom-user-agent { display: block; width: 100%; } div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] { overflow: visible; } .better-xcloud-badges { position: absolute; margin-left: 0px; user-select: none; -webkit-user-select: none; } .better-xcloud-badge { border: none; display: inline-block; line-height: 24px; color: #fff; font-family: Bahnschrift Semibold, Arial, Helvetica, sans-serif; font-size: 14px; font-weight: 400; margin: 0 8px 8px 0; box-shadow: 0px 0px 6px #000; border-radius: 4px; } .better-xcloud-badge-name { background-color: #2d3036; display: inline-block; padding: 2px 8px; border-radius: 4px 0 0 4px; text-transform: uppercase; } .better-xcloud-badge-value { background-color: grey; display: inline-block; padding: 2px 8px; border-radius: 0 4px 4px 0; } .better-xcloud-badge-battery[data-charging=true] span:first-of-type::after { content: ' ⚡️'; } .better-xcloud-screenshot-button { display: none; opacity: 0; position: fixed; bottom: 0; box-sizing: border-box; width: 16vh; height: 16vh; max-width: 128px; max-height: 128px; padding: 2vh; padding: 24px 24px 12px 12px; background-size: cover; background-repeat: no-repeat; background-origin: content-box; filter: drop-shadow(0 0 2px #000000B0); transition: opacity 0.1s ease-in-out 0s, padding 0.1s ease-in 0s; z-index: 8888; /* Credit: https://phosphoricons.com */ background-image: url(${ICON_SCREENSHOT_B64}); } .better-xcloud-screenshot-button[data-showing=true] { opacity: 0.9; } .better-xcloud-screenshot-button[data-capturing=true] { padding: 1vh; } .better-xcloud-screenshot-canvas { display: none; } .better-xcloud-stats-bar { display: block; user-select: none; -webkit-user-select: none; position: fixed; top: 0; background-color: #000; color: #fff; font-family: Consolas, "Courier New", Courier, monospace; font-size: 0.9rem; padding-left: 8px; z-index: 1000; text-wrap: nowrap; } .better-xcloud-stats-bar > div { display: none; margin-right: 8px; border-right: 2px solid #fff; padding-right: 8px; } .better-xcloud-stats-bar[data-stats*="[fps]"] > .better-xcloud-stat-fps, .better-xcloud-stats-bar[data-stats*="[ping]"] > .better-xcloud-stat-ping, .better-xcloud-stats-bar[data-stats*="[btr]"] > .better-xcloud-stat-btr, .better-xcloud-stats-bar[data-stats*="[dt]"] > .better-xcloud-stat-dt, .better-xcloud-stats-bar[data-stats*="[pl]"] > .better-xcloud-stat-pl, .better-xcloud-stats-bar[data-stats*="[fl]"] > .better-xcloud-stat-fl { display: inline-block; } .better-xcloud-stats-bar[data-stats$="[fps]"] > .better-xcloud-stat-fps, .better-xcloud-stats-bar[data-stats$="[ping]"] > .better-xcloud-stat-ping, .better-xcloud-stats-bar[data-stats$="[btr]"] > .better-xcloud-stat-btr, .better-xcloud-stats-bar[data-stats$="[dt]"] > .better-xcloud-stat-dt, .better-xcloud-stats-bar[data-stats$="[pl]"] > .better-xcloud-stat-pl, .better-xcloud-stats-bar[data-stats$="[fl]"] > .better-xcloud-stat-fl { margin-right: 0; border-right: none; } .better-xcloud-stats-bar[data-display=glancing]::before { content: '👀 '; vertical-align: middle; } .better-xcloud-stats-bar[data-position=top-left] { left: 0; border-radius: 0 0 4px 0; } .better-xcloud-stats-bar[data-position=top-right] { right: 0; border-radius: 0 0 0 4px; } .better-xcloud-stats-bar[data-position=top-center] { transform: translate(-50%, 0); left: 50%; border-radius: 0 0 4px 4px; } .better-xcloud-stats-bar[data-transparent=true] { background: none; filter: drop-shadow(1px 0 0 #000000f0) drop-shadow(-1px 0 0 #000000f0) drop-shadow(0 1px 0 #000000f0) drop-shadow(0 -1px 0 #000000f0); } .better-xcloud-stats-bar label { margin: 0 8px 0 0; font-family: Bahnschrift, Arial, Helvetica, sans-serif; font-size: inherit; font-weight: bold; vertical-align: middle; } .better-xcloud-stats-bar span { min-width: 60px; display: inline-block; text-align: right; vertical-align: middle; } .better-xcloud-stats-bar span[data-grade=good] { color: #6bffff; } .better-xcloud-stats-bar span[data-grade=ok] { color: #fff16b; } .better-xcloud-stats-bar span[data-grade=bad] { color: #ff5f5f; } .better-xcloud-stats-bar span:first-of-type { min-width: 30px; } .better-xcloud-stats-settings { display: none; position: fixed; top: 50%; left: 50%; margin-right: -50%; transform: translate(-50%, -50%); width: 420px; padding: 20px; border-radius: 8px; z-index: 1000; background: #1a1b1e; color: #fff; font-weight: 400; font-size: 16px; font-family: "Segoe UI", Arial, Helvetica, sans-serif; box-shadow: 0 0 6px #000; user-select: none; -webkit-user-select: none; } .better-xcloud-stats-settings *:focus { outline: none !important; } .better-xcloud-stats-settings > b { color: #fff; display: block; font-family: Bahnschrift, Arial, Helvetica, sans-serif; font-size: 26px; font-weight: 400; line-height: 32px; margin-bottom: 12px; } .better-xcloud-stats-settings > div { display: flex; margin-bottom: 8px; padding: 2px 4px; } .better-xcloud-stats-settings label { flex: 1; margin-bottom: 0; align-self: center; } .better-xcloud-stats-settings button { padding: 8px 32px; margin: 20px auto 0; border: none; border-radius: 4px; display: block; background-color: #2d3036; text-align: center; color: white; text-transform: uppercase; font-family: Bahnschrift, Arial, Helvetica, sans-serif; font-weight: 400; line-height: 18px; font-size: 14px; } @media (hover: hover) { .better-xcloud-stats-settings button:hover { background-color: #515863; } } .better-xcloud-stats-settings button:focus { background-color: #515863; } .better-xcloud-quick-settings-bar { display: none; user-select: none; -webkit-user-select: none; position: fixed; bottom: 0; left: 50%; transform: translate(-50%, 0); z-index: 9999; padding: 16px; width: 600px; background: #1a1b1e; color: #fff; border-radius: 8px 8px 0 0; font-weight: 400; font-size: 14px; font-family: Bahnschrift, Arial, Helvetica, sans-serif; text-align: center; box-shadow: 0px 0px 6px #000; opacity: 0.95; } .better-xcloud-quick-settings-bar *:focus { outline: none !important; } .better-xcloud-quick-settings-bar > div { flex: 1; } .better-xcloud-quick-settings-bar label { font-size: 18px; font-weight: bold; display: block; margin-bottom: 8px; } .better-xcloud-quick-settings-bar button { border: none; width: 24px; height: 24px; margin: 0 4px; line-height: 24px; background-color: #515151; color: #fff; border-radius: 4px; } @media (hover: hover) { .better-xcloud-quick-settings-bar button:hover { background-color: #414141; color: white; } } .better-xcloud-quick-settings-bar button:active { background-color: #414141; color: white; } .better-xcloud-quick-settings-bar span { display: inline-block; width: 40px; font-weight: bold; font-family: Consolas, "Courier New", Courier, monospace; font-size: 16px; } .better-xcloud-stream-menu-button-on { fill: #000 !important; background-color: #fff !important; color: #000 !important; } #better-xcloud-touch-controller-bar { display: none; opacity: 0; position: fixed; bottom: 0; left: 0; right: 0; height: 6vh; z-index: 5555; } #better-xcloud-touch-controller-bar[data-showing=true] { display: block !important; } .better-xcloud-wait-time-box { position: fixed; top: 0; right: 0; background-color: #000000cc; color: #fff; z-index: 9999; padding: 12px; border-radius: 0 0 0 8px; } .better-xcloud-wait-time-box label { display: block; text-transform: uppercase; text-align: right; font-size: 12px; font-weight: bold; margin: 0; } .better-xcloud-wait-time-estimated, .better-xcloud-wait-time-countdown { display: block; font-family: Consolas, "Courier New", Courier, monospace; text-align: right; font-size: 16px; } .better-xcloud-wait-time-estimated { margin-bottom: 10px; } /* Hide UI elements */ #headerArea, #uhfSkipToMain, .uhf-footer { display: none; } div[class*=NotFocusedDialog] { position: absolute !important; top: -9999px !important; left: -9999px !important; width: 0px !important; height: 0px !important; } #game-stream video:not([src]) { visibility: hidden; } `; // Reduce animations if (PREFS.get(Preferences.REDUCE_ANIMATIONS)) { css += 'div[class*=GameImageItem-module], div[class*=ScrollArrows-module] { transition: none !important; }'; } // Hide the top-left dots icon while playing if (PREFS.get(Preferences.HIDE_DOTS_ICON)) { css += ` div[class*=Grip-module__container] { visibility: hidden; } @media (hover: hover) { button[class*=GripHandle-module__container]:hover div[class*=Grip-module__container] { visibility: visible; } } button[class*=GripHandle-module__container][aria-expanded=true] div[class*=Grip-module__container] { visibility: visible; } button[class*=GripHandle-module__container][aria-expanded=false] { background-color: transparent !important; } div[class*=StreamHUD-module__buttonsContainer] { padding: 0px !important; } `; } // Hide touch controller if (PREFS.get(Preferences.STREAM_TOUCH_CONTROLLER) === 'off') { css += ` #MultiTouchSurface, #BabylonCanvasContainer-main { display: none !important; } `; } // Simplify Stream's menu css += ` div[class*=StreamMenu-module__menu] { min-width: 100vw !important; } `; if (PREFS.get(Preferences.STREAM_SIMPLIFY_MENU)) { css += ` div[class*=Menu-module__scrollable] { --bxStreamMenuItemSize: 80px; --streamMenuItemSize: calc(var(--bxStreamMenuItemSize) + 40px) !important; } .better-xcloud-badges { top: calc(var(--streamMenuItemSize) - 20px); } body[data-media-type=tv] .better-xcloud-badges { top: calc(var(--streamMenuItemSize) - 10px) !important; } button[class*=MenuItem-module__container] { min-width: auto !important; min-height: auto !important; width: var(--bxStreamMenuItemSize) !important; height: var(--bxStreamMenuItemSize) !important; } div[class*=MenuItem-module__label] { display: none !important; } svg[class*=MenuItem-module__icon] { width: 36px; height: 100% !important; padding: 0 !important; margin: 0 !important; } `; } else { css += ` body[data-media-type=tv] .better-xcloud-badges { top: calc(var(--streamMenuItemSize) + 30px); } body:not([data-media-type=tv]) .better-xcloud-badges { top: calc(var(--streamMenuItemSize) + 20px); } body:not([data-media-type=tv]) button[class*=MenuItem-module__container] { min-width: auto !important; width: 100px !important; } body:not([data-media-type=tv]) button[class*=MenuItem-module__container]:nth-child(n+2) { margin-left: 10px !important; } body:not([data-media-type=tv]) div[class*=MenuItem-module__label] { margin-left: 8px !important; margin-right: 8px !important; } `; } const $style = createElement('style', {}, css); document.documentElement.appendChild($style); } function getPreferredServerRegion() { let preferredRegion = PREFS.get(Preferences.SERVER_REGION); if (preferredRegion in SERVER_REGIONS) { return preferredRegion; } for (let regionName in SERVER_REGIONS) { const region = SERVER_REGIONS[regionName]; if (region.isDefault) { return regionName; } } return '???'; } function updateIceCandidates(candidates) { const pattern = new RegExp(/a=candidate:(?\d+) (?\d+) UDP (?\d+) (?[^\s]+) (?.*)/); const lst = []; for (let item of candidates) { if (item.candidate == 'a=end-of-candidates') { continue; } const groups = pattern.exec(item.candidate).groups; lst.push(groups); } lst.sort((a, b) => (a.ip.includes(':') || a.ip > b.ip) ? -1 : 1); const newCandidates = []; let foundation = 1; lst.forEach(item => { item.foundation = foundation; item.priority = (foundation == 1) ? 100 : 1; newCandidates.push({ 'candidate': `a=candidate:${item.foundation} 1 UDP ${item.priority} ${item.ip} ${item.the_rest}`, 'messageType': 'iceCandidate', 'sdpMLineIndex': '0', 'sdpMid': '0', }); ++foundation; }); newCandidates.push({ 'candidate': 'a=end-of-candidates', 'messageType': 'iceCandidate', 'sdpMLineIndex': '0', 'sdpMid': '0', }); return newCandidates; } function interceptHttpRequests() { var BLOCKED_URLS = []; if (PREFS.get(Preferences.BLOCK_TRACKING)) { BLOCKED_URLS = BLOCKED_URLS.concat([ 'https://arc.msn.com', 'https://browser.events.data.microsoft.com', 'https://dc.services.visualstudio.com', // 'https://2c06dea3f26c40c69b8456d319791fd0@o427368.ingest.sentry.io', ]); } if (PREFS.get(Preferences.BLOCK_SOCIAL_FEATURES)) { // Disable WebSocket WebSocket = { CLOSING: 2, }; BLOCKED_URLS = BLOCKED_URLS.concat([ 'https://peoplehub.xboxlive.com/users/me', 'https://accounts.xboxlive.com/family/memberXuid', 'https://notificationinbox.xboxlive.com', ]); } const xhrPrototype = XMLHttpRequest.prototype; xhrPrototype.orgOpen = xhrPrototype.open; xhrPrototype.orgSend = xhrPrototype.send; xhrPrototype.open = function(method, url) { // Save URL to use it later in send() this._url = url; return this.orgOpen.apply(this, arguments); }; xhrPrototype.send = function(...arg) { for (let blocked of BLOCKED_URLS) { if (this._url.startsWith(blocked)) { return false; } } return this.orgSend.apply(this, arguments); }; const PREF_PREFER_IPV6_SERVER = PREFS.get(Preferences.PREFER_IPV6_SERVER); const PREF_STREAM_TARGET_RESOLUTION = PREFS.get(Preferences.STREAM_TARGET_RESOLUTION); const PREF_STREAM_PREFERRED_LOCALE = PREFS.get(Preferences.STREAM_PREFERRED_LOCALE); const PREF_USE_DESKTOP_CODEC = PREFS.get(Preferences.USE_DESKTOP_CODEC); const PREF_UI_LOADING_SCREEN_GAME_ART = PREFS.get(Preferences.UI_LOADING_SCREEN_GAME_ART); const PREF_UI_LOADING_SCREEN_WAIT_TIME = PREFS.get(Preferences.UI_LOADING_SCREEN_WAIT_TIME); const PREF_STREAM_TOUCH_CONTROLLER = PREFS.get(Preferences.STREAM_TOUCH_CONTROLLER); const PREF_AUDIO_MIC_ON_PLAYING = PREFS.get(Preferences.AUDIO_MIC_ON_PLAYING); const PREF_OVERRIDE_CONFIGURATION = PREF_AUDIO_MIC_ON_PLAYING || PREF_STREAM_TOUCH_CONTROLLER === 'all'; const orgFetch = window.fetch; window.fetch = async (...arg) => { const request = arg[0]; const url = (typeof request === 'string') ? request : request.url; // Server list if (url.endsWith('/v2/login/user')) { const promise = orgFetch(...arg); return promise.then(response => { return response.clone().json().then(obj => { // Get server list if (!Object.keys(SERVER_REGIONS).length) { for (let region of obj.offeringSettings.regions) { SERVER_REGIONS[region.name] = Object.assign({}, region); } // Start rendering UI if (!document.getElementById('gamepass-root')) { setTimeout(watchHeader, 2000); } else { watchHeader(); } } const preferredRegion = getPreferredServerRegion(); if (preferredRegion in SERVER_REGIONS) { const tmp = Object.assign({}, SERVER_REGIONS[preferredRegion]); tmp.isDefault = true; obj.offeringSettings.regions = [tmp]; } response.json = () => Promise.resolve(obj); return response; }); }); } // Get region if (url.endsWith('/sessions/cloud/play')) { // Setup loading screen PREF_UI_LOADING_SCREEN_GAME_ART && LoadingScreen.setup(); // Start hiding cursor if (PREFS.get(Preferences.STREAM_HIDE_IDLE_CURSOR)) { MouseCursorHider.start(); MouseCursorHider.hide(); } const parsedUrl = new URL(url); StreamBadges.region = parsedUrl.host.split('.', 1)[0]; for (let regionName in SERVER_REGIONS) { const region = SERVER_REGIONS[regionName]; if (parsedUrl.origin == region.baseUri) { StreamBadges.region = regionName; break; } } const clone = request.clone(); const body = await clone.json(); // Force stream's resolution if (PREF_STREAM_TARGET_RESOLUTION !== 'auto') { const osName = (PREF_STREAM_TARGET_RESOLUTION === '720p') ? 'android' : 'windows'; body.settings.osName = osName; } // Override "locale" value if (PREF_STREAM_PREFERRED_LOCALE !== 'default') { body.settings.locale = PREF_STREAM_PREFERRED_LOCALE; } const newRequest = new Request(request, { body: JSON.stringify(body), }); arg[0] = newRequest; return orgFetch(...arg); } // Get wait time if (PREF_UI_LOADING_SCREEN_WAIT_TIME && url.includes('xboxlive.com') && url.includes('/waittime/')) { const promise = orgFetch(...arg); return promise.then(response => { return response.clone().json().then(json => { if (json.estimatedAllocationTimeInSeconds > 0) { // Setup wait time overlay LoadingScreen.setupWaitTime(json.estimatedTotalWaitTimeInSeconds); } return response; }); }); } if (url.endsWith('/configuration') && url.includes('/sessions/cloud/') && request.method === 'GET') { PREF_UI_LOADING_SCREEN_GAME_ART && LoadingScreen.hide(); const promise = orgFetch(...arg); if (!PREF_OVERRIDE_CONFIGURATION) { return promise; } // Touch controller for all games if (PREF_STREAM_TOUCH_CONTROLLER === 'all') { TouchController.disable(); // Get game ID from window.location const match = window.location.pathname.match(/\/launch\/[^\/]+\/([\w\d]+)/); // Check touch support if (match) { const titleId = match[1]; !TitlesInfo.hasTouchSupport(titleId) && TouchController.enable(); } // If both settings are invalid -> return promise if (!PREF_AUDIO_MIC_ON_PLAYING && !TouchController.isEnabled()) { return promise; } } // Intercept configurations return promise.then(response => { return response.clone().text().then(text => { if (!text.length) { return response; } const obj = JSON.parse(text); let overrides = JSON.parse(obj.clientStreamingConfigOverrides || '{}') || {}; // Enable touch controller if (TouchController.isEnabled()) { overrides.inputConfiguration = overrides.inputConfiguration || {}; overrides.inputConfiguration.enableTouchInput = true; overrides.inputConfiguration.maxTouchPoints = 10; } // Enable mic if (PREF_AUDIO_MIC_ON_PLAYING) { overrides.audioConfiguration = overrides.audioConfiguration || {}; overrides.audioConfiguration.enableMicrophone = true; } obj.clientStreamingConfigOverrides = JSON.stringify(overrides); response.json = () => Promise.resolve(obj); response.text = () => Promise.resolve(JSON.stringify(obj)); return response; }); }); } // catalog.gamepass if (url.startsWith('https://catalog.gamepass.com') && url.includes('/products')) { const promise = orgFetch(...arg); return promise.then(response => { return response.clone().json().then(json => { for (let productId in json.Products) { TitlesInfo.saveFromCatalogInfo(json.Products[productId]); } return response; }); }); } if (PREF_STREAM_TOUCH_CONTROLLER === 'all' && (url.endsWith('/titles') || url.endsWith('/mru'))) { const promise = orgFetch(...arg); return promise.then(response => { return response.clone().json().then(json => { for (let game of json.results) { TitlesInfo.saveFromTitleInfo(game); } return response; }); }); } // ICE server candidates if (PREF_PREFER_IPV6_SERVER && url.endsWith('/ice') && url.includes('/sessions/cloud/') && request.method === 'GET') { const promise = orgFetch(...arg); return promise.then(response => { return response.clone().text().then(text => { if (!text.length) { return response; } const obj = JSON.parse(text); let exchangeResponse = JSON.parse(obj.exchangeResponse); exchangeResponse = updateIceCandidates(exchangeResponse) obj.exchangeResponse = JSON.stringify(exchangeResponse); response.json = () => Promise.resolve(obj); response.text = () => Promise.resolve(JSON.stringify(obj)); return response; }); }); } for (let blocked of BLOCKED_URLS) { if (!url.startsWith(blocked)) { continue; } return new Response('{"acc":1,"webResult":{}}', { status: 200, statusText: '200 OK', }); } return orgFetch(...arg); } } function injectSettingsButton($parent) { if (!$parent) { return; } const CE = createElement; const PREF_PREFERRED_REGION = getPreferredServerRegion(); const PREF_LATEST_VERSION = PREFS.get(Preferences.LATEST_VERSION, null); // Setup Settings button const $button = CE('button', {'class': 'better-xcloud-settings-button'}, PREF_PREFERRED_REGION); $button.addEventListener('click', e => { const $settings = document.querySelector('.better_xcloud_settings'); $settings.classList.toggle('better-xcloud-gone'); $settings.scrollIntoView(); }); // Show new update status if (PREF_LATEST_VERSION && PREF_LATEST_VERSION !== SCRIPT_VERSION) { $button.setAttribute('data-update-available', true); } // Add Settings button to the web page $parent.appendChild($button); // Setup Settings UI const $container = CE('div', { 'class': 'better_xcloud_settings better-xcloud-gone', }); let $updateAvailable; const $wrapper = CE('div', {'class': 'better-xcloud-settings-wrapper'}, CE('div', {'class': 'better-xcloud-settings-title-wrapper'}, CE('a', { 'class': 'better-xcloud-settings-title', 'href': SCRIPT_HOME, 'target': '_blank', }, 'Better xCloud ' + SCRIPT_VERSION), $updateAvailable = CE('a', { 'class': 'better-xcloud-settings-update', 'href': 'https://github.com/redphx/better-xcloud/releases', 'target': '_blank', }) ) ); $container.appendChild($wrapper); // Show new version indicator if (PREF_LATEST_VERSION && PREF_LATEST_VERSION != SCRIPT_VERSION) { $updateAvailable.textContent = `🌟 Version ${PREF_LATEST_VERSION} available`; $updateAvailable.style.display = 'block'; } // Render settings const SETTINGS_UI = { 'Server': { [Preferences.SERVER_REGION]: 'Region', [Preferences.STREAM_PREFERRED_LOCALE]: 'Preferred game\'s language', [Preferences.PREFER_IPV6_SERVER]: 'Prefer IPv6 server', }, 'Stream': { [Preferences.STREAM_TARGET_RESOLUTION]: 'Target resolution', [Preferences.USE_DESKTOP_CODEC]: 'Force high-quality codec', [Preferences.DISABLE_BANDWIDTH_CHECKING]: 'Disable bandwidth checking', [Preferences.AUDIO_MIC_ON_PLAYING]: 'Enable microphone on game launch', [Preferences.STREAM_HIDE_IDLE_CURSOR]: 'Hide mouse cursor on idle', }, 'Touch controller': { [Preferences.STREAM_TOUCH_CONTROLLER]: 'Availability', [Preferences.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD]: 'Standard layout\'s button style', [Preferences.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM]: 'Custom layout\'s button style', }, 'Loading screen': { [Preferences.UI_LOADING_SCREEN_GAME_ART]: 'Show game art', [Preferences.UI_LOADING_SCREEN_WAIT_TIME]: 'Show the estimated wait time', [Preferences.UI_LOADING_SCREEN_ROCKET]: 'Rocket animation', }, 'UI': { [Preferences.STREAM_SIMPLIFY_MENU]: 'Simplify Stream\'s menu', [Preferences.SKIP_SPLASH_VIDEO]: 'Skip Xbox splash video', [Preferences.HIDE_DOTS_ICON]: 'Hide System menu\'s icon', [Preferences.REDUCE_ANIMATIONS]: 'Reduce UI animations', [Preferences.SCREENSHOT_BUTTON_POSITION]: 'Screenshot button\'s position', }, 'Other': { [Preferences.BLOCK_SOCIAL_FEATURES]: 'Disable social features', [Preferences.BLOCK_TRACKING]: 'Disable xCloud analytics', }, 'Advanced': { [Preferences.USER_AGENT_PROFILE]: 'User-Agent profile', }, }; for (let groupLabel in SETTINGS_UI) { const $group = CE('span', {'class': 'better-xcloud-settings-group-label'}, groupLabel); $wrapper.appendChild($group); for (let settingId in SETTINGS_UI[groupLabel]) { const setting = Preferences.SETTINGS[settingId]; const settingLabel = SETTINGS_UI[groupLabel][settingId]; let $control, $inpCustomUserAgent; let labelAttrs = {}; if (settingId === Preferences.USER_AGENT_PROFILE) { let defaultUserAgent = window.navigator.orgUserAgent || window.navigator.userAgent; $inpCustomUserAgent = CE('input', { 'type': 'text', 'placeholder': defaultUserAgent, 'class': 'better-xcloud-settings-custom-user-agent', }); $inpCustomUserAgent.addEventListener('change', e => { PREFS.set(Preferences.USER_AGENT_CUSTOM, e.target.value.trim()); }); $control = PREFS.toElement(Preferences.USER_AGENT_PROFILE, e => { const value = e.target.value; let isCustom = value === UserAgent.PROFILE_CUSTOM; let userAgent = UserAgent.get(value); $inpCustomUserAgent.value = userAgent; $inpCustomUserAgent.readOnly = !isCustom; $inpCustomUserAgent.disabled = !isCustom; }); } else if (settingId === Preferences.SERVER_REGION) { let selectedValue; $control = CE('select', {id: 'xcloud_setting_' + settingId}); $control.addEventListener('change', e => { PREFS.set(settingId, e.target.value); }); selectedValue = PREF_PREFERRED_REGION; setting.options = {}; for (let regionName in SERVER_REGIONS) { const region = SERVER_REGIONS[regionName]; let value = regionName; let label = regionName; if (region.isDefault) { label += ' (Default)'; value = 'default'; } setting.options[value] = label; } for (let value in setting.options) { const label = setting.options[value]; const $option = CE('option', {value: value}, label); $option.selected = value === selectedValue || label.includes(selectedValue); $control.appendChild($option); } } else { $control = PREFS.toElement(settingId); labelAttrs = {'for': $control.id, 'tabindex': 0}; } // Disable unsupported settings if (settingId === Preferences.USE_DESKTOP_CODEC && !hasHighQualityCodecSupport()) { $control.disabled = true; $control.checked = false; $control.title = 'Your browser doesn\'t support this feature'; } else if (!HAS_TOUCH_SUPPORT) { // Disable this setting for non-touchable devices if ([Preferences.STREAM_TOUCH_CONTROLLER, Preferences.STREAM_TOUCH_CONTROLLER_STYLE_STANDARD, Preferences.STREAM_TOUCH_CONTROLLER_STYLE_CUSTOM].indexOf(settingId) > -1) { $control.disabled = true; $control.title = 'Your device doesn\'t have touch support'; } } $control.disabled && ($control.style.cursor = 'help'); const $elm = CE('div', {'class': 'better-xcloud-settings-row'}, CE('label', labelAttrs, settingLabel), $control ); $wrapper.appendChild($elm); // Add User-Agent input if (settingId === Preferences.USER_AGENT_PROFILE) { $wrapper.appendChild($inpCustomUserAgent); // Trigger 'change' event $control.dispatchEvent(new Event('change')); } } } // Setup Reload button const $reloadBtn = CE('button', {'class': 'better-xcloud-settings-reload-button', 'tabindex': 0}, 'Reload page to reflect changes'); $reloadBtn.addEventListener('click', e => { window.location.reload(); $reloadBtn.textContent = 'Reloading...'; }); $wrapper.appendChild($reloadBtn); // Show Game Pass app version try { const appVersion = document.querySelector('meta[name=gamepass-app-version]').content; const appDate = new Date(document.querySelector('meta[name=gamepass-app-date]').content).toISOString().substring(0, 10); $wrapper.appendChild(CE('div', {'class': 'better-xcloud-settings-app-version'}, `GamePass app ${appVersion} (${appDate})`)); } catch (e) {} // Add Settings UI to the web page const $pageContent = document.getElementById('PageContent'); $pageContent.parentNode.insertBefore($container, $pageContent); } function getVideoPlayerFilterStyle() { const filters = []; const clarity = PREFS.get(Preferences.VIDEO_CLARITY); if (clarity != 0) { const level = (7 - (clarity - 1) * 0.5).toFixed(1); // 5, 5.5, 6, 6.5, 7 const matrix = `0 -1 0 -1 ${level} -1 0 -1 0`; document.getElementById('better-xcloud-filter-clarity-matrix').setAttributeNS(null, 'kernelMatrix', matrix); filters.push(`url(#better-xcloud-filter-clarity)`); } const saturation = PREFS.get(Preferences.VIDEO_SATURATION); if (saturation != 100) { filters.push(`saturate(${saturation}%)`); } const contrast = PREFS.get(Preferences.VIDEO_CONTRAST); if (contrast != 100) { filters.push(`contrast(${contrast}%)`); } const brightness = PREFS.get(Preferences.VIDEO_BRIGHTNESS); if (brightness != 100) { filters.push(`brightness(${brightness}%)`); } return filters.join(' '); } function updateVideoPlayerCss() { let $elm = document.getElementById('better-xcloud-video-css'); if (!$elm) { const CE = createElement; $elm = CE('style', {id: 'better-xcloud-video-css'}); document.documentElement.appendChild($elm); // Setup SVG filters const $svg = CE('svg', { 'id': 'better-xcloud-video-filters', 'xmlns': 'http://www.w3.org/2000/svg', 'class': 'better-xcloud-gone', }, CE('defs', {'xmlns': 'http://www.w3.org/2000/svg'}, CE('filter', {'id': 'better-xcloud-filter-clarity', 'xmlns': 'http://www.w3.org/2000/svg'}, CE('feConvolveMatrix', {'id': 'better-xcloud-filter-clarity-matrix', 'order': '3', 'xmlns': 'http://www.w3.org/2000/svg'})) ) ); document.documentElement.appendChild($svg); } let filters = getVideoPlayerFilterStyle(); let videoCss = ''; if (filters) { videoCss += `filter: ${filters} !important;`; } const PREF_RATIO = PREFS.get(Preferences.VIDEO_RATIO); if (PREF_RATIO) { const minRatio = 16 / 9; let maxRatio = window.innerWidth / window.innerHeight; const ratio = Math.min(maxRatio, PREF_RATIO / 9); if (ratio > minRatio) { videoCss += `aspect-ratio: ${ratio}; width: auto !important; object-fit: unset !important;`; } } let css = ''; if (videoCss) { css = ` div[data-testid="media-container"] { display: flex; } #game-stream video { margin: 0 auto; ${videoCss} } `; } $elm.textContent = css; } function checkHeader() { const $button = document.querySelector('.better-xcloud-settings-button'); if (!$button) { const $rightHeader = document.querySelector('#PageContent div[class*=EdgewaterHeader-module__rightSectionSpacing]'); injectSettingsButton($rightHeader); } } function watchHeader() { const $header = document.querySelector('#PageContent header'); if (!$header) { return; } let timeout; const observer = new MutationObserver(mutationList => { timeout && clearTimeout(timeout); timeout = setTimeout(checkHeader, 2000); }); observer.observe($header, {subtree: true, childList: true}); checkHeader(); } function cloneStreamMenuButton($orgButton, label, svg_icon) { const $button = $orgButton.cloneNode(true); $button.setAttribute('aria-label', label); $button.querySelector('div[class*=label]').textContent = label; const $svg = $button.querySelector('svg'); $svg.innerHTML = svg_icon; $svg.setAttribute('viewBox', '0 0 32 32'); return $button; } function injectStreamMenuButtons() { const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); if (!$screen) { return; } if ($screen.xObserving) { return; } $screen.xObserving = true; const $quickBar = document.querySelector('.better-xcloud-quick-settings-bar'); const $parent = $screen.parentElement; const hideQuickBarFunc = e => { e.stopPropagation(); if (e.target != $parent && e.target.id !== 'MultiTouchSurface' && !e.target.querySelector('#BabylonCanvasContainer-main')) { return; } // Hide Quick settings bar $quickBar.style.display = 'none'; $parent.removeEventListener('click', hideQuickBarFunc); $parent.removeEventListener('touchstart', hideQuickBarFunc); if (e.target.id === 'MultiTouchSurface') { e.target.removeEventListener('touchstart', hideQuickBarFunc); } } const observer = new MutationObserver(mutationList => { mutationList.forEach(item => { if (item.type !== 'childList') { return; } item.addedNodes.forEach(async node => { if (!node.className || !node.className.startsWith('StreamMenu')) { return; } // Get the second last button const $orgButton = node.querySelector('div > div > button:nth-last-child(2)'); if (!$orgButton) { return; } // Create Video Settings button const $btnVideoSettings = cloneStreamMenuButton($orgButton, 'Video settings', ICON_VIDEO_SETTINGS); $btnVideoSettings.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); const msVideoProcessing = $STREAM_VIDEO.msVideoProcessing; if (msVideoProcessing && msVideoProcessing !== 'default') { alert('This feature doesn\'t work when the Clarity Boost mode is ON'); return; } // Close HUD $btnCloseHud.click(); // Show Quick settings bar $quickBar.style.display = 'flex'; $parent.addEventListener('click', hideQuickBarFunc); $parent.addEventListener('touchstart', hideQuickBarFunc); const $touchSurface = document.getElementById('MultiTouchSurface'); $touchSurface && $touchSurface.style.display != 'none' && $touchSurface.addEventListener('touchstart', hideQuickBarFunc); }); // Add button at the beginning $orgButton.parentElement.insertBefore($btnVideoSettings, $orgButton.parentElement.firstChild); // Hide Quick bar when closing HUD const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]'); $btnCloseHud.addEventListener('click', e => { $quickBar.style.display = 'none'; }); // Create Stream Stats button const $btnStreamStats = cloneStreamMenuButton($orgButton, 'Stream stats', ICON_STREAM_STATS); $btnStreamStats.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); // Close HUD $btnCloseHud.click(); // Toggle Stream Stats StreamStats.toggle(); }); const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); $btnStreamStats.classList.toggle('better-xcloud-stream-menu-button-on', btnStreamStatsOn); // Insert after Video Settings button $orgButton.parentElement.insertBefore($btnStreamStats, $btnVideoSettings); // Get "Quit game" button const $btnQuit = $orgButton.parentElement.querySelector('button:last-of-type'); let isHolding = false; let holdTimeout; const onMouseDown = e => { isHolding = false; holdTimeout = setTimeout(() => { isHolding = true; confirm('Do you want to refresh the stream?') && window.location.reload(); }, 1000); }; const onMouseUp = e => { holdTimeout && clearTimeout(holdTimeout); if (isHolding) { e.preventDefault(); e.stopPropagation(); } isHolding = false; }; $btnQuit.addEventListener('mousedown', onMouseDown); $btnQuit.addEventListener('click', onMouseUp); $btnQuit.addEventListener('touchstart', onMouseDown); $btnQuit.addEventListener('touchend', onMouseUp); // Render stream badges const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); $menu.appendChild(await StreamBadges.render()); }); }); }); observer.observe($screen, {subtree: true, childList: true}); } function patchVideoApi() { const PREF_SKIP_SPLASH_VIDEO = PREFS.get(Preferences.SKIP_SPLASH_VIDEO); const PREF_SCREENSHOT_BUTTON_POSITION = PREFS.get(Preferences.SCREENSHOT_BUTTON_POSITION); // Show video player when it's ready var showFunc; showFunc = function() { this.style.visibility = 'visible'; this.removeEventListener('playing', showFunc); if (!this.videoWidth) { return; } onStreamStarted(this); } HTMLMediaElement.prototype.orgPlay = HTMLMediaElement.prototype.play; HTMLMediaElement.prototype.play = function() { LoadingScreen.reset(); if (this.className && this.className.startsWith('XboxSplashVideo')) { if (PREF_SKIP_SPLASH_VIDEO) { this.volume = 0; this.style.display = 'none'; this.dispatchEvent(new Event('ended')); return { catch: () => {}, }; } return this.orgPlay.apply(this); } this.addEventListener('playing', showFunc); injectStreamMenuButtons(); return this.orgPlay.apply(this); }; } function hasHighQualityCodecSupport() { if (typeof HAS_HIGH_QUALITY_CODEC_SUPPORT !== 'undefined') { return HAS_HIGH_QUALITY_CODEC_SUPPORT; } if (typeof RTCRtpTransceiver === 'undefined' || !('setCodecPreferences' in RTCRtpTransceiver.prototype)) { return false; } if (!('getCapabilities' in RTCRtpReceiver)) { return false; } const codecs = RTCRtpReceiver.getCapabilities('video').codecs; for (let codec of codecs) { if (codec.mimeType.toLowerCase() !== 'video/h264' || !codec.sdpFmtpLine) { continue; } const fmtp = codec.sdpFmtpLine.toLowerCase(); if (fmtp.includes('profile-level-id=4d')) { return true; } } return false; } var HAS_HIGH_QUALITY_CODEC_SUPPORT = hasHighQualityCodecSupport(); function patchRtcCodecs() { if (!PREFS.get(Preferences.USE_DESKTOP_CODEC) || !hasHighQualityCodecSupport()) { return; } RTCRtpTransceiver.prototype.orgSetCodecPreferences = RTCRtpTransceiver.prototype.setCodecPreferences; RTCRtpTransceiver.prototype.setCodecPreferences = function(codecs) { // Use the same codecs as desktop const newCodecs = codecs.slice(); let pos = 0; newCodecs.forEach((codec, i) => { // Find high-quality codecs if (codec.sdpFmtpLine && codec.sdpFmtpLine.includes('profile-level-id=4d')) { // Move it to the top of the array newCodecs.splice(i, 1); newCodecs.splice(pos, 0, codec); ++pos; } }); try { this.orgSetCodecPreferences.apply(this, [newCodecs]); } catch (e) { console.log(e); this.orgSetCodecPreferences.apply(this, [codecs]); } } } function setupVideoSettingsBar() { const CE = createElement; const isSafari = UserAgent.isSafari(); const onChange = e => { updateVideoPlayerCss(); } let $stretchInp; const $wrapper = CE('div', {'class': 'better-xcloud-quick-settings-bar'}, CE('div', {}, CE('label', {'for': 'better-xcloud-quick-setting-stretch'}, 'Video Ratio'), PREFS.toNumberStepper(Preferences.VIDEO_RATIO, onChange, ':9')), CE('div', {}, CE('label', {}, 'Clarity'), PREFS.toNumberStepper(Preferences.VIDEO_CLARITY, onChange, '', isSafari)), // disable this feature in Safari CE('div', {}, CE('label', {}, 'Saturation'), PREFS.toNumberStepper(Preferences.VIDEO_SATURATION, onChange, '%')), CE('div', {}, CE('label', {}, 'Contrast'), PREFS.toNumberStepper(Preferences.VIDEO_CONTRAST, onChange, '%')), CE('div', {}, CE('label', {}, 'Brightness'), PREFS.toNumberStepper(Preferences.VIDEO_BRIGHTNESS, onChange, '%')) ); document.documentElement.appendChild($wrapper); } function setupScreenshotButton() { $SCREENSHOT_CANVAS = createElement('canvas', {'class': 'better-xcloud-screenshot-canvas'}); document.documentElement.appendChild($SCREENSHOT_CANVAS); const $canvasContext = $SCREENSHOT_CANVAS.getContext('2d'); const delay = 2000; const $btn = createElement('div', {'class': 'better-xcloud-screenshot-button', 'data-showing': false}); let timeout; const detectDbClick = e => { if (!$STREAM_VIDEO) { timeout = null; $btn.style.display = 'none'; return; } if (timeout) { clearTimeout(timeout); timeout = null; $btn.setAttribute('data-capturing', 'true'); $canvasContext.drawImage($STREAM_VIDEO, 0, 0, $SCREENSHOT_CANVAS.width, $SCREENSHOT_CANVAS.height); $SCREENSHOT_CANVAS.toBlob(blob => { // Download screenshot const now = +new Date; const $anchor = createElement('a', { 'download': `${GAME_TITLE_ID}-${now}.png`, 'href': URL.createObjectURL(blob), }); $anchor.click(); // Free screenshot from memory URL.revokeObjectURL($anchor.href); $canvasContext.clearRect(0, 0, $SCREENSHOT_CANVAS.width, $SCREENSHOT_CANVAS.height); // Hide button $btn.setAttribute('data-showing', 'false'); setTimeout(() => { if (!timeout) { $btn.setAttribute('data-capturing', 'false'); } }, 100); }, 'image/png'); return; } const isShowing = $btn.getAttribute('data-showing') === 'true'; if (!isShowing) { // Show button $btn.setAttribute('data-showing', 'true'); $btn.setAttribute('data-capturing', 'false'); clearTimeout(timeout); timeout = setTimeout(() => { timeout = null; $btn.setAttribute('data-showing', 'false'); $btn.setAttribute('data-capturing', 'false'); }, delay); } } $btn.addEventListener('mousedown', detectDbClick); document.documentElement.appendChild($btn); } function patchHistoryMethod(type) { var orig = window.history[type]; return function(...args) { const event = new Event('xcloud_popstate'); event.arguments = args; window.dispatchEvent(event); return orig.apply(this, arguments); }; }; function onHistoryChanged() { const $settings = document.querySelector('.better_xcloud_settings'); if ($settings) { $settings.classList.add('better-xcloud-gone'); } const $quickBar = document.querySelector('.better-xcloud-quick-settings-bar'); if ($quickBar) { $quickBar.style.display = 'none'; } STREAM_WEBRTC = null; $STREAM_VIDEO = null; StreamStats.onStoppedPlaying(); document.querySelector('.better-xcloud-screenshot-button').style = ''; MouseCursorHider.stop(); TouchController.reset(); LoadingScreen.reset(); setTimeout(checkHeader, 2000); } function onStreamStarted($video) { // Get title ID for screenshot's name GAME_TITLE_ID = /\/launch\/([^/]+)/.exec(window.location.pathname)[1]; if (TouchController.isEnabled()) { TouchController.enableBar(); } const PREF_SCREENSHOT_BUTTON_POSITION = PREFS.get(Preferences.SCREENSHOT_BUTTON_POSITION); const PREF_STATS_QUICK_GLANCE = PREFS.get(Preferences.STATS_QUICK_GLANCE); // Setup Stat's Quick Glance mode if (PREF_STATS_QUICK_GLANCE) { StreamStats.quickGlanceSetup(); // Show stats bar StreamStats.start(true); } $STREAM_VIDEO = $video; $SCREENSHOT_CANVAS.width = $video.videoWidth; $SCREENSHOT_CANVAS.height = $video.videoHeight; StreamBadges.resolution = {width: $video.videoWidth, height: $video.videoHeight}; StreamBadges.startTimestamp = +new Date; // Get battery level try { navigator.getBattery && navigator.getBattery().then(bm => { StreamBadges.startBatteryLevel = Math.round(bm.level * 100); }); } catch(e) {} STREAM_WEBRTC.getStats().then(stats => { const allVideoCodecs = {}; let videoCodecId; const allAudioCodecs = {}; let audioCodecId; stats.forEach(stat => { if (stat.type == 'codec') { const mimeType = stat.mimeType.split('/'); if (mimeType[0] === 'video') { // Store all video stats allVideoCodecs[stat.id] = stat; } else if (mimeType[0] === 'audio') { // Store all audio stats allAudioCodecs[stat.id] = stat; } } else if (stat.type === 'inbound-rtp' && stat.packetsReceived > 0) { // Get the codecId of the video/audio track currently being used if (stat.kind === 'video') { videoCodecId = stat.codecId; } else if (stat.kind === 'audio') { audioCodecId = stat.codecId; } } }); // Get video codec from codecId if (videoCodecId) { const videoStat = allVideoCodecs[videoCodecId]; const video = { codec: videoStat.mimeType.substring(6), }; if (video.codec === 'H264') { const match = /profile-level-id=([0-9a-f]{6})/.exec(videoStat.sdpFmtpLine); video.profile = match ? match[1] : null; } StreamBadges.video = video; } // Get audio codec from codecId if (audioCodecId) { const audioStat = allAudioCodecs[audioCodecId]; StreamBadges.audio = { codec: audioStat.mimeType.substring(6), bitrate: audioStat.clockRate, } } if (PREFS.get(Preferences.STATS_SHOW_WHEN_PLAYING)) { StreamStats.start(); } }); // Setup screenshot button if (PREF_SCREENSHOT_BUTTON_POSITION !== 'none') { const $btn = document.querySelector('.better-xcloud-screenshot-button'); $btn.style.display = 'block'; if (PREF_SCREENSHOT_BUTTON_POSITION === 'bottom-right') { $btn.style.right = '0'; } else { $btn.style.left = '0'; } } } function disablePwa() { const userAgent = (window.navigator.orgUserAgent || window.navigator.userAgent || '').toLowerCase(); if (!userAgent) { return; } // Check if it's Safari on mobile if (UserAgent.isSafari(true)) { // Disable the PWA prompt Object.defineProperty(window.navigator, 'standalone', { value: true, }); } } // Hide Settings UI when navigate to another page window.addEventListener('xcloud_popstate', onHistoryChanged); window.addEventListener('popstate', onHistoryChanged); // Make pushState/replaceState methods dispatch "xcloud_popstate" event window.history.pushState = patchHistoryMethod('pushState'); window.history.replaceState = patchHistoryMethod('replaceState'); PreloadedState.override(); // Disable bandwidth checking if (PREFS.get(Preferences.DISABLE_BANDWIDTH_CHECKING)) { Object.defineProperty(window.navigator, 'connection', { get: () => undefined, }); } // Check for Update checkForUpdate(); // Monkey patches RTCPeerConnection.prototype.orgAddIceCandidate = RTCPeerConnection.prototype.addIceCandidate; RTCPeerConnection.prototype.addIceCandidate = function(...args) { const candidate = args[0].candidate; if (candidate && candidate.startsWith('a=candidate:1 ')) { STREAM_WEBRTC = this; StreamBadges.ipv6 = candidate.substring(20).includes(':'); } return this.orgAddIceCandidate.apply(this, args); } if (PREFS.get(Preferences.STREAM_TOUCH_CONTROLLER) === 'all') { TouchController.setup(); } patchRtcCodecs(); interceptHttpRequests(); patchVideoApi(); // Setup UI addCss(); updateVideoPlayerCss(); window.addEventListener('resize', updateVideoPlayerCss); setupVideoSettingsBar(); setupScreenshotButton(); StreamStats.render(); disablePwa();