mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-06-06 07:37:19 +02:00
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { isLiteVersion } from "@macros/build" with {type: "macro"};
|
|
|
|
import { t } from "@utils/translation";
|
|
import { BxEvent } from "@utils/bx-event";
|
|
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?: {
|
|
region?: string,
|
|
},
|
|
|
|
video?: {
|
|
width: number,
|
|
height: number,
|
|
codec: string,
|
|
profile?: string,
|
|
},
|
|
|
|
audio?: {
|
|
codec: string,
|
|
bitrate: number,
|
|
},
|
|
};
|
|
|
|
enum StreamBadge {
|
|
PLAYTIME = 'playtime',
|
|
BATTERY = 'battery',
|
|
DOWNLOAD = 'download',
|
|
UPLOAD = 'upload',
|
|
|
|
SERVER = 'server',
|
|
VIDEO = 'video',
|
|
AUDIO = 'audio',
|
|
}
|
|
|
|
|
|
export class StreamBadges {
|
|
private static instance: StreamBadges;
|
|
public static getInstance = () => StreamBadges.instance ?? (StreamBadges.instance = new StreamBadges());
|
|
private readonly LOG_TAG = 'StreamBadges';
|
|
|
|
private serverInfo: StreamServerInfo = {};
|
|
|
|
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',
|
|
},
|
|
};
|
|
|
|
private $container: HTMLElement | undefined;
|
|
|
|
private intervalId?: number | null;
|
|
private readonly REFRESH_INTERVAL = 3 * 1000;
|
|
|
|
private constructor() {
|
|
BxLogger.info(this.LOG_TAG, 'constructor()');
|
|
}
|
|
|
|
setRegion(region: string) {
|
|
this.serverInfo.server = {
|
|
region: region,
|
|
};
|
|
}
|
|
|
|
renderBadge(name: StreamBadge, value: string) {
|
|
const badgeInfo = this.badges[name];
|
|
|
|
let $badge;
|
|
if (badgeInfo.$element) {
|
|
$badge = badgeInfo.$element;
|
|
$badge.lastElementChild!.textContent = value;
|
|
return $badge;
|
|
}
|
|
|
|
$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.badges[name].$element = $badge;
|
|
return $badge;
|
|
}
|
|
|
|
private async updateBadges(forceUpdate = false) {
|
|
if (!this.$container || (!forceUpdate && !this.$container.isConnected)) {
|
|
this.stop();
|
|
return;
|
|
}
|
|
|
|
const statsCollector = StreamStatsCollector.getInstance();
|
|
await statsCollector.collect();
|
|
|
|
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]: dl.toString(),
|
|
[StreamBadge.UPLOAD]: ul.toString(),
|
|
[StreamBadge.PLAYTIME]: play.toString(),
|
|
[StreamBadge.BATTERY]: batt.toString(),
|
|
};
|
|
|
|
let name: keyof typeof badges;
|
|
for (name in badges) {
|
|
const value = badges[name];
|
|
if (value === null) {
|
|
continue;
|
|
}
|
|
|
|
const $elm = this.badges[name].$element;
|
|
if (!$elm) {
|
|
continue;
|
|
}
|
|
|
|
$elm.lastElementChild!.textContent = value;
|
|
|
|
if (name === StreamBadge.BATTERY) {
|
|
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 = batt.isCharging.toString();
|
|
$elm.classList.remove('bx-gone');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async start() {
|
|
await this.updateBadges(true);
|
|
this.stop();
|
|
this.intervalId = window.setInterval(this.updateBadges.bind(this), this.REFRESH_INTERVAL);
|
|
}
|
|
|
|
private stop() {
|
|
this.intervalId && clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
|
|
destroy() {
|
|
this.serverInfo = {};
|
|
delete this.$container;
|
|
}
|
|
|
|
async render() {
|
|
if (this.$container) {
|
|
this.start();
|
|
return this.$container;
|
|
}
|
|
|
|
await this.getServerStats();
|
|
|
|
// Battery
|
|
let batteryLevel = '';
|
|
if (STATES.browser.capabilities.batteryApi) {
|
|
batteryLevel = '100%';
|
|
}
|
|
|
|
const BADGES = [
|
|
[StreamBadge.PLAYTIME, '1m'],
|
|
[StreamBadge.BATTERY, batteryLevel],
|
|
[StreamBadge.DOWNLOAD, humanFileSize(0)],
|
|
[StreamBadge.UPLOAD, humanFileSize(0)],
|
|
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'});
|
|
|
|
for (const item of BADGES) {
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
|
|
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();
|
|
|
|
return $container;
|
|
}
|
|
|
|
private async getServerStats() {
|
|
const stats = await STATES.currentStream.peerConnection!.getStats();
|
|
|
|
const allVideoCodecs: Record<string, RTCBasicStat> = {};
|
|
let videoCodecId;
|
|
let videoWidth = 0;
|
|
let videoHeight = 0;
|
|
|
|
const allAudioCodecs: Record<string, RTCBasicStat> = {};
|
|
let audioCodecId;
|
|
|
|
const allCandidates: Record<string, string> = {};
|
|
let candidateId;
|
|
|
|
stats.forEach((stat: RTCBasicStat) => {
|
|
if (stat.type === 'codec') {
|
|
const mimeType = stat.mimeType.split('/')[0];
|
|
if (mimeType === 'video') {
|
|
// Store all video stats
|
|
allVideoCodecs[stat.id] = stat;
|
|
} else if (mimeType === 'audio') {
|
|
// Store all audio stats
|
|
allAudioCodecs[stat.id] = stat;
|
|
}
|
|
} else if (stat.type === 'inbound-rtp' && stat.packetsReceived > 0) {
|
|
// 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;
|
|
}
|
|
} else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') {
|
|
candidateId = stat.remoteCandidateId;
|
|
} else if (stat.type === 'remote-candidate') {
|
|
allCandidates[stat.id] = stat.address;
|
|
}
|
|
});
|
|
|
|
// Get video codec from codecId
|
|
if (videoCodecId) {
|
|
const videoStat = allVideoCodecs[videoCodecId];
|
|
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);
|
|
match && (video.profile = match[1]);
|
|
}
|
|
|
|
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];
|
|
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);
|
|
|
|
// Server + Region
|
|
let text = '';
|
|
const isIpv6 = allCandidates[candidateId].includes(':');
|
|
|
|
const server = this.serverInfo.server;
|
|
if (server && server.region) {
|
|
text += server.region;
|
|
}
|
|
|
|
text += '@' + (isIpv6 ? 'IPv6' : 'IPv4');
|
|
this.badges.server.$element = this.renderBadge(StreamBadge.SERVER, text);
|
|
}
|
|
}
|
|
|
|
static setupEvents() {
|
|
// 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 => {
|
|
const where = (e as any).where as GuideMenuTab;
|
|
|
|
if (where !== GuideMenuTab.HOME || !STATES.isPlaying) {
|
|
return;
|
|
}
|
|
|
|
const $btnQuit = document.querySelector('#gamepass-dialog-root a[class*=QuitGameButton]');
|
|
if ($btnQuit) {
|
|
// Add badges
|
|
$btnQuit.insertAdjacentElement('beforebegin', await StreamBadges.getInstance().render());
|
|
}
|
|
});
|
|
}
|
|
}
|