From b564de249abb47fbee2b8350f58176d5277aed49 Mon Sep 17 00:00:00 2001
From: redphx <96280+redphx@users.noreply.github.com>
Date: Sun, 23 Jul 2023 17:09:30 +0700
Subject: [PATCH] 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
---
better-xcloud.user.js | 400 ++++++++++++++++++++++++++++--------------
1 file changed, 269 insertions(+), 131 deletions(-)
diff --git a/better-xcloud.user.js b/better-xcloud.user.js
index 06a4b32..4a63654 100644
--- a/better-xcloud.user.js
+++ b/better-xcloud.user.js
@@ -17,14 +17,17 @@ const SCRIPT_VERSION = '1.5';
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 resolution = {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) {
@@ -37,21 +40,138 @@ 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'],
- ['resolution', `${StreamStatus.resolution.width}x${StreamStatus.resolution.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'; }
@@ -319,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;
}
@@ -331,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 {
@@ -380,6 +502,36 @@ div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module] {
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;
@@ -396,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
@@ -541,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) => {
@@ -586,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;
}
}
@@ -612,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);
@@ -690,7 +826,7 @@ function createElement(elmName, props = {}) {
if (argType == 'string' || argType == 'number') {
$elm.innerText = arg;
- } else {
+ } else if (arg) {
$elm.appendChild(arg);
}
}
@@ -781,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'},
@@ -856,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');
@@ -904,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) {
@@ -945,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 = '';
- const $svg = $button.querySelector('svg');
- $svg.innerHTML = SVG_ICON;
- $svg.setAttribute('viewBox', '0 0 24 24');
-
- $button.addEventListener('click', e => {
+ const ICON_VIDEO_SETTINGS = '';
+ // Create Video Settings button
+ const $btnVideoSettings = cloneStreamMenuButton($orgButton, 'Video settings', ICON_VIDEO_SETTINGS);
+ $btnVideoSettings.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
@@ -978,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 = '';
+ // 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});
@@ -1011,25 +1147,54 @@ function patchVideoApi() {
this.style.visibility = 'visible';
this.removeEventListener('playing', showFunc);
- if (this.videoWidth) {
- $STREAM_VIDEO = this;
- $SCREENSHOT_CANVAS.width = this.videoWidth;
- $SCREENSHOT_CANVAS.height = this.videoHeight;
- StreamStatus.resolution = {width: this.videoWidth, height: this.videoHeight};
-
- 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];
+ 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;
@@ -1354,7 +1519,9 @@ function hideUiOnPageChange() {
$quickBar.style.display = 'none';
}
+ STREAM_WEBRTC = null;
$STREAM_VIDEO = null;
+ StreamStats.stop();
document.querySelector('.better_xcloud_screenshot_button').style = '';
}
@@ -1385,6 +1552,7 @@ addCss();
updateVideoPlayerCss();
setupVideoSettingsBar();
setupScreenshotButton();
+StreamStats.render();
// Workaround for Hermit browser
var onLoadTriggered = false;
@@ -1396,43 +1564,13 @@ if (document.readyState === 'complete' && !onLoadTriggered) {
watchHeader();
}
-
-RTCPeerConnection.prototype.orgSetRemoteDescription = RTCPeerConnection.prototype.setRemoteDescription;
-RTCPeerConnection.prototype.setRemoteDescription = function(...args) {
- StreamStatus.hqCodec = false;
-
- const sdpDesc = args[0];
- if (sdpDesc.sdp) {
- const sdp = sdpDesc.sdp;
-
- let lineIndex = 0;
- let endPos = 0;
- let line;
- while (lineIndex > -1) {
- lineIndex = sdp.indexOf('a=fmtp:', endPos);
- if (lineIndex === -1) {
- break;
- }
-
- endPos = sdp.indexOf('\n', lineIndex);
- line = sdp.substring(lineIndex, endPos);
- if (line.includes('profile-level-id')) {
- StreamStatus.hqCodec = line.includes('profile-level-id=4d');
- break;
- }
- }
- }
-
- 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);
}