diff --git a/src/assets/css/stream-stats.styl b/src/assets/css/stream-stats.styl index 5d523c8..b0107ee 100644 --- a/src/assets/css/stream-stats.styl +++ b/src/assets/css/stream-stats.styl @@ -1,6 +1,5 @@ /* STATS BADGE */ .bx-badges { - position: absolute; margin-left: 0px; user-select: none; -webkit-user-select: none; @@ -17,27 +16,60 @@ margin: 0 8px 8px 0; box-shadow: 0px 0px 6px #000; border-radius: 4px; + height: 30px; } .bx-badge-name { background-color: #2d3036; - display: inline-block; - padding: 2px 8px; border-radius: 4px 0 0 4px; - text-transform: uppercase; + + svg { + width: 16px; + height: 16px; + } } .bx-badge-value { background-color: grey; - display: inline-block; - padding: 2px 8px; border-radius: 0 4px 4px 0; } +.bx-badge-name, .bx-badge-value { + display: inline-block; + padding: 0 8px; + height: 30px; + line-height: 30px; + vertical-align: bottom; +} + .bx-badge-battery[data-charging=true] span:first-of-type::after { content: ' ⚡️'; } +div[class^=StreamMenu-module__container] .bx-badges { + position: absolute; + max-width: 500px; +} + +#gamepass-dialog-root .bx-badges { + position: fixed; + top: 140px; + left: 460px; + max-width: 500px; + + @media (min-width: 568px) and (max-height: 480px) { + position: unset; + top: unset; + left: unset; + margin: 8px 0; + } + + @media (min-width: 480px) and (min-height: calc(481px)) { + + } +} + + /* STATS BAR */ .bx-stats-bar { display: block; diff --git a/src/assets/svg/battery-full.svg b/src/assets/svg/battery-full.svg new file mode 100644 index 0000000..c3cab03 --- /dev/null +++ b/src/assets/svg/battery-full.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/clock.svg b/src/assets/svg/clock.svg new file mode 100644 index 0000000..3d9fc0f --- /dev/null +++ b/src/assets/svg/clock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svg/cloud.svg b/src/assets/svg/cloud.svg new file mode 100644 index 0000000..6cc60bf --- /dev/null +++ b/src/assets/svg/cloud.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svg/download.svg b/src/assets/svg/download.svg new file mode 100644 index 0000000..ac616fd --- /dev/null +++ b/src/assets/svg/download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svg/speaker-high.svg b/src/assets/svg/speaker-high.svg new file mode 100644 index 0000000..2f0f5f8 --- /dev/null +++ b/src/assets/svg/speaker-high.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svg/upload.svg b/src/assets/svg/upload.svg new file mode 100644 index 0000000..6004a78 --- /dev/null +++ b/src/assets/svg/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/index.ts b/src/index.ts index dbebc2c..b1aafee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import "@utils/global"; -import { BxEvent } from "@utils/bx-event"; +import { BxEvent, XcloudGuideWhere } from "@utils/bx-event"; import { BX_FLAGS } from "@utils/bx-flags"; import { BxExposed } from "@utils/bx-exposed"; import { t } from "@utils/translation"; @@ -185,7 +185,7 @@ window.addEventListener(BxEvent.STREAM_STOPPED, e => { STATES.currentStream.audioGainNode = null; STATES.currentStream.$video = null; - StreamStats.onStoppedPlaying(); + StreamStats.getInstance().onStoppedPlaying(); MouseCursorHider.stop(); TouchController.reset(); @@ -206,6 +206,25 @@ function observeRootDialog($root: HTMLElement) { continue; } + if (mutation.addedNodes.length === 1) { + const $addedElm = mutation.addedNodes[0]; + if ($addedElm instanceof HTMLElement && $addedElm.className) { + if ($addedElm.className.startsWith('NavigationAnimation') || $addedElm.className.startsWith('DialogRoutes') || $addedElm.className.startsWith('Dialog-module__container')) { + // Find navigation bar + const $selectedTab = $addedElm.querySelector('div[class^=NavigationMenu] button[aria-selected=true'); + if ($selectedTab) { + let $elm: Element | null = $selectedTab; + let index; + for (index = 0; ($elm = $elm?.previousElementSibling); index++); + + if (index === 0) { + BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_SHOWN, {where: XcloudGuideWhere.HOME}); + } + } + } + } + } + const shown = ($root.firstElementChild && $root.firstElementChild.childElementCount > 0) || false; if (shown !== currentShown) { currentShown = shown; diff --git a/src/modules/controller-shortcut.ts b/src/modules/controller-shortcut.ts index cbfba39..5d2bed8 100644 --- a/src/modules/controller-shortcut.ts +++ b/src/modules/controller-shortcut.ts @@ -87,7 +87,7 @@ export class ControllerShortcut { break; case ShortcutAction.STREAM_STATS_TOGGLE: - StreamStats.toggle(); + StreamStats.getInstance().toggle(); break; case ShortcutAction.STREAM_MICROPHONE_TOGGLE: diff --git a/src/modules/stream/stream-badges.ts b/src/modules/stream/stream-badges.ts index 8c1f1f3..9a9d569 100644 --- a/src/modules/stream/stream-badges.ts +++ b/src/modules/stream/stream-badges.ts @@ -1,71 +1,91 @@ import { t } from "@utils/translation"; -import { BxEvent } from "@utils/bx-event"; -import { CE } from "@utils/html"; +import { BxEvent, XcloudGuideWhere } from "@utils/bx-event"; +import { CE, createSvgIcon } from "@utils/html"; import { STATES } from "@utils/global"; +import { BxLogger } from "@/utils/bx-logger"; +import { BxIcon } from "@/utils/bx-icon"; enum StreamBadge { PLAYTIME = 'playtime', BATTERY = 'battery', - IN = 'in', - OUT = 'out', + DOWNLOAD = 'in', + UPLOAD = 'out', SERVER = 'server', VIDEO = 'video', AUDIO = 'audio', +} - BREAK = 'break', +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 { - static ipv6 = false; - static resolution?: {width: number, height: number} | null = null; - static video?: {codec: string, profile?: string | null} | null = null; - static audio?: {codec: string, bitrate: number} | null = null; - static fps = 0; - static region = ''; - - static startBatteryLevel = 100; - static startTimestamp = 0; - - static #cachedDoms: {[index: string]: HTMLElement} = {}; - - static #interval?: number | null; - static readonly #REFRESH_INTERVAL = 3000; - - static #renderBadge(name: StreamBadge, value: string, color: string) { - if (name === StreamBadge.BREAK) { - return CE('div', {'style': 'display: block'}); + private static instance: StreamBadges; + public static getInstance(): StreamBadges { + if (!StreamBadges.instance) { + StreamBadges.instance = new 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 = ''; + + startBatteryLevel = 100; + startTimestamp = 0; + + #$container: HTMLElement | undefined; + #cachedDoms: Partial<{[key in StreamBadge]: HTMLElement}> = {}; + + #interval?: number | null; + readonly #REFRESH_INTERVAL = 3000; + + setRegion(region: string) { + this.#region = region; + } + + #renderBadge(name: StreamBadge, value: string, color: string) { let $badge; - if (StreamBadges.#cachedDoms[name]) { - $badge = StreamBadges.#cachedDoms[name]; + if (this.#cachedDoms[name]) { + $badge = this.#cachedDoms[name]!; $badge.lastElementChild!.textContent = value; return $badge; } - $badge = CE('div', {'class': 'bx-badge'}, - CE('span', {'class': 'bx-badge-name'}, t(`badge-${name}`)), - CE('span', {'class': 'bx-badge-value', 'style': `background-color: ${color}`}, value)); + $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), + ); if (name === StreamBadge.BATTERY) { $badge.classList.add('bx-badge-battery'); } - StreamBadges.#cachedDoms[name] = $badge; + this.#cachedDoms[name] = $badge; return $badge; } - static async #updateBadges(forceUpdate: boolean) { - if (!forceUpdate && !document.querySelector('.bx-badges')) { - StreamBadges.#stop(); + async #updateBadges(forceUpdate = false) { + if (!this.#$container || (!forceUpdate && !this.#$container.isConnected)) { + this.#stop(); return; } // Playtime let now = +new Date; - const diffSeconds = Math.ceil((now - StreamBadges.startTimestamp) / 1000); - const playtime = StreamBadges.#secondsToHm(diffSeconds); + const diffSeconds = Math.ceil((now - this.startTimestamp) / 1000); + const playtime = this.#secondsToHm(diffSeconds); // Battery let batteryLevel = '100%'; @@ -78,8 +98,8 @@ export class StreamBadges { batteryLevelInt = Math.round(bm.level * 100); batteryLevel = `${batteryLevelInt}%`; - if (batteryLevelInt != StreamBadges.startBatteryLevel) { - const diffLevel = Math.round(batteryLevelInt - StreamBadges.startBatteryLevel); + if (batteryLevelInt != this.startBatteryLevel) { + const diffLevel = Math.round(batteryLevelInt - this.startBatteryLevel); const sign = diffLevel > 0 ? '+' : ''; batteryLevel += ` (${sign}${diffLevel}%)`; } @@ -97,8 +117,8 @@ export class StreamBadges { }); const badges = { - [StreamBadge.IN]: totalIn ? StreamBadges.#humanFileSize(totalIn) : null, - [StreamBadge.OUT]: totalOut ? StreamBadges.#humanFileSize(totalOut) : null, + [StreamBadge.DOWNLOAD]: totalIn ? this.#humanFileSize(totalIn) : null, + [StreamBadge.UPLOAD]: totalOut ? this.#humanFileSize(totalOut) : null, [StreamBadge.PLAYTIME]: playtime, [StreamBadge.BATTERY]: batteryLevel, }; @@ -110,28 +130,34 @@ export class StreamBadges { continue; } - const $elm = StreamBadges.#cachedDoms[name]; + const $elm = this.#cachedDoms[name]!; $elm && ($elm.lastElementChild!.textContent = value); if (name === StreamBadge.BATTERY) { - // Show charging status - $elm.setAttribute('data-charging', isCharging.toString()); - - if (StreamBadges.startBatteryLevel === 100 && batteryLevelInt === 100) { - $elm.style.display = 'none'; + if (this.startBatteryLevel === 100 && batteryLevelInt === 100) { + // Hide battery badge when the battery is 100% + $elm.classList.add('bx-gone'); } else { - $elm.removeAttribute('style'); + // Show charging status + $elm.dataset.charging = isCharging.toString() + $elm.classList.remove('bx-gone'); } } } } - static #stop() { - StreamBadges.#interval && clearInterval(StreamBadges.#interval); - StreamBadges.#interval = null; + async #start() { + await this.#updateBadges(true); + this.#stop(); + this.#interval = window.setInterval(this.#updateBadges.bind(this), this.#REFRESH_INTERVAL); } - static #secondsToHm(seconds: number) { + #stop() { + this.#interval && clearInterval(this.#interval); + this.#interval = null; + } + + #secondsToHm(seconds: number) { const h = Math.floor(seconds / 3600); const m = Math.floor(seconds % 3600 / 60) + 1; @@ -141,25 +167,32 @@ export class StreamBadges { } // https://stackoverflow.com/a/20732091 - static #humanFileSize(size: number) { - const units = ['B', 'kB', 'MB', 'GB', 'TB']; + #humanFileSize(size: number) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); return (size / Math.pow(1024, i)).toFixed(2) + ' ' + units[i]; } - static async render() { - // Video - let video = ''; - if (StreamBadges.resolution) { - video = `${StreamBadges.resolution.height}p`; + async render() { + if (this.#$container) { + this.#start(); + return this.#$container; } - if (StreamBadges.video) { + await this.#getServerStats(); + + // Video + let video = ''; + if (this.#resolution) { + video = `${this.#resolution.height}p`; + } + + if (this.#video) { video && (video += '/'); - video += StreamBadges.video.codec; - if (StreamBadges.video.profile) { - const profile = StreamBadges.video.profile; + video += this.#video.codec; + if (this.#video.profile) { + const profile = this.#video.profile; let quality = profile; if (profile.startsWith('4d')) { @@ -176,9 +209,9 @@ export class StreamBadges { // Audio let audio; - if (StreamBadges.audio) { - audio = StreamBadges.audio.codec; - const bitrate = StreamBadges.audio.bitrate / 1000; + if (this.#audio) { + audio = this.#audio.codec; + const bitrate = this.#audio.bitrate / 1000; audio += ` (${bitrate} kHz)`; } @@ -189,53 +222,133 @@ export class StreamBadges { } // Server + Region - let server = StreamBadges.region; - server += '@' + (StreamBadges.ipv6 ? 'IPv6' : 'IPv4'); + let server = this.#region; + server += '@' + (this.#ipv6 ? 'IPv6' : 'IPv4'); const BADGES = [ [StreamBadge.PLAYTIME, '1m', '#ff004d'], [StreamBadge.BATTERY, batteryLevel, '#00b543'], - [StreamBadge.IN, StreamBadges.#humanFileSize(0), '#29adff'], - [StreamBadge.OUT, StreamBadges.#humanFileSize(0), '#ff77a8'], - [StreamBadge.BREAK], + [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, ]; - const $wrapper = CE('div', {'class': 'bx-badges'}); + const $container = CE('div', {'class': 'bx-badges'}); BADGES.forEach(item => { if (!item) { return; } - const $badge = StreamBadges.#renderBadge(...(item as [StreamBadge, string, string])); - $wrapper.appendChild($badge); + const $badge = this.#renderBadge(...(item as [StreamBadge, string, string])); + $container.appendChild($badge); }); - await StreamBadges.#updateBadges(true); - StreamBadges.#stop(); - StreamBadges.#interval = window.setInterval(StreamBadges.#updateBadges, StreamBadges.#REFRESH_INTERVAL); + this.#$container = $container; + await this.#start(); - return $wrapper; + return $container; + } + + async #getServerStats() { + const stats = await STATES.currentStream.peerConnection!.getStats(); + + const allVideoCodecs: {[index: string]: RTCBasicStat} = {}; + let videoCodecId; + + const allAudioCodecs: {[index: string]: RTCBasicStat} = {}; + let audioCodecId; + + const allCandidates: {[index: 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; + } 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: any = { + 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; + } + + this.#video = video; + } + + // Get audio codec from codecId + if (audioCodecId) { + const audioStat = allAudioCodecs[audioCodecId]; + this.#audio = { + codec: audioStat.mimeType.substring(6), + bitrate: audioStat.clockRate, + } + } + + // Get server type + if (candidateId) { + BxLogger.info('candidate', candidateId, allCandidates); + this.#ipv6 = allCandidates[candidateId].includes(':'); + } } static setupEvents() { window.addEventListener(BxEvent.STREAM_PLAYING, e => { const $video = (e as any).$video; + const streamBadges = StreamBadges.getInstance(); - StreamBadges.resolution = { + streamBadges.#resolution = { width: $video.videoWidth, - height: $video.videoHeight + height: $video.videoHeight, }; - StreamBadges.startTimestamp = +new Date; + streamBadges.startTimestamp = +new Date; // Get battery level try { 'getBattery' in navigator && (navigator as NavigatorBattery).getBattery().then(bm => { - StreamBadges.startBatteryLevel = Math.round(bm.level * 100); + streamBadges.startBatteryLevel = Math.round(bm.level * 100); }); } catch(e) {} }); + + window.addEventListener(BxEvent.XCLOUD_GUIDE_SHOWN, async e => { + const where = (e as any).where as XcloudGuideWhere; + + if (where === XcloudGuideWhere.HOME && STATES.isPlaying) { + const $btnQuit = document.querySelector('#gamepass-dialog-root a[class*=QuitGameButton]'); + if (!$btnQuit) { + return; + } + + // Add badges + $btnQuit.insertAdjacentElement('beforebegin', await StreamBadges.getInstance().render()); + } + }); } } diff --git a/src/modules/stream/stream-stats.ts b/src/modules/stream/stream-stats.ts index 7a6dc4d..f368efe 100644 --- a/src/modules/stream/stream-stats.ts +++ b/src/modules/stream/stream-stats.ts @@ -1,11 +1,9 @@ import { PrefKey } from "@utils/preferences" import { BxEvent } from "@utils/bx-event" import { getPref } from "@utils/preferences" -import { StreamBadges } from "./stream-badges" import { CE } from "@utils/html" import { t } from "@utils/translation" import { STATES } from "@utils/global" -import { BxLogger } from "@utils/bx-logger" export enum StreamStat { PING = 'ping', @@ -17,286 +15,254 @@ export enum StreamStat { }; export class StreamStats { - static #interval?: number | null; - static #updateInterval = 1000; + private static instance: StreamStats; + public static getInstance(): StreamStats { + if (!StreamStats.instance) { + StreamStats.instance = new StreamStats(); + } - static #$container: HTMLElement; - static #$fps: HTMLElement; - static #$ping: HTMLElement; - static #$dt: HTMLElement; - static #$pl: HTMLElement; - static #$fl: HTMLElement; - static #$br: HTMLElement; + return StreamStats.instance; + } - static #lastStat?: RTCBasicStat | null; + #timeoutId?: number | null; + readonly #updateInterval = 1000; - static #quickGlanceObserver?: MutationObserver | null; + #$container: HTMLElement | undefined; + #$fps: HTMLElement | undefined; + #$ping: HTMLElement | undefined; + #$dt: HTMLElement | undefined; + #$pl: HTMLElement | undefined; + #$fl: HTMLElement | undefined; + #$br: HTMLElement | undefined; - static start(glancing=false) { - if (!StreamStats.isHidden() || (glancing && StreamStats.isGlancing())) { + #lastVideoStat?: RTCBasicStat | null; + + #quickGlanceObserver?: MutationObserver | null; + + start(glancing=false) { + if (!this.isHidden() || (glancing && this.isGlancing())) { return; } - StreamStats.#$container.classList.remove('bx-gone'); - StreamStats.#$container.setAttribute('data-display', glancing ? 'glancing' : 'fixed'); + if (this.#$container) { + this.#$container.classList.remove('bx-gone'); + this.#$container.dataset.display = glancing ? 'glancing' : 'fixed'; + } - StreamStats.#interval = window.setInterval(StreamStats.update, StreamStats.#updateInterval); + this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval); } - static stop(glancing=false) { - if (glancing && !StreamStats.isGlancing()) { + stop(glancing=false) { + if (glancing && !this.isGlancing()) { return; } - StreamStats.#interval && clearInterval(StreamStats.#interval); - StreamStats.#interval = null; - StreamStats.#lastStat = null; + this.#timeoutId && clearTimeout(this.#timeoutId); + this.#timeoutId = null; + this.#lastVideoStat = null; - if (StreamStats.#$container) { - StreamStats.#$container.removeAttribute('data-display'); - StreamStats.#$container.classList.add('bx-gone'); + if (this.#$container) { + this.#$container.removeAttribute('data-display'); + this.#$container.classList.add('bx-gone'); } } - static toggle() { - if (StreamStats.isGlancing()) { - StreamStats.#$container.setAttribute('data-display', 'fixed'); + toggle() { + if (this.isGlancing()) { + this.#$container && (this.#$container.dataset.display = 'fixed'); } else { - StreamStats.isHidden() ? StreamStats.start() : StreamStats.stop(); + this.isHidden() ? this.start() : this.stop(); } } - static onStoppedPlaying() { - StreamStats.stop(); - StreamStats.quickGlanceStop(); - StreamStats.hideSettingsUi(); + onStoppedPlaying() { + this.stop(); + this.quickGlanceStop(); + this.hideSettingsUi(); } - static isHidden = () => StreamStats.#$container && StreamStats.#$container.classList.contains('bx-gone'); - static isGlancing = () => StreamStats.#$container && StreamStats.#$container.getAttribute('data-display') === 'glancing'; + isHidden = () => this.#$container && this.#$container.classList.contains('bx-gone'); + isGlancing = () => this.#$container && this.#$container.dataset.display === 'glancing'; - static quickGlanceSetup() { - if (StreamStats.#quickGlanceObserver) { + quickGlanceSetup() { + if (this.#quickGlanceObserver) { return; } const $uiContainer = document.querySelector('div[data-testid=ui-container]')!; - StreamStats.#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; if (expanded === 'true') { - StreamStats.isHidden() && StreamStats.start(true); + this.isHidden() && this.start(true); } else { - StreamStats.stop(true); + this.stop(true); } } } }); - StreamStats.#quickGlanceObserver.observe($uiContainer, { + this.#quickGlanceObserver.observe($uiContainer, { attributes: true, attributeFilter: ['aria-expanded'], subtree: true, }); } - static quickGlanceStop() { - StreamStats.#quickGlanceObserver && StreamStats.#quickGlanceObserver.disconnect(); - StreamStats.#quickGlanceObserver = null; + quickGlanceStop() { + this.#quickGlanceObserver && this.#quickGlanceObserver.disconnect(); + this.#quickGlanceObserver = null; } - static update() { - if (StreamStats.isHidden() || !STATES.currentStream.peerConnection) { - StreamStats.onStoppedPlaying(); + async #update() { + if (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); - STATES.currentStream.peerConnection.getStats().then(stats => { - stats.forEach(stat => { - let grade = ''; - if (stat.type === 'inbound-rtp' && stat.kind === 'video') { - // FPS - StreamStats.#$fps.textContent = stat.framesPerSecond || 0; - // Packets Lost - const packetsLost = stat.packetsLost; - const packetsReceived = stat.packetsReceived; - const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2); - StreamStats.#$pl.textContent = packetsLostPercentage === '0.00' ? packetsLost : `${packetsLost} (${packetsLostPercentage}%)`; + const stats = await STATES.currentStream.peerConnection.getStats(); + let grade = ''; - // Frames Dropped - const framesDropped = stat.framesDropped; - const framesReceived = stat.framesReceived; - const framesDroppedPercentage = (framesDropped * 100 / ((framesDropped + framesReceived) || 1)).toFixed(2); - StreamStats.#$fl.textContent = framesDroppedPercentage === '0.00' ? framesDropped : `${framesDropped} (${framesDroppedPercentage}%)`; + stats.forEach(stat => { + if (stat.type === 'inbound-rtp' && stat.kind === 'video') { + // FPS + this.#$fps!.textContent = stat.framesPerSecond || 0; - if (StreamStats.#lastStat) { - const lastStat = StreamStats.#lastStat; - // Bitrate - const timeDiff = stat.timestamp - lastStat.timestamp; - const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000; - StreamStats.#$br.textContent = `${bitrate.toFixed(2)} Mbps`; + // Packets Lost + const packetsLost = stat.packetsLost; + const packetsReceived = stat.packetsReceived; + const packetsLostPercentage = (packetsLost * 100 / ((packetsLost + packetsReceived) || 1)).toFixed(2); + this.#$pl!.textContent = packetsLostPercentage === '0.00' ? packetsLost : `${packetsLost} (${packetsLostPercentage}%)`; - // Decode time - const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime; - const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; - const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000; - StreamStats.#$dt.textContent = `${currentDecodeTime.toFixed(2)}ms`; + // 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 (PREF_STATS_CONDITIONAL_FORMATTING) { - grade = (currentDecodeTime > 12) ? 'bad' : (currentDecodeTime > 9) ? 'ok' : (currentDecodeTime > 6) ? 'good' : ''; - } - StreamStats.#$dt.setAttribute('data-grade', grade); - } - - StreamStats.#lastStat = stat; - } else if (stat.type === 'candidate-pair' && stat.packetsReceived > 0 && stat.state === 'succeeded') { - // Round Trip Time - const roundTripTime = typeof stat.currentRoundTripTime !== 'undefined' ? stat.currentRoundTripTime * 1000 : -1; - StreamStats.#$ping.textContent = roundTripTime === -1 ? '???' : roundTripTime.toString(); - - if (PREF_STATS_CONDITIONAL_FORMATTING) { - grade = (roundTripTime > 100) ? 'bad' : (roundTripTime > 75) ? 'ok' : (roundTripTime > 40) ? 'good' : ''; - } - StreamStats.#$ping.setAttribute('data-grade', grade); + 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; + 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; + this.#timeoutId = window.setTimeout(this.#update.bind(this), this.#updateInterval - lapsedTime); } - static refreshStyles() { + 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 = StreamStats.#$container; - $container.setAttribute('data-stats', '[' + PREF_ITEMS.join('][') + ']'); - $container.setAttribute('data-position', PREF_POSITION); - $container.setAttribute('data-transparent', PREF_TRANSPARENT); + 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; } - static hideSettingsUi() { - if (StreamStats.isGlancing() && !getPref(PrefKey.STATS_QUICK_GLANCE)) { - StreamStats.stop(); + hideSettingsUi() { + if (this.isGlancing() && !getPref(PrefKey.STATS_QUICK_GLANCE)) { + this.stop(); } } - static render() { - if (StreamStats.#$container) { + #render() { + if (this.#$container) { return; } - const STATS = { - [StreamStat.PING]: [t('stat-ping'), StreamStats.#$ping = CE('span', {}, '0')], - [StreamStat.FPS]: [t('stat-fps'), StreamStats.#$fps = CE('span', {}, '0')], - [StreamStat.BITRATE]: [t('stat-bitrate'), StreamStats.#$br = CE('span', {}, '0 Mbps')], - [StreamStat.DECODE_TIME]: [t('stat-decode-time'), StreamStats.#$dt = CE('span', {}, '0ms')], - [StreamStat.PACKETS_LOST]: [t('stat-packets-lost'), StreamStats.#$pl = CE('span', {}, '0')], - [StreamStat.FRAMES_LOST]: [t('stat-frames-lost'), StreamStats.#$fl = CE('span', {}, '0')], + 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')], }; const $barFragment = document.createDocumentFragment(); - let statKey: keyof typeof STATS - for (statKey in STATS) { - const $div = CE('div', {'class': `bx-stat-${statKey}`, title: STATS[statKey][0]}, CE('label', {}, statKey.toUpperCase()), STATS[statKey][1]); + let statKey: keyof typeof stats; + for (statKey in stats) { + const $div = CE('div', { + 'class': `bx-stat-${statKey}`, + title: stats[statKey][0] + }, + CE('label', {}, statKey.toUpperCase()), + stats[statKey][1], + ); + $barFragment.appendChild($div); } - StreamStats.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment); - document.documentElement.appendChild(StreamStats.#$container); + this.#$container = CE('div', {'class': 'bx-stats-bar bx-gone'}, $barFragment); + this.refreshStyles(); - StreamStats.refreshStyles(); - } - - static getServerStats() { - STATES.currentStream.peerConnection && STATES.currentStream.peerConnection.getStats().then(stats => { - const allVideoCodecs: {[index: string]: RTCBasicStat} = {}; - let videoCodecId; - - const allAudioCodecs: {[index: string]: RTCBasicStat} = {}; - let audioCodecId; - - const allCandidates: {[index: string]: string} = {}; - let candidateId; - - stats.forEach((stat: RTCBasicStat) => { - if (stat.type === 'codec') { - const mimeType = stat.mimeType.split('/'); - if (mimeType[0] === 'video') { - // Store all video stats - allVideoCodecs[stat.id] = stat; - } else if (mimeType[0] === '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; - } 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: typeof StreamBadges.video = { - 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; - } - - StreamBadges.video = video; - } - - // Get audio codec from codecId - if (audioCodecId) { - const audioStat = allAudioCodecs[audioCodecId]; - StreamBadges.audio = { - codec: audioStat.mimeType.substring(6), - bitrate: audioStat.clockRate, - } - } - - // Get server type - if (candidateId) { - BxLogger.info('candidate', candidateId, allCandidates); - StreamBadges.ipv6 = allCandidates[candidateId].includes(':'); - } - - if (getPref(PrefKey.STATS_SHOW_WHEN_PLAYING)) { - StreamStats.start(); - } - }); + document.documentElement.appendChild(this.#$container!); } static setupEvents() { + window.addEventListener(BxEvent.STREAM_LOADING, e => { + StreamStats.getInstance().#render(); + }); + window.addEventListener(BxEvent.STREAM_PLAYING, e => { const PREF_STATS_QUICK_GLANCE = getPref(PrefKey.STATS_QUICK_GLANCE); const PREF_STATS_SHOW_WHEN_PLAYING = getPref(PrefKey.STATS_SHOW_WHEN_PLAYING); - StreamStats.getServerStats(); + const streamStats = StreamStats.getInstance(); // Setup Stat's Quick Glance mode - if (PREF_STATS_QUICK_GLANCE) { - StreamStats.quickGlanceSetup(); + + 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); + !PREF_STATS_SHOW_WHEN_PLAYING && streamStats.start(true); } }); } + + static refreshStyles() { + StreamStats.getInstance().refreshStyles(); + } } diff --git a/src/modules/stream/stream-ui.ts b/src/modules/stream/stream-ui.ts index 80e92ac..15790b2 100644 --- a/src/modules/stream/stream-ui.ts +++ b/src/modules/stream/stream-ui.ts @@ -2,7 +2,6 @@ import { STATES } from "@utils/global.ts"; import { createSvgIcon } from "@utils/html.ts"; import { BxIcon } from "@utils/bx-icon"; import { BxEvent } from "@utils/bx-event.ts"; -import { PrefKey, getPref } from "@utils/preferences.ts"; import { t } from "@utils/translation.ts"; import { StreamBadges } from "./stream-badges.ts"; import { StreamStats } from "./stream-stats.ts"; @@ -13,7 +12,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t let timeout: number | null; const onTransitionStart = (e: TransitionEvent) => { - if ( e.propertyName !== 'opacity') { + if (e.propertyName !== 'opacity') { return; } @@ -22,7 +21,7 @@ function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: t }; const onTransitionEnd = (e: TransitionEvent) => { - if ( e.propertyName !== 'opacity') { + if (e.propertyName !== 'opacity') { return; } @@ -89,6 +88,7 @@ export function injectStreamMenuButtons() { let $btnStreamSettings: HTMLElement; let $btnStreamStats: HTMLElement; + const streamStats = StreamStats.getInstance(); const observer = new MutationObserver(mutationList => { mutationList.forEach(item => { @@ -96,16 +96,6 @@ export function injectStreamMenuButtons() { return; } - item.removedNodes.forEach($node => { - if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { - return; - } - - if (!($node as HTMLElement).className || !($node as HTMLElement).className.startsWith) { - return; - } - }); - item.addedNodes.forEach(async $node => { if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { return; @@ -159,7 +149,7 @@ export function injectStreamMenuButtons() { // Render stream badges const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); - $menu?.appendChild(await StreamBadges.render()); + $menu?.appendChild(await StreamBadges.getInstance().render()); hideSettingsFunc(); return; @@ -219,14 +209,14 @@ export function injectStreamMenuButtons() { e.preventDefault(); // Toggle Stream Stats - StreamStats.toggle(); + streamStats.toggle(); - const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); + const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing()); $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); }); } - const btnStreamStatsOn = (!StreamStats.isHidden() && !StreamStats.isGlancing()); + const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing()); $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); if ($orgButton) { diff --git a/src/modules/ui/ui.ts b/src/modules/ui/ui.ts index bae4357..f21ecb5 100644 --- a/src/modules/ui/ui.ts +++ b/src/modules/ui/ui.ts @@ -268,7 +268,8 @@ function setupStreamSettingsDialog() { { pref: PrefKey.STATS_QUICK_GLANCE, onChange: (e: InputEvent) => { - (e.target! as HTMLInputElement).checked ? StreamStats.quickGlanceSetup() : StreamStats.quickGlanceStop(); + const streamStats = StreamStats.getInstance(); + (e.target! as HTMLInputElement).checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop(); }, }, { @@ -520,7 +521,6 @@ export function setupStreamUi() { window.addEventListener('resize', updateVideoPlayerCss); setupStreamSettingsDialog(); - StreamStats.render(); Screenshot.setup(); } diff --git a/src/utils/bx-event.ts b/src/utils/bx-event.ts index e10d014..a1b1a53 100644 --- a/src/utils/bx-event.ts +++ b/src/utils/bx-event.ts @@ -39,6 +39,8 @@ export enum BxEvent { XCLOUD_DIALOG_SHOWN = 'bx-xcloud-dialog-shown', XCLOUD_DIALOG_DISMISSED = 'bx-xcloud-dialog-dismissed', + XCLOUD_GUIDE_SHOWN = 'bx-xcloud-guide-shown', + XCLOUD_POLLING_MODE_CHANGED = 'bx-xcloud-polling-mode-changed', } @@ -46,6 +48,10 @@ export enum XcloudEvent { MICROPHONE_STATE_CHANGED = 'microphoneStateChanged', } +export enum XcloudGuideWhere { + HOME, +} + export namespace BxEvent { export function dispatch(target: HTMLElement | Window, eventName: string, data?: any) { if (!eventName) { diff --git a/src/utils/bx-icon.ts b/src/utils/bx-icon.ts index a6e83d3..2d19394 100644 --- a/src/utils/bx-icon.ts +++ b/src/utils/bx-icon.ts @@ -22,6 +22,15 @@ import iconCamera from "@assets/svg/camera.svg" with { type: "text" }; import iconMicrophone from "@assets/svg/microphone.svg" with { type: "text" }; import iconMicrophoneMuted from "@assets/svg/microphone-slash.svg" with { type: "text" }; +// Stream Badge +import iconBatteryFull from "@assets/svg/battery-full.svg" with { type: "text" }; +import iconClock from "@assets/svg/clock.svg" with { type: "text" }; +import iconCloud from "@assets/svg/cloud.svg" with { type: "text" }; +import iconDownload from "@assets/svg/download.svg" with { type: "text" }; +import iconSpeakerHigh from "@assets/svg/speaker-high.svg" with { type: "text" }; +import iconUpload from "@assets/svg/upload.svg" with { type: "text" }; + + export const BxIcon = { STREAM_SETTINGS: iconStreamSettings, STREAM_STATS: iconStreamStats, @@ -48,4 +57,12 @@ export const BxIcon = { MICROPHONE: iconMicrophone, MICROPHONE_MUTED: iconMicrophoneMuted, + + // Stream Badge + BATTERY: iconBatteryFull, + PLAYTIME: iconClock, + SERVER: iconCloud, + DOWNLOAD: iconDownload, + UPLOAD: iconUpload, + AUDIO: iconSpeakerHigh, } as const; diff --git a/src/utils/network.ts b/src/utils/network.ts index 90420df..542e3a7 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -366,14 +366,15 @@ class XcloudInterceptor { const url = (typeof request === 'string') ? request : (request as Request).url; const parsedUrl = new URL(url); - StreamBadges.region = parsedUrl.host.split('.', 1)[0]; + let badgeRegion: string = parsedUrl.host.split('.', 1)[0]; for (let regionName in STATES.serverRegions) { const region = STATES.serverRegions[regionName]; if (parsedUrl.origin == region.baseUri) { - StreamBadges.region = regionName; + badgeRegion = regionName; break; } } + StreamBadges.getInstance().setRegion(badgeRegion); const clone = (request as Request).clone(); const body = await clone.json();