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

View File

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

View File

@ -2,32 +2,51 @@ import { isLiteVersion } from "@macros/build" with {type: "macro"};
import { t } from "@utils/translation"; import { t } from "@utils/translation";
import { BxEvent } from "@utils/bx-event"; 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 { STATES } from "@utils/global";
import { BxLogger } from "@/utils/bx-logger"; import { BxLogger } from "@/utils/bx-logger";
import { BxIcon } from "@/utils/bx-icon"; import { BxIcon } from "@/utils/bx-icon";
import { GuideMenuTab } from "../ui/guide-menu"; 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 { enum StreamBadge {
PLAYTIME = 'playtime', PLAYTIME = 'playtime',
BATTERY = 'battery', BATTERY = 'battery',
DOWNLOAD = 'in', DOWNLOAD = 'download',
UPLOAD = 'out', UPLOAD = 'upload',
SERVER = 'server', SERVER = 'server',
VIDEO = 'video', VIDEO = 'video',
AUDIO = 'audio', 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 { export class StreamBadges {
private static instance: StreamBadges; private static instance: StreamBadges;
@ -39,91 +58,100 @@ export class StreamBadges {
return StreamBadges.instance; return StreamBadges.instance;
} }
#ipv6 = false; private serverInfo: StreamServerInfo = {};
#resolution?: {width: number, height: number} | null = null;
#video?: {codec: string, profile?: string | null} | null = null;
#audio?: {codec: string, bitrate: number} | null = null;
#region = '';
startBatteryLevel = 100; private badges: Record<StreamBadge, StreamBadgeInfo> = {
startTimestamp = 0; [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; private $container: HTMLElement | undefined;
#cachedDoms: Partial<{[key in StreamBadge]: HTMLElement}> = {};
#interval?: number | null; private intervalId?: number | null;
readonly #REFRESH_INTERVAL = 3000; private readonly REFRESH_INTERVAL = 3 * 1000;
setRegion(region: string) { 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; let $badge;
if (this.#cachedDoms[name]) { if (badgeInfo.$element) {
$badge = this.#cachedDoms[name]!; $badge = badgeInfo.$element;
$badge.lastElementChild!.textContent = value; $badge.lastElementChild!.textContent = value;
return $badge; return $badge;
} }
$badge = CE('div', {'class': 'bx-badge', 'title': t(`badge-${name}`)}, $badge = CE('div', {class: 'bx-badge', title: badgeInfo.name},
CE('span', {'class': 'bx-badge-name'}, createSvgIcon(StreamBadgeIcon[name])), CE('span', {class: 'bx-badge-name'}, createSvgIcon(badgeInfo.icon)),
CE('span', {'class': 'bx-badge-value', 'style': `background-color: ${color}`}, value), CE('span', {class: 'bx-badge-value', style: `background-color: ${badgeInfo.color}`}, value),
); );
if (name === StreamBadge.BATTERY) { if (name === StreamBadge.BATTERY) {
$badge.classList.add('bx-badge-battery'); $badge.classList.add('bx-badge-battery');
} }
this.#cachedDoms[name] = $badge; this.badges[name].$element = $badge;
return $badge; return $badge;
} }
async #updateBadges(forceUpdate = false) { private async updateBadges(forceUpdate = false) {
if (!this.#$container || (!forceUpdate && !this.#$container.isConnected)) { if (!this.$container || (!forceUpdate && !this.$container.isConnected)) {
this.#stop(); this.stop();
return; return;
} }
// Playtime const statsCollector = StreamStatsCollector.getInstance();
let now = +new Date; await statsCollector.collect();
const diffSeconds = Math.ceil((now - this.startTimestamp) / 1000);
const playtime = this.#secondsToHm(diffSeconds);
// Battery const play = statsCollector.getStat(StreamStat.PLAYTIME);
let batteryLevel = '100%'; const batt = statsCollector.getStat(StreamStat.BATTERY);
let batteryLevelInt = 100; const dl = statsCollector.getStat(StreamStat.DOWNLOAD);
let isCharging = false; const ul = statsCollector.getStat(StreamStat.UPLOAD);
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 badges = { const badges = {
[StreamBadge.DOWNLOAD]: totalIn ? this.#humanFileSize(totalIn) : null, [StreamBadge.DOWNLOAD]: dl.toString(),
[StreamBadge.UPLOAD]: totalOut ? this.#humanFileSize(totalOut) : null, [StreamBadge.UPLOAD]: ul.toString(),
[StreamBadge.PLAYTIME]: playtime, [StreamBadge.PLAYTIME]: play.toString(),
[StreamBadge.BATTERY]: batteryLevel, [StreamBadge.BATTERY]: batt.toString(),
}; };
let name: keyof typeof badges; let name: keyof typeof badges;
@ -133,97 +161,44 @@ export class StreamBadges {
continue; continue;
} }
const $elm = this.#cachedDoms[name]!; const $elm = this.badges[name].$element;
$elm && ($elm.lastElementChild!.textContent = value); if (!$elm) {
continue;
}
$elm.lastElementChild!.textContent = value;
if (name === StreamBadge.BATTERY) { 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% // Hide battery badge when the battery is 100%
$elm.classList.add('bx-gone'); $elm.classList.add('bx-gone');
} else { } else {
// Show charging status // Show charging status
$elm.dataset.charging = isCharging.toString() $elm.dataset.charging = batt.isCharging.toString();
$elm.classList.remove('bx-gone'); $elm.classList.remove('bx-gone');
} }
} }
} }
} }
async #start() { private async start() {
await this.#updateBadges(true); await this.updateBadges(true);
this.#stop(); this.stop();
this.#interval = window.setInterval(this.#updateBadges.bind(this), this.#REFRESH_INTERVAL); this.intervalId = window.setInterval(this.updateBadges.bind(this), this.REFRESH_INTERVAL);
} }
#stop() { private stop() {
this.#interval && clearInterval(this.#interval); this.intervalId && clearInterval(this.intervalId);
this.#interval = null; this.intervalId = 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];
} }
async render() { async render() {
if (this.#$container) { if (this.$container) {
this.#start(); this.start();
return this.#$container; return this.$container;
} }
await this.#getServerStats(); 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)`;
}
// Battery // Battery
let batteryLevel = ''; let batteryLevel = '';
@ -231,46 +206,50 @@ export class StreamBadges {
batteryLevel = '100%'; batteryLevel = '100%';
} }
// Server + Region
let server = this.#region;
server += '@' + (this.#ipv6 ? 'IPv6' : 'IPv4');
const BADGES = [ const BADGES = [
[StreamBadge.PLAYTIME, '1m', '#ff004d'], [StreamBadge.PLAYTIME, '1m'],
[StreamBadge.BATTERY, batteryLevel, '#00b543'], [StreamBadge.BATTERY, batteryLevel],
[StreamBadge.DOWNLOAD, this.#humanFileSize(0), '#29adff'], [StreamBadge.DOWNLOAD, humanFileSize(0)],
[StreamBadge.UPLOAD, this.#humanFileSize(0), '#ff77a8'], [StreamBadge.UPLOAD, humanFileSize(0)],
[StreamBadge.SERVER, server, '#ff6c24'], this.serverInfo.server ? this.badges.server.$element : [StreamBadge.SERVER, '?'],
video ? [StreamBadge.VIDEO, video, '#742f29'] : null, this.serverInfo.video ? this.badges.video.$element : [StreamBadge.VIDEO, '?'],
audio ? [StreamBadge.AUDIO, audio, '#5f574f'] : null, 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 => { BADGES.forEach(item => {
if (!item) { if (!item) {
return; 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); $container.appendChild($badge);
}); });
this.#$container = $container; this.$container = $container;
await this.#start(); await this.start();
return $container; return $container;
} }
async #getServerStats() { private async getServerStats() {
const stats = await STATES.currentStream.peerConnection!.getStats(); const stats = await STATES.currentStream.peerConnection!.getStats();
const allVideoCodecs: {[index: string]: RTCBasicStat} = {}; const allVideoCodecs: Record<string, RTCBasicStat> = {};
let videoCodecId; let videoCodecId;
let videoWidth = 0;
let videoHeight = 0;
const allAudioCodecs: {[index: string]: RTCBasicStat} = {}; const allAudioCodecs: Record<string, RTCBasicStat> = {};
let audioCodecId; let audioCodecId;
const allCandidates: {[index: string]: string} = {}; const allCandidates: Record<string, string> = {};
let candidateId; let candidateId;
stats.forEach((stat: RTCBasicStat) => { stats.forEach((stat: RTCBasicStat) => {
@ -287,6 +266,8 @@ export class StreamBadges {
// Get the codecId of the video/audio track currently being used // Get the codecId of the video/audio track currently being used
if (stat.kind === 'video') { if (stat.kind === 'video') {
videoCodecId = stat.codecId; videoCodecId = stat.codecId;
videoWidth = stat.frameWidth;
videoHeight = stat.frameHeight;
} else if (stat.kind === 'audio') { } else if (stat.kind === 'audio') {
audioCodecId = stat.codecId; audioCodecId = stat.codecId;
} }
@ -300,53 +281,77 @@ export class StreamBadges {
// Get video codec from codecId // Get video codec from codecId
if (videoCodecId) { if (videoCodecId) {
const videoStat = allVideoCodecs[videoCodecId]; const videoStat = allVideoCodecs[videoCodecId];
const video: any = { const video: StreamServerInfo['video'] = {
width: videoWidth,
height: videoHeight,
codec: videoStat.mimeType.substring(6), codec: videoStat.mimeType.substring(6),
}; };
if (video.codec === 'H264') { if (video.codec === 'H264') {
const match = /profile-level-id=([0-9a-f]{6})/.exec(videoStat.sdpFmtpLine); 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 // Get audio codec from codecId
if (audioCodecId) { if (audioCodecId) {
const audioStat = allAudioCodecs[audioCodecId]; const audioStat = allAudioCodecs[audioCodecId];
this.#audio = { const audio: StreamServerInfo['audio'] = {
codec: audioStat.mimeType.substring(6), codec: audioStat.mimeType.substring(6),
bitrate: audioStat.clockRate, 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 // Get server type
if (candidateId) { if (candidateId) {
BxLogger.info('candidate', candidateId, allCandidates); 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() { 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 // Since the Lite version doesn't have the "..." button on System menu
// we need to display Stream badges in the Guide menu instead // we need to display Stream badges in the Guide menu instead
isLiteVersion() && window.addEventListener(BxEvent.XCLOUD_GUIDE_MENU_SHOWN, async e => { 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 { STATES } from "@utils/global"
import { PrefKey } from "@/enums/pref-keys" import { PrefKey } from "@/enums/pref-keys"
import { getPref } from "@/utils/settings-storages/global-settings-storage" 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 { export class StreamStats {
private static instance: StreamStats; private static instance: StreamStats;
@ -24,36 +17,76 @@ export class StreamStats {
return StreamStats.instance; return StreamStats.instance;
} }
#timeoutId?: number | null; private intervalId?: number | null;
readonly #updateInterval = 1000; private readonly REFRESH_INTERVAL = 1 * 1000;
#$container: HTMLElement | undefined; private stats = {
#$fps: HTMLElement | undefined; [StreamStat.CLOCK]: {
#$ping: HTMLElement | undefined; name: t('clock'),
#$dt: HTMLElement | undefined; $element: CE('span'),
#$pl: HTMLElement | undefined; },
#$fl: HTMLElement | undefined; [StreamStat.PLAYTIME]: {
#$br: HTMLElement | undefined; 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() { constructor() {
this.#render(); this.render();
} }
start(glancing=false) { async start(glancing=false) {
if (!this.isHidden() || (glancing && this.isGlancing())) { if (!this.isHidden() || (glancing && this.isGlancing())) {
return; return;
} }
if (this.#$container) { this.intervalId && clearInterval(this.intervalId);
this.#$container.classList.remove('bx-gone'); await this.update(true);
this.#$container.dataset.display = glancing ? 'glancing' : 'fixed';
}
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) { stop(glancing=false) {
@ -61,19 +94,16 @@ export class StreamStats {
return; return;
} }
this.#timeoutId && clearTimeout(this.#timeoutId); this.intervalId && clearInterval(this.intervalId);
this.#timeoutId = null; this.intervalId = null;
this.#lastVideoStat = null;
if (this.#$container) { this.$container.removeAttribute('data-display');
this.#$container.removeAttribute('data-display'); this.$container.classList.add('bx-gone');
this.#$container.classList.add('bx-gone');
}
} }
toggle() { toggle() {
if (this.isGlancing()) { if (this.isGlancing()) {
this.#$container && (this.#$container.dataset.display = 'fixed'); this.$container && (this.$container.dataset.display = 'fixed');
} else { } else {
this.isHidden() ? this.start() : this.stop(); this.isHidden() ? this.start() : this.stop();
} }
@ -85,11 +115,11 @@ export class StreamStats {
this.hideSettingsUi(); this.hideSettingsUi();
} }
isHidden = () => this.#$container && this.#$container.classList.contains('bx-gone'); isHidden = () => this.$container.classList.contains('bx-gone');
isGlancing = () => this.#$container && this.#$container.dataset.display === 'glancing'; isGlancing = () => this.$container.dataset.display === 'glancing';
quickGlanceSetup() { quickGlanceSetup() {
if (!STATES.isPlaying || this.#quickGlanceObserver) { if (!STATES.isPlaying || this.quickGlanceObserver) {
return; return;
} }
@ -98,7 +128,7 @@ export class StreamStats {
return; return;
} }
this.#quickGlanceObserver = new MutationObserver((mutationList, observer) => { this.quickGlanceObserver = new MutationObserver((mutationList, observer) => {
for (let record of mutationList) { for (let record of mutationList) {
if (record.attributeName && record.attributeName === 'aria-expanded') { if (record.attributeName && record.attributeName === 'aria-expanded') {
const expanded = (record.target as HTMLElement).ariaExpanded; const expanded = (record.target as HTMLElement).ariaExpanded;
@ -111,7 +141,7 @@ export class StreamStats {
} }
}); });
this.#quickGlanceObserver.observe($uiContainer, { this.quickGlanceObserver.observe($uiContainer, {
attributes: true, attributes: true,
attributeFilter: ['aria-expanded'], attributeFilter: ['aria-expanded'],
subtree: true, subtree: true,
@ -119,98 +149,54 @@ export class StreamStats {
} }
quickGlanceStop() { quickGlanceStop() {
this.#quickGlanceObserver && this.#quickGlanceObserver.disconnect(); this.quickGlanceObserver && this.quickGlanceObserver.disconnect();
this.#quickGlanceObserver = null; this.quickGlanceObserver = null;
} }
async #update() { private async update(forceUpdate=false) {
if (this.isHidden() || !STATES.currentStream.peerConnection) { if ((!forceUpdate && this.isHidden()) || !STATES.currentStream.peerConnection) {
this.onStoppedPlaying(); this.onStoppedPlaying();
return; return;
} }
this.#timeoutId = null;
const startTime = performance.now();
const PREF_STATS_CONDITIONAL_FORMATTING = getPref(PrefKey.STATS_CONDITIONAL_FORMATTING); const PREF_STATS_CONDITIONAL_FORMATTING = getPref(PrefKey.STATS_CONDITIONAL_FORMATTING);
let grade: StreamStatGrade = '';
const stats = await STATES.currentStream.peerConnection.getStats(); // Collect stats
let grade = ''; const statsCollector = StreamStatsCollector.getInstance();
await statsCollector.collect();
stats.forEach(stat => { let statKey: keyof typeof this.stats;
if (stat.type === 'inbound-rtp' && stat.kind === 'video') { for (statKey in this.stats) {
// FPS grade = '';
this.#$fps!.textContent = stat.framesPerSecond || 0;
// Packets Lost const stat = this.stats[statKey];
const packetsLost = Math.max(0, stat.packetsLost); // packetsLost can be negative, but we don't care about that const value = statsCollector.getStat(statKey);
const packetsReceived = stat.packetsReceived; const $element = stat.$element;
const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2); $element.textContent = value.toString();
this.#$pl!.textContent = packetsLostPercentage === '0.00' ? packetsLost.toString() : `${packetsLost} (${packetsLostPercentage}%)`;
// Frames dropped // Get stat's grade
const framesDropped = stat.framesDropped; if (PREF_STATS_CONDITIONAL_FORMATTING) {
const framesReceived = stat.framesReceived; if (statKey === StreamStat.PING || statKey === StreamStat.DECODE_TIME) {
const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2); grade = (value as any).calculateGrade();
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;
} }
} }
});
const lapsedTime = performance.now() - startTime; if ($element.dataset.grade !== grade) {
this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval - lapsedTime); $element.dataset.grade = grade;
}
}
} }
refreshStyles() { refreshStyles() {
const PREF_ITEMS = getPref(PrefKey.STATS_ITEMS); 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.stats = '[' + PREF_ITEMS.join('][') + ']';
$container.dataset.position = PREF_POSITION; $container.dataset.position = getPref(PrefKey.STATS_POSITION);
$container.dataset.transparent = PREF_TRANSPARENT; $container.dataset.transparent = getPref(PrefKey.STATS_TRANSPARENT);
$container.style.opacity = PREF_OPACITY + '%'; $container.style.opacity = getPref(PrefKey.STATS_OPACITY) + '%';
$container.style.fontSize = PREF_TEXT_SIZE; $container.style.fontSize = getPref(PrefKey.STATS_TEXT_SIZE);
} }
hideSettingsUi() { hideSettingsUi() {
@ -219,34 +205,25 @@ export class StreamStats {
} }
} }
#render() { private async render() {
const stats = { this.$container = CE('div', {class: 'bx-stats-bar bx-gone'});
[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')],
};
const $barFragment = document.createDocumentFragment(); let statKey: keyof typeof this.stats;
let statKey: keyof typeof stats; for (statKey in this.stats) {
for (statKey in stats) { const stat = this.stats[statKey];
const $div = CE('div', { const $div = CE('div', {
'class': `bx-stat-${statKey}`, class: `bx-stat-${statKey}`,
title: stats[statKey][0] title: stat.name,
}, },
CE('label', {}, statKey.toUpperCase()), 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(); this.refreshStyles();
document.documentElement.appendChild(this.$container);
document.documentElement.appendChild(this.#$container!);
} }
static setupEvents() { static setupEvents() {
@ -255,8 +232,8 @@ export class StreamStats {
const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING); const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING);
const streamStats = StreamStats.getInstance(); const streamStats = StreamStats.getInstance();
// Setup Stat's Quick Glance mode
// Setup Stat's Quick Glance mode
if (PREF_STATS_SHOW_WHEN_PLAYING) { if (PREF_STATS_SHOW_WHEN_PLAYING) {
streamStats.start(); streamStats.start();
} else if (PREF_STATS_QUICK_GLANCE) { } else if (PREF_STATS_QUICK_GLANCE) {

View File

@ -1,27 +1,11 @@
import { BxEvent } from "@/utils/bx-event"; import { BxEvent } from "@/utils/bx-event";
import { BxIcon } from "@/utils/bx-icon"; 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"; import { XcloudApi } from "@/utils/xcloud-api";
export class GameTile { export class GameTile {
static #timeout: number | null; 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) { static async #showWaitTime($elm: HTMLElement, productId: string) {
if (($elm as any).hasWaitTime) { if (($elm as any).hasWaitTime) {
return; return;
@ -42,7 +26,7 @@ export class GameTile {
if (typeof totalWaitTime === 'number' && isElementVisible($elm)) { if (typeof totalWaitTime === 'number' && isElementVisible($elm)) {
const $div = CE('div', {'class': 'bx-game-tile-wait-time'}, const $div = CE('div', {'class': 'bx-game-tile-wait-time'},
createSvgIcon(BxIcon.PLAYTIME), createSvgIcon(BxIcon.PLAYTIME),
CE('span', {}, GameTile.#secondsToHms(totalWaitTime)), CE('span', {}, secondsToHms(totalWaitTime)),
); );
$elm.insertAdjacentElement('afterbegin', $div); $elm.insertAdjacentElement('afterbegin', $div);
} }

View File

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

View File

@ -181,9 +181,47 @@ export function clearFocus() {
} }
} }
export function clearDataSet($elm: HTMLElement) { export function clearDataSet($elm: HTMLElement) {
Object.keys($elm.dataset).forEach(key => { Object.keys($elm.dataset).forEach(key => {
delete $elm.dataset[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 { StreamPlayerType, StreamVideoProcessing } from "@/enums/stream-player";
import { UiSection } from "@/enums/ui-sections"; import { UiSection } from "@/enums/ui-sections";
import { UserAgentProfile } from "@/enums/user-agent"; import { UserAgentProfile } from "@/enums/user-agent";
import { StreamStat } from "@/modules/stream/stream-stats";
import { type SettingDefinition, type SettingDefinitions } from "@/types/setting-definition"; import { type SettingDefinition, type SettingDefinitions } from "@/types/setting-definition";
import { BX_FLAGS } from "../bx-flags"; import { BX_FLAGS } from "../bx-flags";
import { STATES, AppInterface, STORAGE } from "../global"; import { STATES, AppInterface, STORAGE } from "../global";
@ -12,6 +11,7 @@ import { t, SUPPORTED_LANGUAGES } from "../translation";
import { UserAgent } from "../user-agent"; import { UserAgent } from "../user-agent";
import { BaseSettingsStore as BaseSettingsStorage } from "./base-settings-storage"; import { BaseSettingsStore as BaseSettingsStorage } from "./base-settings-storage";
import { SettingElementType } from "../setting-element"; import { SettingElementType } from "../setting-element";
import { StreamStat } from "../stream-stats-collector";
export const enum StreamResolution { export const enum StreamResolution {
@ -713,12 +713,17 @@ export class GlobalSettingsStorage extends BaseSettingsStorage {
label: t('stats'), label: t('stats'),
default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST], default: [StreamStat.PING, StreamStat.FPS, StreamStat.BITRATE, StreamStat.DECODE_TIME, StreamStat.PACKETS_LOST, StreamStat.FRAMES_LOST],
multipleOptions: { 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.PING]: `${StreamStat.PING.toUpperCase()}: ${t('stat-ping')}`,
[StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`, [StreamStat.FPS]: `${StreamStat.FPS.toUpperCase()}: ${t('stat-fps')}`,
[StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`, [StreamStat.BITRATE]: `${StreamStat.BITRATE.toUpperCase()}: ${t('stat-bitrate')}`,
[StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`, [StreamStat.DECODE_TIME]: `${StreamStat.DECODE_TIME.toUpperCase()}: ${t('stat-decode-time')}`,
[StreamStat.PACKETS_LOST]: `${StreamStat.PACKETS_LOST.toUpperCase()}: ${t('stat-packets-lost')}`, [StreamStat.PACKETS_LOST]: `${StreamStat.PACKETS_LOST.toUpperCase()}: ${t('stat-packets-lost')}`,
[StreamStat.FRAMES_LOST]: `${StreamStat.FRAMES_LOST.toUpperCase()}: ${t('stat-frames-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: { params: {
size: 6, 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", "auto": "Auto",
"back-to-home": "Back to home", "back-to-home": "Back to home",
"back-to-home-confirm": "Do you want to go back to the home page (without disconnecting)?", "back-to-home-confirm": "Do you want to go back to the home page (without disconnecting)?",
"badge-audio": "Audio", "battery": "Battery",
"badge-battery": "Battery",
"badge-in": "In",
"badge-out": "Out",
"badge-playtime": "Playtime",
"badge-server": "Server",
"badge-video": "Video",
"battery-saving": "Battery saving", "battery-saving": "Battery saving",
"better-xcloud": "Better xCloud", "better-xcloud": "Better xCloud",
"bitrate-audio-maximum": "Maximum audio bitrate", "bitrate-audio-maximum": "Maximum audio bitrate",
@ -63,6 +57,7 @@ const Texts = {
"clarity-boost": "Clarity boost", "clarity-boost": "Clarity boost",
"clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON", "clarity-boost-warning": "These settings don't work when the Clarity Boost mode is ON",
"clear": "Clear", "clear": "Clear",
"clock": "Clock",
"close": "Close", "close": "Close",
"close-app": "Close app", "close-app": "Close app",
"combine-audio-video-streams": "Combine audio & video streams", "combine-audio-video-streams": "Combine audio & video streams",
@ -97,6 +92,7 @@ const Texts = {
"disable-xcloud-analytics": "Disable xCloud analytics", "disable-xcloud-analytics": "Disable xCloud analytics",
"disabled": "Disabled", "disabled": "Disabled",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"download": "Download",
"edit": "Edit", "edit": "Edit",
"enable-controller-shortcuts": "Enable controller shortcuts", "enable-controller-shortcuts": "Enable controller shortcuts",
"enable-local-co-op-support": "Enable local co-op support", "enable-local-co-op-support": "Enable local co-op support",
@ -186,6 +182,7 @@ const Texts = {
"opacity": "Opacity", "opacity": "Opacity",
"other": "Other", "other": "Other",
"playing": "Playing", "playing": "Playing",
"playtime": "Playtime",
"poland": "Poland", "poland": "Poland",
"position": "Position", "position": "Position",
"powered-off": "Powered off", "powered-off": "Powered off",
@ -350,6 +347,7 @@ const Texts = {
"unlimited": "Unlimited", "unlimited": "Unlimited",
"unmuted": "Unmuted", "unmuted": "Unmuted",
"unsharp-masking": "Unsharp masking", "unsharp-masking": "Unsharp masking",
"upload": "Upload",
"use-mouse-absolute-position": "Use mouse's absolute position", "use-mouse-absolute-position": "Use mouse's absolute position",
"use-this-at-your-own-risk": "Use this at your own risk", "use-this-at-your-own-risk": "Use this at your own risk",
"user-agent-profile": "User-Agent profile", "user-agent-profile": "User-Agent profile",