Compare commits

...

27 Commits
v1.4 ... v1.6

Author SHA1 Message Date
e43c34ed3a Bump version to 1.6 2023-07-23 17:45:27 +07:00
28a2e32fc5 Update README.md 2023-07-23 17:44:50 +07:00
b564de249a Show stream stats (#16)
* Get video and audio info from RTCPeerConnection.getStats()

* Show stream's stats bar

* Make Stream menu icon's size smaller

* Add shadow to stream badges

* Show bitrate

* Add button to toggle Stream Stats

* Rename StreamStatus to StreamBadges

* Show '???' then currentRoundTripTime is undefined

* Remove work-around for browsers with no setCodecPreferences() support as it's not working

* Remove updateVideoPlayerPreview()

* Disable USE_DESKTOP_CODEC setting on unsupported browsers
2023-07-23 17:09:30 +07:00
9fa073da82 Update README.md 2023-07-22 15:41:14 +07:00
958f5410f4 Update README.md 2023-07-22 15:20:45 +07:00
15d3efdf4e Update README.md 2023-07-22 15:20:35 +07:00
645b49751d Update README.md 2023-07-22 15:15:16 +07:00
53913ae218 Update README.md 2023-07-22 12:23:31 +07:00
2569e73018 Bump version to 1.5 2023-07-22 12:13:42 +07:00
4c790ac38d Update README.md 2023-07-22 12:08:27 +07:00
11c233e14e Add screenshot capture feature (#15)
* Add a button to test screenshot feature

* Update CSS of the screenshot button

* Prevent screenshot button from blocking UI

* Add setting to change screenshot button's position
2023-07-22 11:46:19 +07:00
a81cb86140 Update README.md 2023-07-22 09:23:58 +07:00
c3de245545 Rename "Dimension" to "Resolution" 2023-07-22 07:29:31 +07:00
a7ab506f0f Update README.md 2023-07-22 07:02:42 +07:00
8ecff6adae Update issue templates 2023-07-22 06:51:44 +07:00
27ec45512d Update issue templates 2023-07-22 06:42:30 +07:00
3a654b99cb Fix video not showing in Chrome 2023-07-22 06:36:26 +07:00
a009cca866 Update README.md 2023-07-21 17:28:29 +07:00
19302ea444 Update README.md 2023-07-21 17:03:03 +07:00
3c4248c1c7 Update README.md 2023-07-21 16:51:40 +07:00
de834fcb80 Bump version to 1.4.2 2023-07-21 16:49:28 +07:00
baf2c2a35d Fix "Force high quality codec" feature not working with Kiwi Browser 2023-07-21 16:42:57 +07:00
522830a47d Update README.md 2023-07-21 15:16:57 +07:00
7207646379 Bump version to 1.4.1 2023-07-21 09:02:37 +07:00
597c150c77 Update README.md 2023-07-21 09:02:10 +07:00
e7980c186d Add Kiwi Browser support (#11)
* Catch exception when trying to force desktop codec profile on Kiwi Browser

* Improve setCodecPreferences(), avoid crashing

* Improve codec status detection

* Fix codec detection
2023-07-21 08:57:05 +07:00
0c38b54c38 Update README.md 2023-07-20 21:06:49 +07:00
4 changed files with 570 additions and 182 deletions

33
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,33 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Platform (please complete the following information):**
- OS: [e.g. Android]
- Browser: [e.g. chrome, firefox]
- Browser Version: [e.g. 100]
- Better xCloud Version: [e.g. 1.4]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -6,29 +6,34 @@ Give this project a 🌟 if you like it. Thank you 🙏.
## Features
<img width="500" alt="Settings UI" src="https://github.com/redphx/better-xcloud/assets/96280/8fb9f0ac-85f5-4e5a-9570-5a5e119e4fc1">
<img width="500" alt="Video Settings UI" src="https://github.com/redphx/better-xcloud/assets/96280/ed219d50-02ab-40bd-95c5-a010956d77bf">
<img width="475" alt="Settings UI" src="https://github.com/redphx/better-xcloud/assets/96280/ad687344-214d-4822-affe-21f1b1e105c8">
<img width="475" alt="Stream HUD UI" src="https://github.com/redphx/better-xcloud/assets/96280/ff695d3a-b077-4b21-b778-beb0a1fdd6be">
&nbsp;
**Demo video:** https://youtu.be/oDr5Eddp55E
- **Switch region of streaming server**
> Connect to another server instead of the default one. Check [FAQ section](#faq) for some notes.
> Connect to another server instead of the default one. Check the [**FAQ** section](#faq) for some notes.
> Not working in Hermit ([#5](https://github.com/redphx/better-xcloud/issues/5)).
- **Force 1080p stream**
> By default you only get 1080p stream when playing on desktop.
> This feature will give you 1080p stream even on mobile, without having to change User-Agent.
> Not working in Hermit ([#5](https://github.com/redphx/better-xcloud/issues/5)).
- **Force high quality codec <sup>(\*)</sup>**
> Force xCloud to use the best streaming codec profile (same as desktop & TV). You don't have to change User-Agent anymore.
- **Force high quality codec (if possible)<sup>(\*)</sup>**
> Force xCloud to use the best streaming codec profile (same as desktop & TV) if possible. You don't have to change User-Agent anymore.
> You should enable this feature even if you're on desktop.
> Not available for some browsers (Firefox, Safari...).
> Use more bandwidth & battery.
> Comparison video with the setting ON & OFF: https://youtu.be/-9PuBJJSgR4
> Disable if it causes crashes.
- **Prefer IPv6 streaming server**
> Might reduce latency
> Might reduce latency.
- **Disable bandwidth checking**
> xCloud won't reduce quality when the internet speed is slow
- **🔥 Capture screenshot**
> Exclusive to **Better xCloud**. Check the [**Capture screenshot** section](#capture-screenshot) for more info.
- **Skip Xbox splash video**
> Save 3 seconds
> Save 3 seconds.
- **Hide Dots icon while playing**
> You can still click on it, but it doesn't block the screen anymore
- **Reduce UI animations**
@ -36,9 +41,9 @@ Give this project a 🌟 if you like it. Thank you 🙏.
- **Stretch video to full sctreen**
> Useful when you don't have a 16:9 screen
- **Adjust video filters**
> Brightness/Contrast/Saturation
> Brightness/Contrast/Saturation.
- **Display stream's statuses**
> Region/Server/Quality/Dimension...
> Region/Server/Quality/Resolution...
- **Disable social features**
> Features like friends, chat... Disable these will make the page load faster.
> Not working in Hermit ([#5](https://github.com/redphx/better-xcloud/issues/5)).
@ -46,11 +51,13 @@ Give this project a 🌟 if you like it. Thank you 🙏.
> The analytics contains statistics of your streaming session, so I'd recommend to allow analytics to help Xbox improve xCloud's experence in the future.
> Not working in Hermit ([#5](https://github.com/redphx/better-xcloud/issues/5)).
- **Hide footer and other UI elements**
- **🔥 Show stream stats**
> Check [Stream stats section](#stream-stats) for more info.
<sup>(\*)</sup> By default (for compatibility reasons) xCloud only uses high quality codec profile when you use Tizen TV or Chrome/Edge/Chromium browser on Chrome/MacOS. Enable this setting will give you the best experience no matter what platform & browser you're on.
## How to use
1. Install [Tampermonkey extension](https://www.tampermonkey.net/) on suppported browsers. It's also available for Firefox on Android.
1. Install [Tampermonkey extension](https://www.tampermonkey.net/) on suppported browsers.
2. Install **Better xCloud**:
- [Stable version](https://github.com/redphx/better-xcloud/releases/latest/download/better-xcloud.user.js)
- [Dev version](https://github.com/redphx/better-xcloud/raw/main/better-xcloud.user.js)
@ -64,18 +71,64 @@ To update manually, just install the script again (you won't lose your settings)
✅ = confirmed to be working
❓ = not yet tested
❌ = not supported (mostly because of lacking Userscript/extension support)
= unavailable
⚠️ = see custom notes
| | Desktop | Android | iOS |
|----------------------------------------|------------------|------------------|-----------------|
| Chrome/Edge/Chromium variants | ✅ | ❌ | ❌ |
| Firefox | ✅ | | ❌ |
| Safari | ✅<sup>(1)</sup> | | ✅<sup>(2)</sup> |
| [Hermit](https://hermit.chimbori.com) | | ⚠️<sup>(3)</sup> | |
| | Desktop | Android/Android TV | iOS |
|-----------------------------------------|:-----------------|:-------------------|:----------------|
| Chrome/Edge/Chromium variants | ✅ | ❌ | ❌ |
| Firefox | ✅ | ⚠️<sup>(1)</sup> | ❌ |
| Safari | ✅<sup>(2)</sup> | | ✅<sup>(3)</sup> |
| [Hermit](https://hermit.chimbori.com) | | ⚠️<sup>(4)</sup> | |
| [Kiwi Browser](https://kiwibrowser.com) | | ✅ | |
Don't see your browser in the table? If it supports Tampermonkey/Userscript then the answer is likely **"YES"**.
<sup>1, 2</sup> Requires [Userscripts app](https://apps.apple.com/us/app/userscripts/id1463298887) (free & open source).
<sup>3</sup> NOT RECOMMENDED at the moment since its Userscript implementation is not working properly (see https://github.com/redphx/better-xcloud/issues/5 for full details). It's still my favorite app to play xCloud on because it's lightweight, supports Userscript (premium features, only $1.99) without having to install anything else. I built **Better xCloud** just so I could use it with Hermit.
<sup>1</sup> Follow [this guide](https://support.mozilla.org/en-US/kb/find-and-install-add-ons-firefox-android) to install Tampermonkey on Firefox Android. Its Gamepad API doesn't work properly so it might not recognize your controller.
<sup>2, 3</sup> Requires [Userscripts app](https://apps.apple.com/us/app/userscripts/id1463298887) (free & open source).
<sup>4</sup> NOT RECOMMENDED at the moment since its Userscript implementation is not working properly (see https://github.com/redphx/better-xcloud/issues/5 for full details).
---
- **Kiwi Browser** is the best choice on Android. All features work, it means you can get 1080p stream + high quality codec profile (the best possible quality).
- **Better xCloud** also works on Android TV, but you'll have to sideload the browser APK and need a bluetooth mouse if you want to interact with the Settings.
## Stream stats
![image](https://github.com/redphx/better-xcloud/assets/96280/dc0f4f36-8a69-4ec1-aadd-cfda2d701991)
- While playing > `...` > `Stream Stats`.
- This bar is updated every second.
| Abbr. | Full name | Explain |
|------:|:-------------------|:------------------------------------------------------------------------------------------------------------------|
| FPS | Frames per Seconds | The number of decoded frames in the last second |
| RTT | Round Trip Time | The number of seconds it takes for data to be sent from your device to the server and back over (lower is better) |
| BR | Bitrate | The amount of data server send to your device in the last second |
| PL | Packets Lost | Total number of packets lost |
| FL | Frames Lost | The total number of frames dropped prior to decode or dropped because the frame missed its display deadline |
These info are provied by WebRTC API. You can use browser's built-in tool to see more info:
- Chrome/Edge/Chromium variants: `chrome://webrtc-internals`
- Firefox: `about:webrtc`
## Capture screenshot
- This feature is only available in **Better xCloud**.
- Works on both desktop & mobile, but it's designed for mobile users.
- It's client-side only.
- It captures the current frame of the stream and save to a file. That means you won't get the raw quality like when you play on console, but it's still better than using the built-in screenshot feature on your phone.
- Screenshot's resolution & quality depend on the quality of the stream at the moment.
- Screenshot doesn't include touch UI, notification bar... only the gameplay.
- There might be a slight delay.
- ⚠️ It's not possible to map the Share/Screenshot button on your controller to this feature.
### How to capture screenshot
1. Enable this feature in setting.
2. Play a game.
3. Tap once at the bottom left/right (depend on your setting) to show the Screenshot button.
4. Tap on that button to capture screenshot.
5. Screenshot will be saved by browser.
6. You can double tap that corner to capture screenshot.
<img width="600" alt="Screenshot button" src="https://github.com/redphx/better-xcloud/assets/96280/a911b141-5dc0-450a-aeac-30d9cf202b44">
## FAQ
1. **Will I get banned for using this?**

View File

@ -1,7 +1,7 @@
// ==UserScript==
// @name Better xCloud
// @namespace https://github.com/redphx
// @version 1.4
// @version 1.6
// @description Improve Xbox Cloud Gaming (xCloud) experience
// @author redphx
// @license MIT
@ -13,16 +13,21 @@
// ==/UserScript==
'use strict';
const SCRIPT_VERSION = '1.4';
const SCRIPT_VERSION = '1.6';
const SCRIPT_HOME = 'https://github.com/redphx/better-xcloud';
const SERVER_REGIONS = {};
var STREAM_WEBRTC;
var $STREAM_VIDEO;
var $SCREENSHOT_CANVAS;
var GAME_TITLE_ID;
class StreamStatus {
class StreamBadges {
static ipv6 = false;
static dimension = {width: 0, height: 0};
static hqCodec = false;
static resolution = null;
static video = null;
static audio = null;
static fps = 0;
static region = '';
static #renderBadge(name, value, color) {
@ -35,27 +40,145 @@ class StreamStatus {
}
static render() {
let video;
if (StreamBadges.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})`;
}
}
let audio;
if (StreamBadges.audio) {
audio = StreamBadges.audio.codec;
const bitrate = StreamBadges.audio.bitrate / 1000;
audio += ` (${bitrate} kHz)`;
}
const BADGES = [
['region', StreamStatus.region, '#d7450b'],
['server', StreamStatus.ipv6 ? 'IPv6' : 'IPv4', '#008746'],
['quality', StreamStatus.hqCodec ? 'High' : 'Normal', '#007c8f'],
['dimension', `${StreamStatus.dimension.width}x${StreamStatus.dimension.height}`, '#ff3977'],
['region', StreamBadges.region, '#d7450b'],
['server', StreamBadges.ipv6 ? 'IPv6' : 'IPv4', '#008746'],
video ? ['video', video, '#007c8f'] : null,
audio ? ['audio', audio, '#007c8f'] : null,
StreamBadges.resolution && ['resolution', `${StreamBadges.resolution.width}x${StreamBadges.resolution.height}`, '#ff3977'],
];
const $wrapper = createElement('div', {'class': 'better_xcloud_badges'});
BADGES.forEach(item => $wrapper.appendChild(StreamStatus.#renderBadge(...item)));
BADGES.forEach(item => item && $wrapper.appendChild(StreamBadges.#renderBadge(...item)));
return $wrapper;
}
}
class StreamStats {
static #timeout;
static #updateInterval = 1000;
static #$container;
static #$fps;
static #$rtt;
static #$pl;
static #$fl;
static #$br;
static #lastInbound;
static start() {
StreamStats.#$container.style.display = 'block';
StreamStats.update();
}
static stop() {
StreamStats.#$container.style.display = 'none';
clearTimeout(StreamStats.#timeout);
StreamStats.#timeout = null;
StreamStats.#lastInbound = null;
}
static toggle() {
StreamStats.#isHidden() ? StreamStats.start() : StreamStats.stop();
}
static #isHidden = () => StreamStats.#$container.style.display === 'none';
static update() {
if (StreamStats.#isHidden()) {
return;
}
if (!STREAM_WEBRTC) {
StreamStats.#timeout = setTimeout(StreamStats.update, StreamStats.#updateInterval);
return;
}
STREAM_WEBRTC.getStats().then(stats => {
stats.forEach(stat => {
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
// FPS
StreamStats.#$fps.textContent = stat.framesPerSecond || 0;
// Packets Loss
const packetsLost = stat.packetsLost;
const packetsReceived = stat.packetsReceived || 1;
StreamStats.#$pl.textContent = `${packetsLost} (${(packetsLost * 100 / packetsReceived).toFixed(2)}%)`;
// Frames Dropped
const framesDropped = stat.framesDropped;
const framesReceived = stat.framesReceived || 1;
StreamStats.#$fl.textContent = `${framesDropped} (${(framesDropped * 100 / framesReceived).toFixed(2)}%)`;
// Bitrate
if (StreamStats.#lastInbound) {
const timeDiff = stat.timestamp - StreamStats.#lastInbound.timestamp;
const bitrate = 8 * (stat.bytesReceived - StreamStats.#lastInbound.bytesReceived) / timeDiff / 1000;
StreamStats.#$br.textContent = `${bitrate.toFixed(2)} Mbps`;
}
StreamStats.#lastInbound = stat;
} else if (stat.type === 'candidate-pair' && stat.state === 'succeeded') {
// Round Trip Time
const roundTripTime = typeof stat.currentRoundTripTime !== 'undefined' ? stat.currentRoundTripTime * 1000 : '???';
StreamStats.#$rtt.textContent = `${roundTripTime}ms`;
}
});
StreamStats.#timeout = setTimeout(StreamStats.update, StreamStats.#updateInterval);
});
}
static render() {
if (StreamStats.#$container) {
return;
}
const CE = createElement;
StreamStats.#$container = CE('div', {'class': 'better_xcloud_stats_bar'},
CE('label', {}, 'FPS'),
StreamStats.#$fps = CE('span', {}, 0),
CE('label', {}, 'RTT'),
StreamStats.#$rtt = CE('span', {}, '0ms'),
CE('label', {}, 'BR'),
StreamStats.#$br = CE('span', {}, '0 Mbps'),
CE('label', {}, 'PL'),
StreamStats.#$pl = CE('span', {}, '0 (0.00%)'),
CE('label', {}, 'FL'),
StreamStats.#$fl = CE('span', {}, '0 (0.00%)'));
document.documentElement.appendChild(StreamStats.#$container);
}
}
class Preferences {
static get SERVER_REGION() { return 'server_region'; }
static get PREFER_IPV6_SERVER() { return 'prefer_ipv6_server'; }
static get FORCE_1080P_STREAM() { return 'force_1080p_stream'; }
static get USE_DESKTOP_CODEC() { return 'use_desktop_codec'; }
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'; }
@ -73,88 +196,71 @@ class Preferences {
'id': Preferences.SERVER_REGION,
'label': 'Region of streaming server',
'default': 'default',
},
{
}, {
'id': Preferences.FORCE_1080P_STREAM,
'label': 'Force 1080p stream',
'default': false,
},
{
}, {
'id': Preferences.USE_DESKTOP_CODEC,
'label': 'Force high quality codec',
'label': 'Force high quality codec (if possible)',
'default': false,
},
{
}, {
'id': Preferences.PREFER_IPV6_SERVER,
'label': 'Prefer IPv6 streaming server',
'default': false,
},
{
}, {
'id': Preferences.DISABLE_BANDWIDTH_CHECKING,
'label': 'Disable bandwidth checking',
'default': false,
},
{
}, {
'id': Preferences.SCREENSHOT_BUTTON_POSITION,
'label': 'Screenshot button\'s position',
'default': 'bottom-left',
'options': {
'bottom-left': 'Bottom Left',
'bottom-right': 'Bottom Right',
'none': 'Disable',
},
}, {
'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',
'default': false,
},
{
}, {
'id': Preferences.BLOCK_TRACKING,
'label': 'Disable xCloud analytics',
'default': false,
},
{
}, {
'id': Preferences.VIDEO_FILL_FULL_SCREEN,
'label': 'Stretch video to full screen',
'default': false,
'hidden': true,
},
{
}, {
'id': Preferences.VIDEO_SATURATION,
'label': 'Video saturation (%)',
'default': 100,
'min': 0,
'max': 150,
'hidden': true,
},
{
}, {
'id': Preferences.VIDEO_CONTRAST,
'label': 'Video contrast (%)',
'default': 100,
'min': 0,
'max': 150,
'hidden': true,
},
{
}, {
'id': Preferences.VIDEO_BRIGHTNESS,
'label': 'Video brightness (%)',
'default': 100,
@ -333,7 +439,7 @@ div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] {
.better_xcloud_badges {
position: absolute;
bottom: -35px;
top: 155px;
margin-left: 0px;
user-select: none;
}
@ -345,7 +451,9 @@ div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] {
color: #fff;
font-family: Bahnschrift Semibold, Arial, Helvetica, sans-serif;
font-weight: 400;
margin-right: 8px;
margin: 0 8px 8px 0;
box-shadow: 0px 0px 6px #000;
border-radius: 4px;
}
.better_xcloud_badge .better_xcloud_badge_name {
@ -363,6 +471,67 @@ div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] {
border-radius: 0 4px 4px 0;
}
.better_xcloud_screenshot_button {
display: none;
opacity: 0;
position: fixed;
bottom: 0;
width: 60px;
height: 60px;
padding: 5px;
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://www.iconfinder.com/iconsets/user-interface-outline-27 */
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHdpZHRoPSIyNCIgeG1sbnM6dj0iaHR0cHM6Ly92ZWN0YS5pby9uYW5vIiBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMTIgN2E1LjAyIDUuMDIgMCAwIDAtNSA1IDUuMDIgNS4wMiAwIDAgMCA1IDUgNS4wMiA1LjAyIDAgMCAwIDUtNSA1LjAyIDUuMDIgMCAwIDAtNS01em0wIDJjMS42NjkgMCAzIDEuMzMxIDMgM3MtMS4zMzEgMy0zIDMtMy0xLjMzMS0zLTMgMS4zMzEtMyAzLTN6TTYgMkMzLjgwMSAyIDIgMy44MDEgMiA2djJhMSAxIDAgMSAwIDIgMFY2YTEuOTcgMS45NyAwIDAgMSAyLTJoMmExIDEgMCAxIDAgMC0yek0zIDE1YTEgMSAwIDAgMC0xIDF2MmMwIDIuMTk5IDEuODAxIDQgNCA0aDJhMSAxIDAgMSAwIDAtMkg2YTEuOTcgMS45NyAwIDAgMS0yLTJ2LTJhMSAxIDAgMCAwLTEtMXptMTggMGExIDEgMCAwIDAtMSAxdjJhMS45NyAxLjk3IDAgMCAxLTIgMmgtMmExIDEgMCAxIDAgMCAyaDJjMi4xOTkgMCA0LTEuODAxIDQtNHYtMmExIDEgMCAwIDAtMS0xeiIvPjxwYXRoIGQ9Ik0xNiAyYTEgMSAwIDEgMCAwIDJoMmExLjk3IDEuOTcgMCAwIDEgMiAydjJhMSAxIDAgMSAwIDIgMFY2YzAtMi4xOTktMS44MDEtNC00LTR6Ii8+PC9zdmc+Cg==);
}
.better_xcloud_screenshot_button[data-showing=true] {
opacity: 1;
}
.better_xcloud_screenshot_button[data-capturing=true] {
padding: 0px;
}
.better_xcloud_screenshot_canvas {
display: none;
}
.better_xcloud_stats_bar {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
opacity: 0.8;
background-color: #000;
color: #fff;
font-family: Consolas, "Courier New", Courier, monospace;
font-size: 0.9rem;
padding-left: 8px;
z-index: 1000;
}
.better_xcloud_stats_bar label {
font-weight: bold;
margin: 0 8px 0 0;
font-size: 0.9rem;
}
.better_xcloud_stats_bar span {
min-width: 60px;
display: inline-block;
text-align: right;
padding-right: 8px;
margin-right: 8px;
border-right: 2px solid #fff;
}
/* Hide UI elements */
#headerArea, #uhfSkipToMain, .uhf-footer {
display: none;
@ -379,6 +548,12 @@ div[class*=NotFocusedDialog] {
#game-stream video {
visibility: hidden;
}
/* Adjust Stream menu icon's size */
button[class*=MenuItem-module__container] {
min-width: auto !important;
width: 110px !important;
}
`;
// Reduce animations
@ -524,7 +699,6 @@ function interceptHttpRequests() {
const PREF_PREFER_IPV6_SERVER = PREFS.get(Preferences.PREFER_IPV6_SERVER);
const PREF_FORCE_1080P_STREAM = PREFS.get(Preferences.FORCE_1080P_STREAM);
const PREF_USE_DESKTOP_CODEC = PREFS.get(Preferences.USE_DESKTOP_CODEC);
const HAS_CODECS_API_SUPPORT = hasRtcSetCodecPreferencesSupport();
const orgFetch = window.fetch;
window.fetch = async (...arg) => {
@ -569,11 +743,11 @@ function interceptHttpRequests() {
if (url.endsWith('/sessions/cloud/play')) {
const parsedUrl = new URL(url);
StreamStatus.region = parsedUrl.host.split('.', 1)[0];
StreamBadges.region = parsedUrl.host.split('.', 1)[0];
for (let regionName in SERVER_REGIONS) {
const region = SERVER_REGIONS[regionName];
if (parsedUrl.origin == region.baseUri) {
StreamStatus.region = regionName;
StreamBadges.region = regionName;
break;
}
}
@ -595,27 +769,6 @@ function interceptHttpRequests() {
return orgFetch(...arg);
}
// Work-around for browsers with no setCodecPreferences() support
if (PREF_USE_DESKTOP_CODEC && !HAS_CODECS_API_SUPPORT && url.endsWith('/sdp') && 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);
obj.exchangeResponse = obj.exchangeResponse.replaceAll('profile-level-id=42', 'profile-level-id=4d');
response.json = () => Promise.resolve(obj);
response.text = () => Promise.resolve(JSON.stringify(obj));
return response;
});
});
}
// ICE server candidates
if (PREF_PREFER_IPV6_SERVER && url.endsWith('/ice') && url.includes('/sessions/cloud/') && request.method === 'GET') {
const promise = orgFetch(...arg);
@ -673,7 +826,7 @@ function createElement(elmName, props = {}) {
if (argType == 'string' || argType == 'number') {
$elm.innerText = arg;
} else {
} else if (arg) {
$elm.appendChild(arg);
}
}
@ -720,27 +873,41 @@ function injectSettingsButton($parent) {
let $control;
let labelAttrs = {};
if (setting.id === Preferences.SERVER_REGION) {
if (setting.id === Preferences.SERVER_REGION || setting.options) {
let selectedValue;
$control = CE('select', {id: 'xcloud_setting_' + setting.id});
$control.addEventListener('change', e => {
PREFS.set(Preferences.SERVER_REGION, e.target.value);
PREFS.set(setting.id, e.target.value);
});
for (let regionName in SERVER_REGIONS) {
const region = SERVER_REGIONS[regionName];
let value = regionName;
if (setting.id === Preferences.SERVER_REGION) {
selectedValue = preferredRegion;
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';
let label = regionName;
if (region.isDefault) {
label += ' (Default)';
value = 'default';
}
setting.options[value] = label;
}
} else {
selectedValue = PREFS.get(setting.id);
}
for (let value in setting.options) {
const label = setting.options[value];
const $option = CE('option', {value: value}, label);
$option.selected = regionName === preferredRegion;
$option.selected = value === selectedValue || label.includes(selectedValue);
$control.appendChild($option);
}
} else {
$control = CE('input', {
id: 'xcloud_setting_' + setting.id,
@ -750,16 +917,19 @@ function injectSettingsButton($parent) {
$control.addEventListener('change', e => {
PREFS.set(e.target.getAttribute('data-key'), e.target.checked);
if (setting.id == Preferences.VIDEO_FILL_FULL_SCREEN) {
updateVideoPlayerPreview();
}
});
setting.value = PREFS.get(setting.id);
$control.checked = setting.value;
labelAttrs = {'for': 'xcloud_setting_' + setting.id, 'tabindex': 0};
if (setting.id === Preferences.USE_DESKTOP_CODEC && !hasRtcSetCodecPreferencesSupport()) {
$control.checked = false;
$control.disabled = true;
$control.title = 'Your browser doesn\'t support this feature';
$control.style.cursor = 'help';
}
}
const $elm = CE('div', {'class': 'setting_row'},
@ -825,24 +995,6 @@ function updateVideoPlayerCss() {
}
function updateVideoPlayerPreview() {
const $screen = document.querySelector('.better_xcloud_settings_preview_screen');
$screen.style.display = 'block';
const filters = getVideoPlayerFilterStyle();
const $video = document.querySelector('.better_xcloud_settings_preview_video');
$video.style.filter = filters;
if (PREFS.get(Preferences.VIDEO_FILL_FULL_SCREEN)) {
$video.style.height = 'auto';
} else {
$video.style.height = '100%';
}
updateVideoPlayerCss();
}
function checkHeader() {
const $button = document.querySelector('#PageContent header .better_xcloud_settings_button');
@ -873,6 +1025,19 @@ function watchHeader() {
}
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 24 24');
return $button;
}
function injectVideoSettingsButton() {
const $screen = document.querySelector('#PageContent section[class*=PureScreens]');
if (!$screen) {
@ -914,29 +1079,16 @@ function injectVideoSettingsButton() {
return;
}
const id = 'better-xcloud-video-settings-btn';
let $wrapper = document.getElementById('#' + id);
if ($wrapper) {
return;
}
const $orgButton = node.querySelector('div > div > button');
if (!$orgButton) {
return;
}
// Clone other button
const $button = $orgButton.cloneNode(true);
$button.setAttribute('aria-label', 'Video settings');
$button.querySelector('div[class*=label]').textContent = 'Video settings';
// Credit: https://www.iconfinder.com/iconsets/user-interface-outline-27
const SVG_ICON = '<path d="M8 2c-1.293 0-2.395.843-2.812 2H3a1 1 0 1 0 0 2h2.186C5.602 7.158 6.706 8 8 8s2.395-.843 2.813-2h10.188a1 1 0 1 0 0-2H10.813C10.395 2.843 9.293 2 8 2zm0 2c.564 0 1 .436 1 1s-.436 1-1 1-1-.436-1-1 .436-1 1-1zm7 5c-1.293 0-2.395.843-2.812 2H3a1 1 0 1 0 0 2h9.186c.417 1.158 1.521 2 2.814 2s2.395-.843 2.813-2H21a1 1 0 1 0 0-2h-3.187c-.418-1.157-1.52-2-2.813-2zm0 2c.564 0 1 .436 1 1s-.436 1-1 1-1-.436-1-1 .436-1 1-1zm-7 5c-1.293 0-2.395.843-2.812 2H3a1 1 0 1 0 0 2h2.188c.417 1.157 1.519 2 2.813 2s2.398-.842 2.814-2H21a1 1 0 1 0 0-2H10.812c-.417-1.157-1.519-2-2.812-2zm0 2c.564 0 1 .436 1 1s-.436 1-1 1-1-.436-1-1 .436-1 1-1z"/>';
const $svg = $button.querySelector('svg');
$svg.innerHTML = SVG_ICON;
$svg.setAttribute('viewBox', '0 0 24 24');
$button.addEventListener('click', e => {
const ICON_VIDEO_SETTINGS = '<path d="M8 2c-1.293 0-2.395.843-2.812 2H3a1 1 0 1 0 0 2h2.186C5.602 7.158 6.706 8 8 8s2.395-.843 2.813-2h10.188a1 1 0 1 0 0-2H10.813C10.395 2.843 9.293 2 8 2zm0 2c.564 0 1 .436 1 1s-.436 1-1 1-1-.436-1-1 .436-1 1-1zm7 5c-1.293 0-2.395.843-2.812 2H3a1 1 0 1 0 0 2h9.186c.417 1.158 1.521 2 2.814 2s2.395-.843 2.813-2H21a1 1 0 1 0 0-2h-3.187c-.418-1.157-1.52-2-2.813-2zm0 2c.564 0 1 .436 1 1s-.436 1-1 1-1-.436-1-1 .436-1 1-1zm-7 5c-1.293 0-2.395.843-2.812 2H3a1 1 0 1 0 0 2h2.188c.417 1.157 1.519 2 2.813 2s2.398-.842 2.814-2H21a1 1 0 1 0 0-2H10.812c-.417-1.157-1.519-2-2.812-2zm0 2c.564 0 1 .436 1 1s-.436 1-1 1-1-.436-1-1 .436-1 1-1z"/>';
// Create Video Settings button
const $btnVideoSettings = cloneStreamMenuButton($orgButton, 'Video settings', ICON_VIDEO_SETTINGS);
$btnVideoSettings.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
@ -947,23 +1099,38 @@ function injectVideoSettingsButton() {
$parent.addEventListener('touchend', hideQuickBarFunc);
const $touchSurface = document.querySelector('#MultiTouchSurface');
if ($touchSurface) {
$touchSurface.addEventListener('touchstart', hideQuickBarFunc);
}
$touchSurface && $touchSurface.addEventListener('touchstart', hideQuickBarFunc);
});
$orgButton.parentElement.insertBefore($button, $orgButton.parentElement.firstChild);
// Add button at the beginning
$orgButton.parentElement.insertBefore($btnVideoSettings, $orgButton.parentElement.firstChild);
// Hide Quick bar when closing HUD
document.querySelector('button[class*=StreamMenu-module__backButton]').addEventListener('click', e => {
const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]');
$btnCloseHud.addEventListener('click', e => {
$quickBar.style.display = 'none';
});
const ICON_STREAM_STATS = '<path d="M12.005 5C9.184 5 6.749 6.416 5.009 7.903c-.87.743-1.571 1.51-2.074 2.18-.251.335-.452.644-.605.934-.434.733-.389 1.314-.004 1.98a6.98 6.98 0 0 0 .609.949 13.62 13.62 0 0 0 2.076 2.182C6.753 17.606 9.188 19 12.005 19s5.252-1.394 6.994-2.873a13.62 13.62 0 0 0 2.076-2.182 6.98 6.98 0 0 0 .609-.949c.425-.737.364-1.343-.004-1.98-.154-.29-.354-.599-.605-.934-.503-.669-1.204-1.436-2.074-2.18C17.261 6.416 14.826 5 12.005 5zm0 2c2.135 0 4.189 1.135 5.697 2.424.754.644 1.368 1.32 1.773 1.859.203.27.354.509.351.733s-.151.462-.353.732c-.404.541-1.016 1.214-1.77 1.854C16.198 15.881 14.145 17 12.005 17s-4.193-1.12-5.699-2.398a11.8 11.8 0 0 1-1.77-1.854c-.202-.27-.351-.508-.353-.732s.149-.463.351-.733c.406-.54 1.019-1.215 1.773-1.859C7.816 8.135 9.87 7 12.005 7zm.025 1.975c-1.645 0-3 1.355-3 3s1.355 3 3 3 3-1.355 3-3-1.355-3-3-3zm0 2c.564 0 1 .436 1 1s-.436 1-1 1-1-.436-1-1 .436-1 1-1z"/>';
// 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();
});
// Insert after Video Settings button
$orgButton.parentElement.insertBefore($btnStreamStats, $btnVideoSettings.nextSibling);
// Render stream badges
const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]');
$menu.appendChild(StreamStatus.render());
$menu.appendChild(StreamBadges.render());
});
});
});
observer.observe($screen, {subtree: true, childList: true});
@ -972,6 +1139,7 @@ function injectVideoSettingsButton() {
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;
@ -979,9 +1147,54 @@ function patchVideoApi() {
this.style.visibility = 'visible';
this.removeEventListener('playing', showFunc);
if (this.videoWidth) {
StreamStatus.dimension = {width: this.videoWidth, height: this.videoHeight};
if (!this.videoWidth) {
return;
}
$STREAM_VIDEO = this;
$SCREENSHOT_CANVAS.width = this.videoWidth;
$SCREENSHOT_CANVAS.height = this.videoHeight;
StreamBadges.resolution = {width: this.videoWidth, height: this.videoHeight};
const stats = STREAM_WEBRTC.getStats().then(stats => {
stats.forEach(stat => {
if (stat.type !== 'codec') {
return;
}
const mimeType = stat.mimeType.split('/');
if (mimeType[0] === 'video') {
const video = {
codec: mimeType[1],
};
if (video.codec === 'H264') {
const match = /profile-level-id=([0-9a-f]{6})/.exec(stat.sdpFmtpLine);
video.profile = match ? match[1] : null;
}
StreamBadges.video = video;
} else if (!StreamBadges.audio && mimeType[0] === 'audio') {
StreamBadges.audio = {
codec: mimeType[1],
bitrate: stat.clockRate,
};
}
});
});
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';
}
}
GAME_TITLE_ID = /\/launch\/([^/]+)/.exec(window.location.pathname)[1];
}
HTMLMediaElement.prototype.orgPlay = HTMLMediaElement.prototype.play;
@ -1021,19 +1234,24 @@ function patchRtcCodecs() {
RTCRtpTransceiver.prototype.orgSetCodecPreferences = RTCRtpTransceiver.prototype.setCodecPreferences;
RTCRtpTransceiver.prototype.setCodecPreferences = function(codecs) {
// Use the same codecs as desktop
codecs = [
{
'clockRate': 90000,
'mimeType': 'video/H264',
'sdpFmtpLine': 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f',
},
{
'clockRate': 90000,
'mimeType': 'video/H264',
'sdpFmtpLine': 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f',
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;
}
].concat(codecs);
this.orgSetCodecPreferences(codecs);
});
try {
this.orgSetCodecPreferences.apply(this, [newCodecs]);
} catch (e) {
console.log(e);
this.orgSetCodecPreferences.apply(this, [codecs]);
}
}
}
@ -1209,6 +1427,75 @@ function setupVideoSettingsBar() {
}
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) {
@ -1231,6 +1518,11 @@ function hideUiOnPageChange() {
if ($quickBar) {
$quickBar.style.display = 'none';
}
STREAM_WEBRTC = null;
$STREAM_VIDEO = null;
StreamStats.stop();
document.querySelector('.better_xcloud_screenshot_button').style = '';
}
@ -1252,15 +1544,15 @@ if (PREFS.get(Preferences.DISABLE_BANDWIDTH_CHECKING)) {
}
patchRtcCodecs();
interceptHttpRequests();
patchVideoApi();
// Setup UI
addCss();
updateVideoPlayerCss();
setupVideoSettingsBar();
setupScreenshotButton();
StreamStats.render();
// Workaround for Hermit browser
var onLoadTriggered = false;
@ -1272,23 +1564,13 @@ if (document.readyState === 'complete' && !onLoadTriggered) {
watchHeader();
}
RTCPeerConnection.prototype.orgSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription;
RTCPeerConnection.prototype.setRemoteDescription = function(...args) {
const sdpDesc = args[0];
if (sdpDesc.sdp) {
StreamStatus.hqCodec = sdpDesc.sdp.includes('profile-level-id=4d');
}
return this.orgSetRemoteDescription.apply(this, args);
}
RTCPeerConnection.prototype.orgAddIceCandidate = RTCPeerConnection.prototype.addIceCandidate;
RTCPeerConnection.prototype.addIceCandidate = function(...args) {
const candidate = args[0].candidate;
if (candidate && candidate.startsWith('a=candidate:1 ')) {
StreamStatus.ipv6 = candidate.substring(20).includes(':');
StreamBadges.ipv6 = candidate.substring(20).includes(':');
}
STREAM_WEBRTC = this;
return this.orgAddIceCandidate.apply(this, args);
}