Initial commit

This commit is contained in:
redphx 2023-07-14 08:37:42 +07:00
commit 4489ea2a41
3 changed files with 883 additions and 0 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 redphx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

64
README.md Normal file
View File

@ -0,0 +1,64 @@
# Better xCloud
Improve [Xbox Cloud Gaming (xCloud)](https://www.xbox.com/play/) experience.
The main target of this script is Android users, but it should work great on desktop too.
## Features:
- Switch region of streaming server.
- Prefer IPv6 streaming server (might improve latency).
- Force HD stream by disabling bandwidth checking -> xCloud always tries to use the best possible quality.
- Skip Xbox splash video.
- Make the top-left dots icon invisible while playing. You can still click on it, but it doesn't block the screen anymore.
- Adjust video filters (brightness/contrast/saturation).
- Hide footer and other UI elements.
- Reduce UI animations (the smooth scrolling cannot be disabled).
- Disable analytics.
- Disable social features (friends, chat...).
## How to use:
1. Install [Tampermonkey extension](https://www.tampermonkey.net/) on suppported browsers. It's also available for Firefox on Android.
2. Install **Better xCloud**:
- [Directly on Github](https://github.com/redphx/better-xcloud/raw/main/better-xcloud.user.js)
4. Refresh [xCloud web page](https://www.xbox.com/play/).
5. Click on the new "SERVER NAME" button next to your profile picture to adjust settings.
6. Optional but recommended: change your browser's User-Agent. Check the [User-Agent section](#user-agent) below for more info.
7. Don't forget to reload the page after changing settings.
## User-Agent
Optional, as changing User-Agent won't guarantee a better streaming experience, but it's worth a try. You might need to install an external extension to do that.
It's recommended to change User-Agent to:
```
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Edg/114.0.1823.67
```
This will trick xCloud into thinking you're using Edge browser on desktop.
Other options (only do one of these):
- Add ` smarttv` to switch to Smart TV layout.
- Add ` Xbox;` to become an Xbox console.
- Add ` 36102dd3-6953-45f6-8b48-031fb95e0e0d` to become a Logitech G Cloud device.
- Add ` 0ed22b6f-b61d-41eb-810a-a1ed586a550b` to become a Razer Edge device.
## Tested on:
- Chrome on macOS.
- Firefox for Android with Tampermonkey add-on.
- *(NOT RECOMMENDED at the moment since its Userscript implementation is not working properly)* [Hermit Browser](https://hermit.chimbori.com) on Android. It supports custom User-Agent and has built-in Userscript support (premium features, only $7.99) so you don't have to install anything else. I built **Better xCloud** just so I could use it with Hermit.
## FAQ
1. **Why is it an Userscript and not extension?**
It's because not many browsers on Android support installing extensions (and not all extensions can be installed).
2. **I see "???" button instead of server's name**
That means Tampermonkey is not working properly. Please make sure you're using the latest version or switch to a well-known browser.
3. **Can I use this with the Xbox Android app?**
No you can't. You'll have to modidy the app.
## Acknowledgements
**Better xCloud** is inspired by these projects:
- [n-thumann/xbox-cloud-server-selector](https://github.com/n-thumann/xbox-cloud-server-selector)
## Disclaimers
- Use as your own risk.
- This project is not affiliated with Xbox in any way. All Xbox logos/icons/trademarks are copyright of their respective owners.

798
better-xcloud.user.js Normal file
View File

@ -0,0 +1,798 @@
// ==UserScript==
// @name Better xCloud
// @namespace https://github.com/redphx
// @version 1.0
// @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://github.com/redphx/better-xcloud/raw/main/better-xcloud.user.js
// @downloadURL https://github.com/redphx/better-xcloud/raw/main/better-xcloud.user.js
// ==/UserScript==
'use strict';
const SCRIPT_VERSION = '1.0';
const SCRIPT_HOME = 'https://github.com/redphx/better-xcloud';
const SERVER_REGIONS = {};
class Preferences {
static get SERVER_REGION() { return 'server_region'; }
static get PREFER_IPV6_SERVER() { return 'prefer_ipv6_server'; }
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 VIDEO_BRIGHTNESS() { return 'video_brightness'; }
static get VIDEO_CONTRAST() { return 'video_contrast'; }
static get VIDEO_SATURATION() { return 'video_saturation'; }
static SETTINGS = [
{
'id': Preferences.SERVER_REGION,
'label': 'Region of streaming server',
'default': 'default',
},
{
'id': Preferences.PREFER_IPV6_SERVER,
'label': 'Prefer IPv6 streaming server',
'default': false,
},
{
'id': Preferences.DISABLE_BANDWIDTH_CHECKING,
'label': 'Force HD stream',
'default': false,
},
{
'id': Preferences.SKIP_SPLASH_VIDEO,
'label': 'Skip Xbox splash video',
'default': false,
},
{
'id': Preferences.HIDE_DOTS_ICON,
'label': 'Hide Dots icon while playing',
'default': false,
},
{
'id': Preferences.REDUCE_ANIMATIONS,
'label': 'Reduce UI animations',
'default': false,
},
{
'id': Preferences.BLOCK_SOCIAL_FEATURES,
'label': 'Disable social features (Friends, Chat...)',
'default': false,
},
{
'id': Preferences.BLOCK_TRACKING,
'label': 'Disable analytics',
'default': false,
},
{
'id': Preferences.VIDEO_SATURATION,
'label': 'Video saturation (%)',
'default': 100,
'min': 0,
'max': 200,
},
{
'id': Preferences.VIDEO_CONTRAST,
'label': 'Video contrast (%)',
'default': 100,
'min': 0,
'max': 200,
},
{
'id': Preferences.VIDEO_BRIGHTNESS,
'label': 'Video brightness (%)',
'default': 100,
'min': 0,
'max': 200,
},
]
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 setting of Preferences.SETTINGS) {
if (setting.id in savedPrefs) {
this._prefs[setting.id] = savedPrefs[setting.id];
} else {
this._prefs[setting.id] = setting.default;
}
}
}
get(key, defaultValue=null) {
const value = this._prefs[key];
if (typeof value !== 'undefined' && value !== null && value !== '') {
return value;
}
if (defaultValue !== null) {
return defaultValue;
}
// Get default value
for (let setting of Preferences.SETTINGS) {
if (setting.id == key) {
return setting.default;
}
}
return null;
}
set(key, value) {
this._prefs[key] = value;
this._update_storage();
}
_update_storage() {
this._storage.setItem(this._key, JSON.stringify(this._prefs));
}
}
const PREFS = new Preferences();
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_xlcoud_settings_button:focus {
background-color: #515863;
}
.better_xcloud_settings {
background-color: #151515;
user-select: none;
color: #fff;
font-family: "Segoe UI", Arial, Helvetica, sans-serif
}
.better_xcloud_settings_gone {
display: none;
}
.better_xcloud_settings_wrapper {
width: 400px;
margin: auto;
padding: 12px;
}
.better_xcloud_settings_wrapper *:focus {
outline: none !important;
}
.better_xcloud_settings_wrapper a {
font-family: Bahnschrift, Arial, Helvetica, sans-serif;
font-size: 20px;
text-decoration: none;
font-weight: bold;
display: block;
margin-bottom: 8px;
}
.better_xcloud_settings_wrapper .setting_row {
display: flex;
margin-bottom: 8px;
}
.better_xcloud_settings_wrapper .setting_row label {
flex: 1;
align-self: center;
margin-bottom: 0;
}
.better_xcloud_settings_wrapper .setting_row input {
align-self: center;
}
.better_xcloud_settings_wrapper .setting_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;
}
.better_xcloud_settings_wrapper .setting_button:hover {
background-color: #06743f;
}
.better_xcloud_settings_color_bars {
display: none;
width: 100%;
aspect-ratio: 16/6;
margin-top: 10px;
}
.better_xcloud_settings_color_bars div {
flex: 1;
}
/* 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;
}
`;
// 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;
}
`;
}
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:(?<order>\d+) (?<num>\d+) UDP (?<priority>\d+) (?<ip>[^\s]+) (?<the_rest>.*)/);
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 order = 1;
let priority = 100;
lst.forEach(item => {
item.order = order;
item.priority = priority;
newCandidates.push({
'candidate': `a=candidate:${item.order} 1 UDP ${item.priority} ${item.ip} ${item.the_rest}`,
'messageType': 'iceCandidate',
'sdpMLineIndex': '0',
'sdpMid': '0',
});
++order;
--priority;
});
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 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;
});
});
}
// ICE server candidates
if (PREF_PREFER_IPV6_SERVER && url.endsWith('/ice') && url.includes('/sessions/cloud/')) {
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);
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);
}
}
// Quickly create a tree of elements without having to use innerHTML
function createElement(elmName, props = {}) {
const $elm = document.createElement(elmName);
for (let key in props) {
if (!props.hasOwnProperty(key) || $elm.hasOwnProperty(key)) {
continue;
}
let value = props[key];
$elm.setAttribute(key, value);
}
for (let i = 2, size = arguments.length; i < size; i++) {
const arg = arguments[i];
const argType = typeof arg;
if (argType == 'string' || argType == 'number') {
$elm.innerText = arg;
} else {
$elm.appendChild(arg);
}
}
return $elm;
}
function injectSettingsButton($parent) {
if (!$parent) {
return;
}
const CE = createElement;
const preferredRegion = getPreferredServerRegion();
const $button = CE('button', {'class': 'better_xcloud_settings_button'}, preferredRegion);
$button.addEventListener('click', e => {
const $settings = document.querySelector('.better_xcloud_settings');
$settings.classList.toggle('better_xcloud_settings_gone');
$settings.scrollIntoView();
});
$parent.appendChild($button);
const $container = CE('div', {
'class': 'better_xcloud_settings better_xcloud_settings_gone',
});
const $wrapper = CE('div', {
'class': 'better_xcloud_settings_wrapper',
});
$container.appendChild($wrapper);
const $title = CE('a', {
href: SCRIPT_HOME,
target: '_blank',
}, 'Better xCloud ' + SCRIPT_VERSION);
$wrapper.appendChild($title);
for (let setting of Preferences.SETTINGS) {
let $control;
if (setting.id === Preferences.SERVER_REGION) {
$control = CE('select', {id: 'xcloud_setting_' + setting.id});
$control.addEventListener('change', e => {
PREFS.set(Preferences.SERVER_REGION, e.target.value);
});
for (let regionName in SERVER_REGIONS) {
const region = SERVER_REGIONS[regionName];
let value = regionName;
let label = regionName;
if (region.isDefault) {
label += ' (Default)';
value = 'default';
}
const $option = CE('option', {value: value}, label);
$option.selected = regionName === preferredRegion;
$control.appendChild($option);
}
} else if (typeof setting.default === 'number') {
$control = CE('input', {
id: 'xcloud_setting_' + setting.id,
type: 'number',
size: 5,
'data-key': setting.id,
});
if ('min' in setting) {
$control.setAttribute('min', setting.min);
}
if ('max' in setting) {
$control.setAttribute('max', setting.max);
}
$control.value = PREFS.get(setting.id);
if (setting.id.startsWith('video_')) {
$control.addEventListener('change', e => {
if (!e.target.value) {
return;
}
PREFS.set(e.target.getAttribute('data-key'), parseInt(e.target.value));
const filters = getVideoPlayerFilterStyle();
const $elm = document.querySelector('.better_xcloud_settings_color_bars');
$elm.style.display = 'flex';
$elm.style.filter = filters;
updateVideoPlayerCss();
});
}
} else {
$control = CE('input', {
id: 'xcloud_setting_' + setting.id,
type: 'checkbox',
'data-key': setting.id,
});
$control.addEventListener('change', e => {
PREFS.set(e.target.getAttribute('data-key'), e.target.checked);
});
setting.value = PREFS.get(setting.id);
$control.checked = setting.value;
}
const $elm = CE('div', {'class': 'setting_row'},
CE('label', {'for': 'xcloud_setting_' + setting.id}, setting.label),
$control
);
$wrapper.appendChild($elm);
}
const COLOR_BARS = [
'white',
'yellow',
'cyan',
'green',
'magenta',
'red',
'blue',
'black',
];
const $colorBars = CE('div', {'class': 'better_xcloud_settings_color_bars'});
COLOR_BARS.forEach(color => {
$colorBars.appendChild(CE('div', {
style: `background-color: ${color}`,
}));
});
$wrapper.appendChild($colorBars);
const $reloadBtn = CE('button', {'class': 'setting_button'}, 'Reload page to reflect changes');
$reloadBtn.addEventListener('click', e => window.location.reload());
$wrapper.appendChild($reloadBtn);
const $pageContent = document.getElementById('PageContent');
$pageContent.parentNode.insertBefore($container, $pageContent);
}
function getVideoPlayerFilterStyle() {
const filters = [];
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) {
$elm = createElement('style', {id: 'better-xcloud-video-css'});
document.documentElement.appendChild($elm);
}
let filters = getVideoPlayerFilterStyle();
let css = '';
if (filters) {
css = `#game-stream video {filter: ${filters}}`;
}
$elm.textContent = css;
}
function checkHeader() {
const $button = document.querySelector('#PageContent header .better_xcloud_settings_button');
if (!$button) {
const $rightHeader = document.querySelector('#PageContent header div[class*=EdgewaterHeader-module__rightSectionSpacing]');
injectSettingsButton($rightHeader);
updateVideoPlayerCss();
}
}
function watchHeader() {
const $header = document.querySelector('#PageContent header');
if (!$header) {
return;
}
let timeout;
const observer = new MutationObserver(mutationList => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(checkHeader, 2000);
});
observer.observe($header, { subtree: true, childList: true});
checkHeader();
}
function patchVideoApi() {
const PREF_SKIP_SPLASH_VIDEO = PREFS.get(Preferences.SKIP_SPLASH_VIDEO);
// Do nothing if the "Skip splash video" setting is off
if (!PREF_SKIP_SPLASH_VIDEO) {
return;
}
HTMLMediaElement.prototype.orgPlay = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
if (!this.className.startsWith('XboxSplashVideo')) {
return this.orgPlay.apply(this);
}
this.volume = 0;
this.style.display = 'none';
this.dispatchEvent(new Event('ended'));
return {
catch: () => {},
};
};
}
function patchHistoryMethod(type) {
var orig = window.history[type];
return function(...args) {
const rv = orig.apply(this, arguments);
const event = new Event('xcloud_popstate');
event.arguments = args;
window.dispatchEvent(event);
return rv;
};
};
function hideSettingsOnPageChange() {
const $settings = document.querySelector('.better_xcloud_settings');
if ($settings) {
$settings.classList.add('better_xcloud_settings_gone');
}
}
// Hide Settings UI when navigate to another page
window.addEventListener('xcloud_popstate', hideSettingsOnPageChange);
window.addEventListener('popstate', hideSettingsOnPageChange);
// Make pushState/replaceState methods dispatch "xcloud_popstate" event
window.history.pushState = patchHistoryMethod('pushState');
window.history.replaceState = patchHistoryMethod('replaceState');
// Add additional CSS
addCss();
// Clear data of window.navigator.userAgentData, force Xcloud to detect browser based on User-Agent header
Object.defineProperty(window.navigator, 'userAgentData', {});
// Disable bandwidth checking
if (PREFS.get(Preferences.DISABLE_BANDWIDTH_CHECKING)) {
Object.defineProperty(window.navigator, 'connection', {
get: () => undefined,
});
}
interceptHttpRequests();
patchVideoApi();
// Workaround for Hermit browser
var onLoadTriggered = false;
window.onload = () => {
onLoadTriggered = true;
};
if (document.readyState === 'complete' && !onLoadTriggered) {
watchHeader();
}