mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-08-05 20:58:27 +02:00
New stats: clock, play time, battery, download, upload
This commit is contained in:
@@ -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(' ');
|
||||
}
|
||||
|
@@ -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,
|
||||
|
303
src/utils/stream-stats-collector.ts
Normal file
303
src/utils/stream-stats-collector.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
@@ -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",
|
||||
|
Reference in New Issue
Block a user