better-xcloud/src/modules/stream/stream-stats.ts
2024-12-08 21:57:29 +07:00

254 lines
7.8 KiB
TypeScript
Executable File

import { CE } from "@utils/html"
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 { StreamStatsCollector, type StreamStatGrade } from "@/utils/stream-stats-collector"
import { BxLogger } from "@/utils/bx-logger"
import { StreamStat } from "@/enums/pref-values"
import { BxEventBus } from "@/utils/bx-event-bus"
export class StreamStats {
private static instance: StreamStats;
public static getInstance = () => StreamStats.instance ?? (StreamStats.instance = new StreamStats());
private readonly LOG_TAG = 'StreamStats';
private intervalId?: number | null;
private readonly REFRESH_INTERVAL = 1 * 1000;
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.JITTER]: {
name: t('jitter'),
$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('downloaded'),
$element: CE('span'),
},
[StreamStat.UPLOAD]: {
name: t('uploaded'),
$element: CE('span'),
},
};
private $container!: HTMLElement;
quickGlanceObserver?: MutationObserver | null;
private constructor() {
BxLogger.info(this.LOG_TAG, 'constructor()');
this.render();
}
async start(glancing=false) {
if (!this.isHidden() || (glancing && this.isGlancing())) {
return;
}
this.intervalId && clearInterval(this.intervalId);
await this.update(true);
this.$container.classList.remove('bx-gone');
this.$container.dataset.display = glancing ? 'glancing' : 'fixed';
this.intervalId = window.setInterval(this.update, this.REFRESH_INTERVAL);
}
async stop(glancing=false) {
if (glancing && !this.isGlancing()) {
return;
}
this.intervalId && clearInterval(this.intervalId);
this.intervalId = null;
this.$container.removeAttribute('data-display');
this.$container.classList.add('bx-gone');
}
async toggle() {
if (this.isGlancing()) {
this.$container && (this.$container.dataset.display = 'fixed');
} else {
this.isHidden() ? await this.start() : await this.stop();
}
}
destroy() {
this.stop();
this.quickGlanceStop();
this.hideSettingsUi();
}
isHidden = () => this.$container.classList.contains('bx-gone');
isGlancing = () => this.$container.dataset.display === 'glancing';
quickGlanceSetup() {
if (!STATES.isPlaying || this.quickGlanceObserver) {
return;
}
const $uiContainer = document.querySelector('div[data-testid=ui-container]')!;
if (!$uiContainer) {
return;
}
this.quickGlanceObserver = new MutationObserver((mutationList, observer) => {
for (const record of mutationList) {
const $target = record.target as HTMLElement;
if (!$target.className || !$target.className.startsWith('GripHandle')) {
continue;
}
const expanded = (record.target as HTMLElement).ariaExpanded;
if (expanded === 'true') {
this.isHidden() && this.start(true);
} else {
this.stop(true);
}
}
});
this.quickGlanceObserver.observe($uiContainer, {
attributes: true,
attributeFilter: ['aria-expanded'],
subtree: true,
});
}
quickGlanceStop() {
this.quickGlanceObserver && this.quickGlanceObserver.disconnect();
this.quickGlanceObserver = null;
}
private update = async (forceUpdate=false) => {
if ((!forceUpdate && this.isHidden()) || !STATES.currentStream.peerConnection) {
this.destroy();
return;
}
const PREF_STATS_CONDITIONAL_FORMATTING = getPref(PrefKey.STATS_CONDITIONAL_FORMATTING);
let grade: StreamStatGrade = '';
// Collect stats
const statsCollector = StreamStatsCollector.getInstance();
await statsCollector.collect();
let statKey: keyof typeof this.stats;
for (statKey in this.stats) {
grade = '';
const stat = this.stats[statKey];
const value = statsCollector.getStat(statKey);
const $element = stat.$element;
$element.textContent = value.toString();
// Get stat's grade
if (PREF_STATS_CONDITIONAL_FORMATTING && 'grades' in value) {
grade = statsCollector.calculateGrade(value.current, value.grades);
}
if ($element.dataset.grade !== grade) {
$element.dataset.grade = grade;
}
}
}
refreshStyles() {
const PREF_ITEMS = getPref<StreamStat[]>(PrefKey.STATS_ITEMS);
const $container = this.$container;
$container.dataset.stats = '[' + PREF_ITEMS.join('][') + ']';
$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() {
if (this.isGlancing() && !getPref(PrefKey.STATS_QUICK_GLANCE_ENABLED)) {
this.stop();
}
}
private async render() {
this.$container = CE('div', { class: 'bx-stats-bar bx-gone' });
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: stat.name,
},
CE('label', {}, statKey.toUpperCase()),
stat.$element,
);
this.$container.appendChild($div);
}
this.refreshStyles();
document.documentElement.appendChild(this.$container);
}
static setupEvents() {
BxEventBus.Stream.on('state.playing', () => {
const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE_ENABLED);
const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING);
const streamStats = StreamStats.getInstance();
// Setup Stat's Quick Glance mode
if (PREF_STATS_SHOW_WHEN_PLAYING) {
streamStats.start();
} else if (PREF_STATS_QUICK_GLANCE) {
streamStats.quickGlanceSetup();
// Show stats bar
!PREF_STATS_SHOW_WHEN_PLAYING && streamStats.start(true);
}
});
}
static refreshStyles() {
StreamStats.getInstance().refreshStyles();
}
}