New stats: clock, play time, battery, download, upload

This commit is contained in:
redphx 2024-10-06 15:50:39 +07:00
parent af41dc7c5e
commit 76b205a65a
12 changed files with 1547 additions and 869 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -71,7 +71,9 @@ div[class^=StreamMenu-module__container] .bx-badges {
/* STATS BAR */
.bx-stats-bar {
display: block;
display: flex;
flex-direction: row;
gap: 8px;
user-select: none;
-webkit-user-select: none;
position: fixed;
@ -84,22 +86,31 @@ div[class^=StreamMenu-module__container] .bx-badges {
z-index: var(--bx-stats-bar-z-index);
text-wrap: nowrap;
&[data-stats*="[time]"] > .bx-stat-time,
&[data-stats*="[play]"] > .bx-stat-play,
&[data-stats*="[batt]"] > .bx-stat-batt,
&[data-stats*="[fps]"] > .bx-stat-fps,
&[data-stats*="[ping]"] > .bx-stat-ping,
&[data-stats*="[btr]"] > .bx-stat-btr,
&[data-stats*="[dt]"] > .bx-stat-dt,
&[data-stats*="[pl]"] > .bx-stat-pl,
&[data-stats*="[fl]"] > .bx-stat-fl {
&[data-stats*="[fl]"] > .bx-stat-fl,
&[data-stats*="[dl]"] > .bx-stat-dl,
&[data-stats*="[ul]"] > .bx-stat-ul {
display: inline-block;
}
&[data-stats$="[time]"] > .bx-stat-time,
&[data-stats$="[play]"] > .bx-stat-play,
&[data-stats$="[batt]"] > .bx-stat-batt,
&[data-stats$="[fps]"] > .bx-stat-fps,
&[data-stats$="[ping]"] > .bx-stat-ping,
&[data-stats$="[btr]"] > .bx-stat-btr,
&[data-stats$="[dt]"] > .bx-stat-dt,
&[data-stats$="[pl]"] > .bx-stat-pl,
&[data-stats$="[fl]"] > .bx-stat-fl {
margin-right: 0;
&[data-stats$="[fl]"] > .bx-stat-fl,
&[data-stats$="[dl]"] > .bx-stat-dl,
&[data-stats$="[ul]"] > .bx-stat-ul {
border-right: none;
}
@ -137,7 +148,6 @@ div[class^=StreamMenu-module__container] .bx-badges {
> div {
display: none;
margin-right: 8px;
border-right: 1px solid #fff;
padding-right: 8px;
}
@ -145,7 +155,7 @@ div[class^=StreamMenu-module__container] .bx-badges {
label {
margin: 0 8px 0 0;
font-family: var(--bx-title-font);
font-size: inherit;
font-size: 70%;
font-weight: bold;
vertical-align: middle;
cursor: help;

View File

@ -41,6 +41,7 @@ import { SettingsNavigationDialog } from "./modules/ui/dialog/settings-dialog";
import { StreamUiHandler } from "./modules/stream/stream-ui";
import { UserAgent } from "./utils/user-agent";
import { XboxApi } from "./utils/xbox-api";
import { StreamStatsCollector } from "./utils/stream-stats-collector";
// Handle login page
if (window.location.pathname.includes('/auth/msa')) {
@ -399,6 +400,7 @@ function main() {
Toast.setup();
GuideMenu.addEventListeners();
StreamStatsCollector.setupEvents();
StreamBadges.setupEvents();
StreamStats.setupEvents();

View File

@ -2,32 +2,51 @@ import { isLiteVersion } from "@macros/build" with {type: "macro"};
import { t } from "@utils/translation";
import { BxEvent } from "@utils/bx-event";
import { CE, createSvgIcon } from "@utils/html";
import { CE, createSvgIcon, humanFileSize } from "@utils/html";
import { STATES } from "@utils/global";
import { BxLogger } from "@/utils/bx-logger";
import { BxIcon } from "@/utils/bx-icon";
import { GuideMenuTab } from "../ui/guide-menu";
import { StreamStat, StreamStatsCollector } from "@/utils/stream-stats-collector";
type StreamBadgeInfo = {
name: string,
$element?: HTMLElement,
icon: typeof BxIcon,
color: string,
};
type StreamServerInfo = {
server?: {
ipv6: boolean,
region?: string,
},
video?: {
width: number,
height: number,
codec: string,
profile?: string,
},
audio?: {
codec: string,
bitrate: number,
},
};
enum StreamBadge {
PLAYTIME = 'playtime',
BATTERY = 'battery',
DOWNLOAD = 'in',
UPLOAD = 'out',
DOWNLOAD = 'download',
UPLOAD = 'upload',
SERVER = 'server',
VIDEO = 'video',
AUDIO = 'audio',
}
const StreamBadgeIcon: Partial<{[key in StreamBadge]: any}> = {
[StreamBadge.PLAYTIME]: BxIcon.PLAYTIME,
[StreamBadge.VIDEO]: BxIcon.DISPLAY,
[StreamBadge.BATTERY]: BxIcon.BATTERY,
[StreamBadge.DOWNLOAD]: BxIcon.DOWNLOAD,
[StreamBadge.UPLOAD]: BxIcon.UPLOAD,
[StreamBadge.SERVER]: BxIcon.SERVER,
[StreamBadge.AUDIO]: BxIcon.AUDIO,
}
export class StreamBadges {
private static instance: StreamBadges;
@ -39,91 +58,100 @@ export class StreamBadges {
return StreamBadges.instance;
}
#ipv6 = false;
#resolution?: {width: number, height: number} | null = null;
#video?: {codec: string, profile?: string | null} | null = null;
#audio?: {codec: string, bitrate: number} | null = null;
#region = '';
private serverInfo: StreamServerInfo = {};
startBatteryLevel = 100;
startTimestamp = 0;
private badges: Record<StreamBadge, StreamBadgeInfo> = {
[StreamBadge.PLAYTIME]: {
name: t('playtime'),
icon: BxIcon.PLAYTIME,
color: '#ff004d',
},
[StreamBadge.BATTERY]: {
name: t('battery'),
icon: BxIcon.BATTERY,
color: '#00b543',
},
[StreamBadge.DOWNLOAD]: {
name: t('download'),
icon: BxIcon.DOWNLOAD,
color: '#29adff',
},
[StreamBadge.UPLOAD]: {
name: t('upload'),
icon: BxIcon.UPLOAD,
color: '#ff77a8',
},
[StreamBadge.SERVER]: {
name: t('server'),
icon: BxIcon.SERVER,
color: '#ff6c24',
},
[StreamBadge.VIDEO]: {
name: t('video'),
icon: BxIcon.DISPLAY,
color: '#742f29',
},
[StreamBadge.AUDIO]: {
name: t('audio'),
icon: BxIcon.AUDIO,
color: '#5f574f',
},
};
#$container: HTMLElement | undefined;
#cachedDoms: Partial<{[key in StreamBadge]: HTMLElement}> = {};
private $container: HTMLElement | undefined;
#interval?: number | null;
readonly #REFRESH_INTERVAL = 3000;
private intervalId?: number | null;
private readonly REFRESH_INTERVAL = 3 * 1000;
setRegion(region: string) {
this.#region = region;
this.serverInfo.server = {
region: region,
ipv6: false,
};
}
#renderBadge(name: StreamBadge, value: string, color: string) {
renderBadge(name: StreamBadge, value: string) {
const badgeInfo = this.badges[name];
let $badge;
if (this.#cachedDoms[name]) {
$badge = this.#cachedDoms[name]!;
if (badgeInfo.$element) {
$badge = badgeInfo.$element;
$badge.lastElementChild!.textContent = value;
return $badge;
}
$badge = CE('div', {'class': 'bx-badge', 'title': t(`badge-${name}`)},
CE('span', {'class': 'bx-badge-name'}, createSvgIcon(StreamBadgeIcon[name])),
CE('span', {'class': 'bx-badge-value', 'style': `background-color: ${color}`}, value),
$badge = CE('div', {class: 'bx-badge', title: badgeInfo.name},
CE('span', {class: 'bx-badge-name'}, createSvgIcon(badgeInfo.icon)),
CE('span', {class: 'bx-badge-value', style: `background-color: ${badgeInfo.color}`}, value),
);
if (name === StreamBadge.BATTERY) {
$badge.classList.add('bx-badge-battery');
}
this.#cachedDoms[name] = $badge;
this.badges[name].$element = $badge;
return $badge;
}
async #updateBadges(forceUpdate = false) {
if (!this.#$container || (!forceUpdate && !this.#$container.isConnected)) {
this.#stop();
private async updateBadges(forceUpdate = false) {
if (!this.$container || (!forceUpdate && !this.$container.isConnected)) {
this.stop();
return;
}
// Playtime
let now = +new Date;
const diffSeconds = Math.ceil((now - this.startTimestamp) / 1000);
const playtime = this.#secondsToHm(diffSeconds);
const statsCollector = StreamStatsCollector.getInstance();
await statsCollector.collect();
// Battery
let batteryLevel = '100%';
let batteryLevelInt = 100;
let isCharging = false;
if (STATES.browser.capabilities.batteryApi) {
try {
const bm = await (navigator as NavigatorBattery).getBattery();
isCharging = bm.charging;
batteryLevelInt = Math.round(bm.level * 100);
batteryLevel = `${batteryLevelInt}%`;
if (batteryLevelInt != this.startBatteryLevel) {
const diffLevel = Math.round(batteryLevelInt - this.startBatteryLevel);
const sign = diffLevel > 0 ? '+' : '';
batteryLevel += ` (${sign}${diffLevel}%)`;
}
} catch(e) {}
}
const stats = await STATES.currentStream.peerConnection?.getStats()!;
let totalIn = 0;
let totalOut = 0;
stats.forEach(stat => {
if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
totalIn += stat.bytesReceived;
totalOut += stat.bytesSent;
}
});
const play = statsCollector.getStat(StreamStat.PLAYTIME);
const batt = statsCollector.getStat(StreamStat.BATTERY);
const dl = statsCollector.getStat(StreamStat.DOWNLOAD);
const ul = statsCollector.getStat(StreamStat.UPLOAD);
const badges = {
[StreamBadge.DOWNLOAD]: totalIn ? this.#humanFileSize(totalIn) : null,
[StreamBadge.UPLOAD]: totalOut ? this.#humanFileSize(totalOut) : null,
[StreamBadge.PLAYTIME]: playtime,
[StreamBadge.BATTERY]: batteryLevel,
[StreamBadge.DOWNLOAD]: dl.toString(),
[StreamBadge.UPLOAD]: ul.toString(),
[StreamBadge.PLAYTIME]: play.toString(),
[StreamBadge.BATTERY]: batt.toString(),
};
let name: keyof typeof badges;
@ -133,97 +161,44 @@ export class StreamBadges {
continue;
}
const $elm = this.#cachedDoms[name]!;
$elm && ($elm.lastElementChild!.textContent = value);
const $elm = this.badges[name].$element;
if (!$elm) {
continue;
}
$elm.lastElementChild!.textContent = value;
if (name === StreamBadge.BATTERY) {
if (this.startBatteryLevel === 100 && batteryLevelInt === 100) {
if (batt.current === 100 && batt.start === 100) {
// Hide battery badge when the battery is 100%
$elm.classList.add('bx-gone');
} else {
// Show charging status
$elm.dataset.charging = isCharging.toString()
$elm.dataset.charging = batt.isCharging.toString();
$elm.classList.remove('bx-gone');
}
}
}
}
async #start() {
await this.#updateBadges(true);
this.#stop();
this.#interval = window.setInterval(this.#updateBadges.bind(this), this.#REFRESH_INTERVAL);
private async start() {
await this.updateBadges(true);
this.stop();
this.intervalId = window.setInterval(this.updateBadges.bind(this), this.REFRESH_INTERVAL);
}
#stop() {
this.#interval && clearInterval(this.#interval);
this.#interval = null;
}
#secondsToHm(seconds: number) {
let h = Math.floor(seconds / 3600);
let m = Math.floor(seconds % 3600 / 60) + 1;
if (m === 60) {
h += 1;
m = 0;
}
const output = [];
h > 0 && output.push(`${h}h`);
m > 0 && output.push(`${m}m`);
return output.join(' ');
}
// https://stackoverflow.com/a/20732091
#humanFileSize(size: number) {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
private stop() {
this.intervalId && clearInterval(this.intervalId);
this.intervalId = null;
}
async render() {
if (this.#$container) {
this.#start();
return this.#$container;
if (this.$container) {
this.start();
return this.$container;
}
await this.#getServerStats();
// Video
let video = '';
if (this.#resolution) {
video = `${this.#resolution.height}p`;
}
if (this.#video) {
video && (video += '/');
video += this.#video.codec;
if (this.#video.profile) {
const profile = this.#video.profile;
let quality = profile;
if (profile.startsWith('4d')) {
quality = t('visual-quality-high');
} else if (profile.startsWith('42e')) {
quality = t('visual-quality-normal');
} else if (profile.startsWith('420')) {
quality = t('visual-quality-low');
}
video += ` (${quality})`;
}
}
// Audio
let audio;
if (this.#audio) {
audio = this.#audio.codec;
const bitrate = this.#audio.bitrate / 1000;
audio += ` (${bitrate} kHz)`;
}
await this.getServerStats();
// Battery
let batteryLevel = '';
@ -231,46 +206,50 @@ export class StreamBadges {
batteryLevel = '100%';
}
// Server + Region
let server = this.#region;
server += '@' + (this.#ipv6 ? 'IPv6' : 'IPv4');
const BADGES = [
[StreamBadge.PLAYTIME, '1m', '#ff004d'],
[StreamBadge.BATTERY, batteryLevel, '#00b543'],
[StreamBadge.DOWNLOAD, this.#humanFileSize(0), '#29adff'],
[StreamBadge.UPLOAD, this.#humanFileSize(0), '#ff77a8'],
[StreamBadge.SERVER, server, '#ff6c24'],
video ? [StreamBadge.VIDEO, video, '#742f29'] : null,
audio ? [StreamBadge.AUDIO, audio, '#5f574f'] : null,
[StreamBadge.PLAYTIME, '1m'],
[StreamBadge.BATTERY, batteryLevel],
[StreamBadge.DOWNLOAD, humanFileSize(0)],
[StreamBadge.UPLOAD, humanFileSize(0)],
this.serverInfo.server ? this.badges.server.$element : [StreamBadge.SERVER, '?'],
this.serverInfo.video ? this.badges.video.$element : [StreamBadge.VIDEO, '?'],
this.serverInfo.audio ? this.badges.audio.$element : [StreamBadge.AUDIO, '?'],
];
const $container = CE('div', {'class': 'bx-badges'});
const $container = CE('div', {class: 'bx-badges'});
BADGES.forEach(item => {
if (!item) {
return;
}
const $badge = this.#renderBadge(...(item as [StreamBadge, string, string]));
let $badge: HTMLElement;
if (!(item instanceof HTMLElement)) {
$badge = this.renderBadge(...(item as [StreamBadge, string]));
} else {
$badge = item;
}
$container.appendChild($badge);
});
this.#$container = $container;
await this.#start();
this.$container = $container;
await this.start();
return $container;
}
async #getServerStats() {
private async getServerStats() {
const stats = await STATES.currentStream.peerConnection!.getStats();
const allVideoCodecs: {[index: string]: RTCBasicStat} = {};
const allVideoCodecs: Record<string, RTCBasicStat> = {};
let videoCodecId;
let videoWidth = 0;
let videoHeight = 0;
const allAudioCodecs: {[index: string]: RTCBasicStat} = {};
const allAudioCodecs: Record<string, RTCBasicStat> = {};
let audioCodecId;
const allCandidates: {[index: string]: string} = {};
const allCandidates: Record<string, string> = {};
let candidateId;
stats.forEach((stat: RTCBasicStat) => {
@ -287,6 +266,8 @@ export class StreamBadges {
// Get the codecId of the video/audio track currently being used
if (stat.kind === 'video') {
videoCodecId = stat.codecId;
videoWidth = stat.frameWidth;
videoHeight = stat.frameHeight;
} else if (stat.kind === 'audio') {
audioCodecId = stat.codecId;
}
@ -300,53 +281,77 @@ export class StreamBadges {
// Get video codec from codecId
if (videoCodecId) {
const videoStat = allVideoCodecs[videoCodecId];
const video: any = {
const video: StreamServerInfo['video'] = {
width: videoWidth,
height: videoHeight,
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;
match && (video.profile = match[1]);
}
this.#video = video;
let text = videoHeight + 'p';
text && (text += '/');
text += video.codec;
if (video.profile) {
const profile = video.profile;
let quality = profile;
if (profile.startsWith('4d')) {
quality = t('visual-quality-high');
} else if (profile.startsWith('42e')) {
quality = t('visual-quality-normal');
} else if (profile.startsWith('420')) {
quality = t('visual-quality-low');
}
text += ` (${quality})`;
}
// Render badge
this.badges.video.$element = this.renderBadge(StreamBadge.VIDEO, text);
this.serverInfo.video = video;
}
// Get audio codec from codecId
if (audioCodecId) {
const audioStat = allAudioCodecs[audioCodecId];
this.#audio = {
const audio: StreamServerInfo['audio'] = {
codec: audioStat.mimeType.substring(6),
bitrate: audioStat.clockRate,
}
};
const bitrate = audio.bitrate / 1000;
const text = `${audio.codec} (${bitrate} kHz)`;
this.badges.audio.$element = this.renderBadge(StreamBadge.AUDIO, text);
this.serverInfo.audio = audio;
}
// Get server type
if (candidateId) {
BxLogger.info('candidate', candidateId, allCandidates);
this.#ipv6 = allCandidates[candidateId].includes(':');
// Server + Region
const server = this.serverInfo.server;
if (server) {
server.ipv6 = allCandidates[candidateId].includes(':');
let text = '';
if (server.region) {
text += server.region;
}
text += '@' + (server ? 'IPv6' : 'IPv4');
this.badges.server.$element = this.renderBadge(StreamBadge.SERVER, text);
}
}
}
static setupEvents() {
window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const $video = (e as any).$video;
const streamBadges = StreamBadges.getInstance();
streamBadges.#resolution = {
width: $video.videoWidth,
height: $video.videoHeight,
};
streamBadges.startTimestamp = +new Date;
// Get battery level
try {
STATES.browser.capabilities.batteryApi && (navigator as NavigatorBattery).getBattery().then(bm => {
streamBadges.startBatteryLevel = Math.round(bm.level * 100);
});
} catch(e) {}
});
// Since the Lite version doesn't have the "..." button on System menu
// we need to display Stream badges in the Guide menu instead
isLiteVersion() && window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, async e => {

View File

@ -4,15 +4,8 @@ import { t } from "@utils/translation"
import { STATES } from "@utils/global"
import { PrefKey } from "@/enums/pref-keys"
import { getPref } from "@/utils/settings-storages/global-settings-storage"
import { StreamStat, StreamStatsCollector, type StreamStatGrade } from "@/utils/stream-stats-collector"
export enum StreamStat {
PING = 'ping',
FPS = 'fps',
BITRATE = 'btr',
DECODE_TIME = 'dt',
PACKETS_LOST = 'pl',
FRAMES_LOST = 'fl',
};
export class StreamStats {
private static instance: StreamStats;
@ -24,36 +17,76 @@ export class StreamStats {
return StreamStats.instance;
}
#timeoutId?: number | null;
readonly #updateInterval = 1000;
private intervalId?: number | null;
private readonly REFRESH_INTERVAL = 1 * 1000;
#$container: HTMLElement | undefined;
#$fps: HTMLElement | undefined;
#$ping: HTMLElement | undefined;
#$dt: HTMLElement | undefined;
#$pl: HTMLElement | undefined;
#$fl: HTMLElement | undefined;
#$br: HTMLElement | undefined;
private stats = {
[StreamStat.CLOCK]: {
name: t('clock'),
$element: CE('span'),
},
[StreamStat.PLAYTIME]: {
name: t('playtime'),
$element: CE('span'),
},
[StreamStat.BATTERY]: {
name: t('battery'),
$element: CE('span'),
},
[StreamStat.PING]: {
name: t('stat-ping'),
$element: CE('span'),
},
[StreamStat.FPS]: {
name: t('stat-fps'),
$element: CE('span'),
},
[StreamStat.BITRATE]: {
name: t('stat-bitrate'),
$element: CE('span'),
},
[StreamStat.DECODE_TIME]: {
name: t('stat-decode-time'),
$element: CE('span'),
},
[StreamStat.PACKETS_LOST]: {
name: t('stat-packets-lost'),
$element: CE('span'),
},
[StreamStat.FRAMES_LOST]: {
name: t('stat-frames-lost'),
$element: CE('span'),
},
[StreamStat.DOWNLOAD]: {
name: t('download'),
$element: CE('span'),
},
[StreamStat.UPLOAD]: {
name: t('upload'),
$element: CE('span'),
},
};
#lastVideoStat?: RTCBasicStat | null;
private $container!: HTMLElement;
#quickGlanceObserver?: MutationObserver | null;
quickGlanceObserver?: MutationObserver | null;
constructor() {
this.#render();
this.render();
}
start(glancing=false) {
async start(glancing=false) {
if (!this.isHidden() || (glancing && this.isGlancing())) {
return;
}
if (this.#$container) {
this.#$container.classList.remove('bx-gone');
this.#$container.dataset.display = glancing ? 'glancing' : 'fixed';
}
this.intervalId && clearInterval(this.intervalId);
await this.update(true);
this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval);
this.$container.classList.remove('bx-gone');
this.$container.dataset.display = glancing ? 'glancing' : 'fixed';
this.intervalId = window.setInterval(this.update.bind(this), this.REFRESH_INTERVAL);
}
stop(glancing=false) {
@ -61,19 +94,16 @@ export class StreamStats {
return;
}
this.#timeoutId && clearTimeout(this.#timeoutId);
this.#timeoutId = null;
this.#lastVideoStat = null;
this.intervalId && clearInterval(this.intervalId);
this.intervalId = null;
if (this.#$container) {
this.#$container.removeAttribute('data-display');
this.#$container.classList.add('bx-gone');
}
this.$container.removeAttribute('data-display');
this.$container.classList.add('bx-gone');
}
toggle() {
if (this.isGlancing()) {
this.#$container && (this.#$container.dataset.display = 'fixed');
this.$container && (this.$container.dataset.display = 'fixed');
} else {
this.isHidden() ? this.start() : this.stop();
}
@ -85,11 +115,11 @@ export class StreamStats {
this.hideSettingsUi();
}
isHidden = () => this.#$container && this.#$container.classList.contains('bx-gone');
isGlancing = () => this.#$container && this.#$container.dataset.display === 'glancing';
isHidden = () => this.$container.classList.contains('bx-gone');
isGlancing = () => this.$container.dataset.display === 'glancing';
quickGlanceSetup() {
if (!STATES.isPlaying || this.#quickGlanceObserver) {
if (!STATES.isPlaying || this.quickGlanceObserver) {
return;
}
@ -98,7 +128,7 @@ export class StreamStats {
return;
}
this.#quickGlanceObserver = new MutationObserver((mutationList, observer) => {
this.quickGlanceObserver = new MutationObserver((mutationList, observer) => {
for (let record of mutationList) {
if (record.attributeName && record.attributeName === 'aria-expanded') {
const expanded = (record.target as HTMLElement).ariaExpanded;
@ -111,7 +141,7 @@ export class StreamStats {
}
});
this.#quickGlanceObserver.observe($uiContainer, {
this.quickGlanceObserver.observe($uiContainer, {
attributes: true,
attributeFilter: ['aria-expanded'],
subtree: true,
@ -119,98 +149,54 @@ export class StreamStats {
}
quickGlanceStop() {
this.#quickGlanceObserver && this.#quickGlanceObserver.disconnect();
this.#quickGlanceObserver = null;
this.quickGlanceObserver && this.quickGlanceObserver.disconnect();
this.quickGlanceObserver = null;
}
async #update() {
if (this.isHidden() || !STATES.currentStream.peerConnection) {
private async update(forceUpdate=false) {
if ((!forceUpdate && this.isHidden()) || !STATES.currentStream.peerConnection) {
this.onStoppedPlaying();
return;
}
this.#timeoutId = null;
const startTime = performance.now();
const PREF_STATS_CONDITIONAL_FORMATTING = getPref(PrefKey.STATS_CONDITIONAL_FORMATTING);
let grade: StreamStatGrade = '';
const stats = await STATES.currentStream.peerConnection.getStats();
let grade = '';
// Collect stats
const statsCollector = StreamStatsCollector.getInstance();
await statsCollector.collect();
stats.forEach(stat => {
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
// FPS
this.#$fps!.textContent = stat.framesPerSecond || 0;
let statKey: keyof typeof this.stats;
for (statKey in this.stats) {
grade = '';
// Packets Lost
const packetsLost = Math.max(0, stat.packetsLost); // packetsLost can be negative, but we don't care about that
const packetsReceived = stat.packetsReceived;
const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2);
this.#$pl!.textContent = packetsLostPercentage === '0.00' ? packetsLost.toString() : `${packetsLost} (${packetsLostPercentage}%)`;
const stat = this.stats[statKey];
const value = statsCollector.getStat(statKey);
const $element = stat.$element;
$element.textContent = value.toString();
// Frames dropped
const framesDropped = stat.framesDropped;
const framesReceived = stat.framesReceived;
const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2);
this.#$fl!.textContent = framesDroppedPercentage === '0.00' ? framesDropped : `${framesDropped} (${framesDroppedPercentage}%)`;
if (!this.#lastVideoStat) {
this.#lastVideoStat = stat;
return;
}
const lastStat = this.#lastVideoStat;
// Bitrate
const timeDiff = stat.timestamp - lastStat.timestamp;
const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000;
this.#$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;
if (isNaN(currentDecodeTime)) {
this.#$dt!.textContent = '??ms';
} else {
this.#$dt!.textContent = `${currentDecodeTime.toFixed(2)}ms`;
}
if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : '';
this.#$dt!.dataset.grade = grade;
}
this.#lastVideoStat = stat;
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
// Round Trip Time
const roundTripTime = !!stat.currentRoundTripTime ? stat.currentRoundTripTime * 1000 : -1;
this.#$ping!.textContent = roundTripTime === -1 ? '???' : roundTripTime.toString();
if (PREF_STATS_CONDITIONAL_FORMATTING) {
grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : '';
this.#$ping!.dataset.grade = grade;
// Get stat's grade
if (PREF_STATS_CONDITIONAL_FORMATTING) {
if (statKey === StreamStat.PING || statKey === StreamStat.DECODE_TIME) {
grade = (value as any).calculateGrade();
}
}
});
const lapsedTime = performance.now() - startTime;
this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval - lapsedTime);
if ($element.dataset.grade !== grade) {
$element.dataset.grade = grade;
}
}
}
refreshStyles() {
const PREF_ITEMS = getPref(PrefKey.STATS_ITEMS);
const PREF_POSITION = getPref(PrefKey.STATS_POSITION);
const PREF_TRANSPARENT = getPref(PrefKey.STATS_TRANSPARENT);
const PREF_OPACITY = getPref(PrefKey.STATS_OPACITY);
const PREF_TEXT_SIZE = getPref(PrefKey.STATS_TEXT_SIZE);
const $container = this.#$container!;
const $container = this.$container;
$container.dataset.stats = '[' + PREF_ITEMS.join('][') + ']';
$container.dataset.position = PREF_POSITION;
$container.dataset.transparent = PREF_TRANSPARENT;
$container.style.opacity = PREF_OPACITY + '%';
$container.style.fontSize = PREF_TEXT_SIZE;
$container.dataset.position = getPref(PrefKey.STATS_POSITION);
$container.dataset.transparent = getPref(PrefKey.STATS_TRANSPARENT);
$container.style.opacity = getPref(PrefKey.STATS_OPACITY) + '%';
$container.style.fontSize = getPref(PrefKey.STATS_TEXT_SIZE);
}
hideSettingsUi() {
@ -219,34 +205,25 @@ export class StreamStats {
}
}
#render() {
const stats = {
[StreamStat.PING]: [t('stat-ping'), this.#$ping = CE('span', {}, '0')],
[StreamStat.FPS]: [t('stat-fps'), this.#$fps = CE('span', {}, '0')],
[StreamStat.BITRATE]: [t('stat-bitrate'), this.#$br = CE('span', {}, '0 Mbps')],
[StreamStat.DECODE_TIME]: [t('stat-decode-time'), this.#$dt = CE('span', {}, '0ms')],
[StreamStat.PACKETS_LOST]: [t('stat-packets-lost'), this.#$pl = CE('span', {}, '0')],
[StreamStat.FRAMES_LOST]: [t('stat-frames-lost'), this.#$fl = CE('span', {}, '0')],
};
private async render() {
this.$container = CE('div', {class: 'bx-stats-bar bx-gone'});
const $barFragment = document.createDocumentFragment();
let statKey: keyof typeof stats;
for (statKey in stats) {
let statKey: keyof typeof this.stats;
for (statKey in this.stats) {
const stat = this.stats[statKey];
const $div = CE('div', {
'class': `bx-stat-${statKey}`,
title: stats[statKey][0]
},
class: `bx-stat-${statKey}`,
title: stat.name,
},
CE('label', {}, statKey.toUpperCase()),
stats[statKey][1],
stat.$element,
);
$barFragment.appendChild($div);
this.$container.appendChild($div);
}
this.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment);
this.refreshStyles();
document.documentElement.appendChild(this.#$container!);
document.documentElement.appendChild(this.$container);
}
static setupEvents() {
@ -255,8 +232,8 @@ export class StreamStats {
const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING);
const streamStats = StreamStats.getInstance();
// Setup Stat's Quick Glance mode
// Setup Stat's Quick Glance mode
if (PREF_STATS_SHOW_WHEN_PLAYING) {
streamStats.start();
} else if (PREF_STATS_QUICK_GLANCE) {

View File

@ -1,27 +1,11 @@
import { BxEvent } from "@/utils/bx-event";
import { BxIcon } from "@/utils/bx-icon";
import { CE, createSvgIcon, getReactProps, isElementVisible } from "@/utils/html";
import { CE, createSvgIcon, getReactProps, isElementVisible, secondsToHms } from "@/utils/html";
import { XcloudApi } from "@/utils/xcloud-api";
export class GameTile {
static #timeout: number | null;
static #secondsToHms(seconds: number) {
let h = Math.floor(seconds / 3600);
seconds %= 3600;
let m = Math.floor(seconds / 60);
let s = seconds % 60;
const output = [];
h > 0 && output.push(`${h}h`);
m > 0 && output.push(`${m}m`);
if (s > 0 || output.length === 0) {
output.push(`${s}s`);
}
return output.join(' ');
}
static async #showWaitTime($elm: HTMLElement, productId: string) {
if (($elm as any).hasWaitTime) {
return;
@ -42,7 +26,7 @@ export class GameTile {
if (typeof totalWaitTime === 'number' && isElementVisible($elm)) {
const $div = CE('div', {'class': 'bx-game-tile-wait-time'},
createSvgIcon(BxIcon.PLAYTIME),
CE('span', {}, GameTile.#secondsToHms(totalWaitTime)),
CE('span', {}, secondsToHms(totalWaitTime)),
);
$elm.insertAdjacentElement('afterbegin', $div);
}

View File

@ -3,6 +3,8 @@ type RTCBasicStat = {
bytesReceived: number,
clockRate: number,
codecId: string,
frameWidth: number,
frameHeight: number,
framesDecoded: number,
id: string,
kind: string,

View File

@ -181,9 +181,47 @@ export function clearFocus() {
}
}
export function clearDataSet($elm: HTMLElement) {
Object.keys($elm.dataset).forEach(key => {
delete $elm.dataset[key];
});
}
// https://stackoverflow.com/a/20732091
const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
export function humanFileSize(size: number) {
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) + ' ' + FILE_SIZE_UNITS[i];
}
export function secondsToHm(seconds: number) {
let h = Math.floor(seconds / 3600);
let m = Math.floor(seconds % 3600 / 60) + 1;
if (m === 60) {
h += 1;
m = 0;
}
const output = [];
h > 0 && output.push(`${h}h`);
m > 0 && output.push(`${m}m`);
return output.join(' ');
}
export function secondsToHms(seconds: number) {
let h = Math.floor(seconds / 3600);
seconds %= 3600;
let m = Math.floor(seconds / 60);
let s = seconds % 60;
const output = [];
h > 0 && output.push(`${h}h`);
m > 0 && output.push(`${m}m`);
if (s > 0 || output.length === 0) {
output.push(`${s}s`);
}
return output.join(' ');
}

View File

@ -3,7 +3,6 @@ import { PrefKey, StorageKey } from "@/enums/pref-keys";
import { StreamPlayerType, StreamVideoProcessing } from "@/enums/stream-player";
import { UiSection } from "@/enums/ui-sections";
import { UserAgentProfile } from "@/enums/user-agent";
import { StreamStat } from "@/modules/stream/stream-stats";
import { type SettingDefinition, type SettingDefinitions } from "@/types/setting-definition";
import { BX_FLAGS } from "../bx-flags";
import { STATES, AppInterface, STORAGE } from "../global";
@ -12,6 +11,7 @@ import { t, SUPPORTED_LANGUAGES } from "../translation";
import { UserAgent } from "../user-agent";
import { BaseSettingsStore as BaseSettingsStorage } from "./base-settings-storage";
import { SettingElementType } from "../setting-element";
import { StreamStat } from "../stream-stats-collector";
export const enum StreamResolution {
@ -713,12 +713,17 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
label: t('stats'),
default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST],
multipleOptions: {
[StreamStat.CLOCK]: `${StreamStat.CLOCK.toUpperCase()}: ${t('clock')}`,
[StreamStat.PLAYTIME]: `${StreamStat.PLAYTIME.toUpperCase()}: ${t('playtime')}`,
[StreamStat.BATTERY]: `${StreamStat.BATTERY.toUpperCase()}: ${t('battery')}`,
[StreamStat.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`,
[StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`,
[StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`,
[StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`,
[StreamStat.PACKETS_LOST]: `${StreamStat.PACKETS_LOST.toUpperCase()}: ${t('stat-packets-lost')}`,
[StreamStat.FRAMES_LOST]: `${StreamStat.FRAMES_LOST.toUpperCase()}: ${t('stat-frames-lost')}`,
[StreamStat.DOWNLOAD]: `${StreamStat.DOWNLOAD.toUpperCase()}: ${t('download')}`,
[StreamStat.UPLOAD]: `${StreamStat.UPLOAD.toUpperCase()}: ${t('upload')}`,
},
params: {
size: 6,

View File

@ -0,0 +1,303 @@
import { BxEvent } from "./bx-event";
import { STATES } from "./global";
import { humanFileSize, secondsToHm } from "./html";
export enum StreamStat {
PING = 'ping',
FPS = 'fps',
BITRATE = 'btr',
DECODE_TIME = 'dt',
PACKETS_LOST = 'pl',
FRAMES_LOST = 'fl',
DOWNLOAD = 'dl',
UPLOAD = 'ul',
PLAYTIME = 'play',
BATTERY = 'batt',
CLOCK = 'time',
};
export type StreamStatGrade = '' | 'bad' | 'ok' | 'good';
type CurrentStats = {
[StreamStat.PING]: {
current: number;
calculateGrade: () => StreamStatGrade;
toString: () => string;
};
[StreamStat.FPS]: {
current: number;
toString: () => string;
};
[StreamStat.BITRATE]: {
current: number;
toString: () => string;
};
[StreamStat.FRAMES_LOST]: {
received: number;
dropped: number;
toString: () => string;
};
[StreamStat.PACKETS_LOST]: {
received: number;
dropped: number;
toString: () => string;
};
[StreamStat.DECODE_TIME]: {
current: number;
total: number;
calculateGrade: () => StreamStatGrade;
toString: () => string;
};
[StreamStat.DOWNLOAD]: {
total: number;
toString: () => string;
};
[StreamStat.UPLOAD]: {
total: number;
toString: () => string;
};
[StreamStat.PLAYTIME]: {
seconds: number;
startTime: number;
toString: () => string;
};
[StreamStat.BATTERY]: {
current: number;
start: number;
isCharging: boolean;
toString: () => string;
},
[StreamStat.CLOCK]: {
toString: () => string;
},
};
export class StreamStatsCollector {
private static instance: StreamStatsCollector;
public static getInstance(): StreamStatsCollector {
if (!StreamStatsCollector.instance) {
StreamStatsCollector.instance = new StreamStatsCollector();
}
return StreamStatsCollector.instance;
}
// Collect in background - 60 seconds
static readonly INTERVAL_BACKGROUND = 60 * 1000;
private currentStats: CurrentStats = {
[StreamStat.PING]: {
current: -1,
calculateGrade() {
return (this.current >= 100) ? 'bad' : (this.current > 75) ? 'ok' : (this.current > 40) ? 'good' : '';
},
toString() {
return this.current === -1 ? '???' : this.current.toString();
},
},
[StreamStat.FPS]: {
current: 0,
toString() {
return this.current.toString();
},
},
[StreamStat.BITRATE]: {
current: 0,
toString() {
return `${this.current.toFixed(2)} Mbps`;
},
},
[StreamStat.FRAMES_LOST]: {
received: 0,
dropped: 0,
toString() {
const framesDroppedPercentage = (this.dropped * 100 / ((this.dropped + this.received) || 1)).toFixed(2);
return framesDroppedPercentage === '0.00' ? this.dropped.toString() : `${this.dropped} (${framesDroppedPercentage}%)`;
},
},
[StreamStat.PACKETS_LOST]: {
received: 0,
dropped: 0,
toString() {
const packetsLostPercentage = (this.dropped * 100 / ((this.dropped + this.received) || 1)).toFixed(2);
return packetsLostPercentage === '0.00' ? this.dropped.toString() : `${this.dropped} (${packetsLostPercentage}%)`;
},
},
[StreamStat.DECODE_TIME]: {
current: 0,
total: 0,
calculateGrade() {
return (this.current > 12) ? 'bad' : (this.current > 9) ? 'ok' : (this.current > 6) ? 'good' : '';
},
toString() {
return isNaN(this.current) ? '??ms' : `${this.current.toFixed(2)}ms`;
},
},
[StreamStat.DOWNLOAD]: {
total: 0,
toString() {
return humanFileSize(this.total);
},
},
[StreamStat.UPLOAD]: {
total: 0,
toString() {
return humanFileSize(this.total);
},
},
[StreamStat.PLAYTIME]: {
seconds: 0,
startTime: 0,
toString() {
return secondsToHm(this.seconds);
},
},
[StreamStat.BATTERY]: {
current: 100,
start: 100,
isCharging: false,
toString() {
let text = `${this.current}%`;
if (this.current !== this.start) {
const diffLevel = Math.round(this.current - this.start);
const sign = diffLevel > 0 ? '+' : '';
text += ` (${sign}${diffLevel}%)`;
}
return text;
},
},
[StreamStat.CLOCK]: {
toString() {
return new Date().toLocaleTimeString([], {
hour: '2-digit',
minute:'2-digit',
hour12: false,
});
}
},
};
private lastVideoStat?: RTCBasicStat | null;
async collect() {
const stats = await STATES.currentStream.peerConnection?.getStats();
stats?.forEach(stat => {
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
// FPS
const fps = this.currentStats[StreamStat.FPS];
fps.current = stat.framesPerSecond || 0;
// Packets Lost
// packetsLost can be negative, but we don't care about that
const pl = this.currentStats[StreamStat.PACKETS_LOST];
pl.dropped = Math.max(0, stat.packetsLost);
pl.received = stat.packetsReceived;
// Frames lost
const fl = this.currentStats[StreamStat.FRAMES_LOST];
fl.dropped = stat.framesDropped;
fl.received = stat.framesReceived;
if (!this.lastVideoStat) {
this.lastVideoStat = stat;
return;
}
const lastStat = this.lastVideoStat;
// Bitrate
const btr = this.currentStats[StreamStat.BITRATE];
const timeDiff = stat.timestamp - lastStat.timestamp;
btr.current = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000;
// Decode time
const dt = this.currentStats[StreamStat.DECODE_TIME];
dt.total = stat.totalDecodeTime - lastStat.totalDecodeTime;
const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded;
dt.current = dt.total / framesDecodedDiff * 1000;
this.lastVideoStat = stat;
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
// Round Trip Time
const ping = this.currentStats[StreamStat.PING];
ping.current = stat.currentRoundTripTime ? stat.currentRoundTripTime * 1000 : -1;
// Download
const dl = this.currentStats[StreamStat.DOWNLOAD];
dl.total = stat.bytesReceived;
// Upload
const ul = this.currentStats[StreamStat.UPLOAD];
ul.total = stat.bytesSent;
}
});
// Battery
let batteryLevel = 100;
let isCharging = false;
if (STATES.browser.capabilities.batteryApi) {
try {
const bm = await (navigator as NavigatorBattery).getBattery();
isCharging = bm.charging;
batteryLevel = Math.round(bm.level * 100);
} catch(e) {}
}
const battery = this.currentStats[StreamStat.BATTERY];
battery.current = batteryLevel;
battery.isCharging = isCharging;
// Playtime
const playTime = this.currentStats[StreamStat.PLAYTIME];
const now = +new Date;
playTime.seconds = Math.ceil((now - playTime.startTime) / 1000);
}
getStat<T extends StreamStat>(kind: T): CurrentStats[T] {
return this.currentStats[kind];
}
reset() {
const playTime = this.currentStats[StreamStat.PLAYTIME];
playTime.seconds = 0;
playTime.startTime = +new Date;
// Get battery level
try {
STATES.browser.capabilities.batteryApi && (navigator as NavigatorBattery).getBattery().then(bm => {
this.currentStats[StreamStat.BATTERY].start = Math.round(bm.level * 100);
});
} catch(e) {}
}
static setupEvents() {
window.addEventListener(BxEvent.STREAM_PLAYING, e => {
const statsCollector = StreamStatsCollector.getInstance();
statsCollector.reset();
});
}
}

View File

@ -40,13 +40,7 @@ const Texts = {
"auto": "Auto",
"back-to-home": "Back to home",
"back-to-home-confirm": "Do you want to go back to the home page (without disconnecting)?",
"badge-audio": "Audio",
"badge-battery": "Battery",
"badge-in": "In",
"badge-out": "Out",
"badge-playtime": "Playtime",
"badge-server": "Server",
"badge-video": "Video",
"battery": "Battery",
"battery-saving": "Battery saving",
"better-xcloud": "Better xCloud",
"bitrate-audio-maximum": "Maximum audio bitrate",
@ -63,6 +57,7 @@ const Texts = {
"clarity-boost": "Clarity boost",
"clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON",
"clear": "Clear",
"clock": "Clock",
"close": "Close",
"close-app": "Close app",
"combine-audio-video-streams": "Combine audio & video streams",
@ -97,6 +92,7 @@ const Texts = {
"disable-xcloud-analytics": "Disable xCloud analytics",
"disabled": "Disabled",
"disconnected": "Disconnected",
"download": "Download",
"edit": "Edit",
"enable-controller-shortcuts": "Enable controller shortcuts",
"enable-local-co-op-support": "Enable local co-op support",
@ -186,6 +182,7 @@ const Texts = {
"opacity": "Opacity",
"other": "Other",
"playing": "Playing",
"playtime": "Playtime",
"poland": "Poland",
"position": "Position",
"powered-off": "Powered off",
@ -350,6 +347,7 @@ const Texts = {
"unlimited": "Unlimited",
"unmuted": "Unmuted",
"unsharp-masking": "Unsharp masking",
"upload": "Upload",
"use-mouse-absolute-position": "Use mouse's absolute position",
"use-this-at-your-own-risk": "Use this at your own risk",
"user-agent-profile": "User-Agent profile",