diff --git a/dist/better-xcloud.user.js b/dist/better-xcloud.user.js index af4de32..063f206 100644 --- a/dist/better-xcloud.user.js +++ b/dist/better-xcloud.user.js @@ -150,12 +150,17 @@ var BxEvent; BxEvent2["GAINNODE_VOLUME_CHANGED"] = "bx-gainnode-volume-changed"; BxEvent2["XCLOUD_DIALOG_SHOWN"] = "bx-xcloud-dialog-shown"; BxEvent2["XCLOUD_DIALOG_DISMISSED"] = "bx-xcloud-dialog-dismissed"; + BxEvent2["XCLOUD_GUIDE_SHOWN"] = "bx-xcloud-guide-shown"; BxEvent2["XCLOUD_POLLING_MODE_CHANGED"] = "bx-xcloud-polling-mode-changed"; })(BxEvent || (BxEvent = {})); var XcloudEvent; (function(XcloudEvent2) { XcloudEvent2["MICROPHONE_STATE_CHANGED"] = "microphoneStateChanged"; })(XcloudEvent || (XcloudEvent = {})); +var XcloudGuideWhere; +(function(XcloudGuideWhere2) { + XcloudGuideWhere2[XcloudGuideWhere2["HOME"] = 0] = "HOME"; +})(XcloudGuideWhere || (XcloudGuideWhere = {})); (function(BxEvent) { function dispatch(target, eventName, data) { if (!eventName) { @@ -1144,221 +1149,6 @@ class MkbPreset { } } -// src/modules/stream/stream-badges.ts -var StreamBadge; -(function(StreamBadge2) { - StreamBadge2["PLAYTIME"] = "playtime"; - StreamBadge2["BATTERY"] = "battery"; - StreamBadge2["IN"] = "in"; - StreamBadge2["OUT"] = "out"; - StreamBadge2["SERVER"] = "server"; - StreamBadge2["VIDEO"] = "video"; - StreamBadge2["AUDIO"] = "audio"; - StreamBadge2["BREAK"] = "break"; -})(StreamBadge || (StreamBadge = {})); - -class StreamBadges { - static ipv6 = false; - static resolution = null; - static video = null; - static audio = null; - static fps = 0; - static region = ""; - static startBatteryLevel = 100; - static startTimestamp = 0; - static #cachedDoms = {}; - static #interval; - static #REFRESH_INTERVAL = 3000; - static #renderBadge(name, value, color) { - if (name === StreamBadge.BREAK) { - return CE("div", { style: "display: block" }); - } - let $badge; - if (StreamBadges.#cachedDoms[name]) { - $badge = StreamBadges.#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)); - if (name === StreamBadge.BATTERY) { - $badge.classList.add("bx-badge-battery"); - } - StreamBadges.#cachedDoms[name] = $badge; - return $badge; - } - static async#updateBadges(forceUpdate) { - if (!forceUpdate && !document.querySelector(".bx-badges")) { - StreamBadges.#stop(); - return; - } - let now = +new Date; - const diffSeconds = Math.ceil((now - StreamBadges.startTimestamp) / 1000); - const playtime = StreamBadges.#secondsToHm(diffSeconds); - let batteryLevel = "100%"; - let batteryLevelInt = 100; - let isCharging = false; - if ("getBattery" in navigator) { - try { - const bm = await navigator.getBattery(); - isCharging = bm.charging; - batteryLevelInt = Math.round(bm.level * 100); - batteryLevel = `${batteryLevelInt}%`; - if (batteryLevelInt != StreamBadges.startBatteryLevel) { - const diffLevel = Math.round(batteryLevelInt - StreamBadges.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 = { - [StreamBadge.IN]: totalIn ? StreamBadges.#humanFileSize(totalIn) : null, - [StreamBadge.OUT]: totalOut ? StreamBadges.#humanFileSize(totalOut) : null, - [StreamBadge.PLAYTIME]: playtime, - [StreamBadge.BATTERY]: batteryLevel - }; - let name; - for (name in badges) { - const value = badges[name]; - if (value === null) { - continue; - } - const $elm = StreamBadges.#cachedDoms[name]; - $elm && ($elm.lastElementChild.textContent = value); - if (name === StreamBadge.BATTERY) { - $elm.setAttribute("data-charging", isCharging.toString()); - if (StreamBadges.startBatteryLevel === 100 && batteryLevelInt === 100) { - $elm.style.display = "none"; - } else { - $elm.removeAttribute("style"); - } - } - } - } - static #stop() { - StreamBadges.#interval && clearInterval(StreamBadges.#interval); - StreamBadges.#interval = null; - } - static #secondsToHm(seconds) { - const h = Math.floor(seconds / 3600); - const m = Math.floor(seconds % 3600 / 60) + 1; - const hDisplay = h > 0 ? `${h}h` : ""; - const mDisplay = m > 0 ? `${m}m` : ""; - return hDisplay + mDisplay; - } - static #humanFileSize(size) { - 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() { - let video = ""; - if (StreamBadges.resolution) { - video = `${StreamBadges.resolution.height}p`; - } - if (StreamBadges.video) { - video && (video += "/"); - video += StreamBadges.video.codec; - if (StreamBadges.video.profile) { - const profile = StreamBadges.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})`; - } - } - let audio; - if (StreamBadges.audio) { - audio = StreamBadges.audio.codec; - const bitrate = StreamBadges.audio.bitrate / 1000; - audio += ` (${bitrate} kHz)`; - } - let batteryLevel = ""; - if ("getBattery" in navigator) { - batteryLevel = "100%"; - } - let server = StreamBadges.region; - server += "@" + (StreamBadges.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.SERVER, server, "#ff6c24"], - video ? [StreamBadge.VIDEO, video, "#742f29"] : null, - audio ? [StreamBadge.AUDIO, audio, "#5f574f"] : null - ]; - const $wrapper = CE("div", { class: "bx-badges" }); - BADGES.forEach((item2) => { - if (!item2) { - return; - } - const $badge = StreamBadges.#renderBadge(...item2); - $wrapper.appendChild($badge); - }); - await StreamBadges.#updateBadges(true); - StreamBadges.#stop(); - StreamBadges.#interval = window.setInterval(StreamBadges.#updateBadges, StreamBadges.#REFRESH_INTERVAL); - return $wrapper; - } - static setupEvents() { - window.addEventListener(BxEvent.STREAM_PLAYING, (e) => { - const $video = e.$video; - StreamBadges.resolution = { - width: $video.videoWidth, - height: $video.videoHeight - }; - StreamBadges.startTimestamp = +new Date; - try { - "getBattery" in navigator && navigator.getBattery().then((bm) => { - StreamBadges.startBatteryLevel = Math.round(bm.level * 100); - }); - } catch (e2) { - } - }); - } -} - -// src/utils/bx-logger.ts -var TextColor; -(function(TextColor2) { - TextColor2["INFO"] = "#008746"; - TextColor2["WARNING"] = "#c1a404"; - TextColor2["ERROR"] = "#c10404"; -})(TextColor || (TextColor = {})); - -class BxLogger { - static #PREFIX = "[BxC]"; - static info(tag, ...args) { - BxLogger.#log(TextColor.INFO, tag, ...args); - } - static warning(tag, ...args) { - BxLogger.#log(TextColor.WARNING, tag, ...args); - } - static error(tag, ...args) { - BxLogger.#log(TextColor.ERROR, tag, ...args); - } - static #log(color, tag, ...args) { - console.log(`%c${BxLogger.#PREFIX}`, `color:${color};font-weight:bold;`, tag, "//", ...args); - } -} -window.BxLogger = BxLogger; - // src/modules/stream/stream-stats.ts var StreamStat; (function(StreamStat2) { @@ -1371,229 +1161,199 @@ var StreamStat; })(StreamStat || (StreamStat = {})); class StreamStats { - static #interval; - static #updateInterval = 1000; - static #$container; - static #$fps; - static #$ping; - static #$dt; - static #$pl; - static #$fl; - static #$br; - static #lastStat; - static #quickGlanceObserver; - static start(glancing = false) { - if (!StreamStats.isHidden() || glancing && StreamStats.isGlancing()) { + static instance; + static getInstance() { + if (!StreamStats.instance) { + StreamStats.instance = new StreamStats; + } + return StreamStats.instance; + } + #timeoutId; + #updateInterval = 1000; + #$container; + #$fps; + #$ping; + #$dt; + #$pl; + #$fl; + #$br; + #lastVideoStat; + #quickGlanceObserver; + start(glancing = false) { + if (!this.isHidden() || glancing && this.isGlancing()) { return; } - StreamStats.#$container.classList.remove("bx-gone"); - StreamStats.#$container.setAttribute("data-display", glancing ? "glancing" : "fixed"); - StreamStats.#interval = window.setInterval(StreamStats.update, StreamStats.#updateInterval); + if (this.#$container) { + this.#$container.classList.remove("bx-gone"); + this.#$container.dataset.display = glancing ? "glancing" : "fixed"; + } + 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; - if (StreamStats.#$container) { - StreamStats.#$container.removeAttribute("data-display"); - StreamStats.#$container.classList.add("bx-gone"); + this.#timeoutId && clearTimeout(this.#timeoutId); + this.#timeoutId = null; + this.#lastVideoStat = null; + 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"; - static quickGlanceSetup() { - if (StreamStats.#quickGlanceObserver) { + isHidden = () => this.#$container && this.#$container.classList.contains("bx-gone"); + isGlancing = () => this.#$container && this.#$container.dataset.display === "glancing"; + 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.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") { - StreamStats.#$fps.textContent = stat.framesPerSecond || 0; - 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 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}%)`; - if (StreamStats.#lastStat) { - const lastStat = StreamStats.#lastStat; - const timeDiff = stat.timestamp - lastStat.timestamp; - const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000; - StreamStats.#$br.textContent = `${bitrate.toFixed(2)} Mbps`; - const totalDecodeTimeDiff = stat.totalDecodeTime - lastStat.totalDecodeTime; - const framesDecodedDiff = stat.framesDecoded - lastStat.framesDecoded; - const currentDecodeTime = totalDecodeTimeDiff / framesDecodedDiff * 1000; - StreamStats.#$dt.textContent = `${currentDecodeTime.toFixed(2)}ms`; - 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") { - 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); + const stats = await STATES.currentStream.peerConnection.getStats(); + let grade = ""; + stats.forEach((stat) => { + if (stat.type === "inbound-rtp" && stat.kind === "video") { + this.#$fps.textContent = stat.framesPerSecond || 0; + 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}%)`; + 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 (!this.#lastVideoStat) { + this.#lastVideoStat = stat; + return; } - }); + const lastStat = this.#lastVideoStat; + const timeDiff = stat.timestamp - lastStat.timestamp; + const bitrate = 8 * (stat.bytesReceived - lastStat.bytesReceived) / timeDiff / 1000; + this.#$br.textContent = `${bitrate.toFixed(2)} Mbps`; + 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") { + 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; - for (statKey in STATS) { - const $div = CE("div", { class: `bx-stat-${statKey}`, title: STATS[statKey][0] }, CE("label", {}, statKey.toUpperCase()), STATS[statKey][1]); + 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); - StreamStats.refreshStyles(); - } - static getServerStats() { - STATES.currentStream.peerConnection && STATES.currentStream.peerConnection.getStats().then((stats) => { - const allVideoCodecs = {}; - let videoCodecId; - const allAudioCodecs = {}; - let audioCodecId; - const allCandidates = {}; - let candidateId; - stats.forEach((stat) => { - if (stat.type === "codec") { - const mimeType = stat.mimeType.split("/"); - if (mimeType[0] === "video") { - allVideoCodecs[stat.id] = stat; - } else if (mimeType[0] === "audio") { - allAudioCodecs[stat.id] = stat; - } - } else if (stat.type === "inbound-rtp" && stat.packetsReceived > 0) { - 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; - } - }); - if (videoCodecId) { - const videoStat = allVideoCodecs[videoCodecId]; - const 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; - } - if (audioCodecId) { - const audioStat = allAudioCodecs[audioCodecId]; - StreamBadges.audio = { - codec: audioStat.mimeType.substring(6), - bitrate: audioStat.clockRate - }; - } - if (candidateId) { - BxLogger.info("candidate", candidateId, allCandidates); - StreamBadges.ipv6 = allCandidates[candidateId].includes(":"); - } - if (getPref(PrefKey.STATS_SHOW_WHEN_PLAYING)) { - StreamStats.start(); - } - }); + this.#$container = CE("div", { class: "bx-stats-bar bx-gone" }, $barFragment); + this.refreshStyles(); + 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(); - if (PREF_STATS_QUICK_GLANCE) { - StreamStats.quickGlanceSetup(); - !PREF_STATS_SHOW_WHEN_PLAYING && StreamStats.start(true); + const streamStats = StreamStats.getInstance(); + if (PREF_STATS_SHOW_WHEN_PLAYING) { + streamStats.start(); + } else if (PREF_STATS_QUICK_GLANCE) { + streamStats.quickGlanceSetup(); + !PREF_STATS_SHOW_WHEN_PLAYING && streamStats.start(true); } }); } + static refreshStyles() { + StreamStats.getInstance().refreshStyles(); + } } // src/utils/preferences.ts @@ -2594,6 +2354,24 @@ var microphone_default = "\n \n\n"; +// src/assets/svg/battery-full.svg +var battery_full_default = "\n \n\n"; + +// src/assets/svg/clock.svg +var clock_default = "\n \n \n \n \n\n"; + +// src/assets/svg/cloud.svg +var cloud_default = "\n \n\n"; + +// src/assets/svg/download.svg +var download_default = "\n \n \n\n"; + +// src/assets/svg/speaker-high.svg +var speaker_high_default = "\n \n \n\n"; + +// src/assets/svg/upload.svg +var upload_default = "\n \n \n\n"; + // src/utils/bx-icon.ts var BxIcon = { STREAM_SETTINGS: stream_settings_default, @@ -2616,9 +2394,303 @@ var BxIcon = { TOUCH_CONTROL_ENABLE: touch_control_enable_default, TOUCH_CONTROL_DISABLE: touch_control_disable_default, MICROPHONE: microphone_default, - MICROPHONE_MUTED: microphone_slash_default + MICROPHONE_MUTED: microphone_slash_default, + BATTERY: battery_full_default, + PLAYTIME: clock_default, + SERVER: cloud_default, + DOWNLOAD: download_default, + UPLOAD: upload_default, + AUDIO: speaker_high_default }; +// src/utils/bx-logger.ts +var TextColor; +(function(TextColor2) { + TextColor2["INFO"] = "#008746"; + TextColor2["WARNING"] = "#c1a404"; + TextColor2["ERROR"] = "#c10404"; +})(TextColor || (TextColor = {})); + +class BxLogger { + static #PREFIX = "[BxC]"; + static info(tag, ...args) { + BxLogger.#log(TextColor.INFO, tag, ...args); + } + static warning(tag, ...args) { + BxLogger.#log(TextColor.WARNING, tag, ...args); + } + static error(tag, ...args) { + BxLogger.#log(TextColor.ERROR, tag, ...args); + } + static #log(color, tag, ...args) { + console.log(`%c${BxLogger.#PREFIX}`, `color:${color};font-weight:bold;`, tag, "//", ...args); + } +} +window.BxLogger = BxLogger; + +// src/modules/stream/stream-badges.ts +var StreamBadge; +(function(StreamBadge2) { + StreamBadge2["PLAYTIME"] = "playtime"; + StreamBadge2["BATTERY"] = "battery"; + StreamBadge2["IN"] = "in"; + StreamBadge2["OUT"] = "out"; + StreamBadge2["SERVER"] = "server"; + StreamBadge2["VIDEO"] = "video"; + StreamBadge2["AUDIO"] = "audio"; + StreamBadge2["BREAK"] = "break"; +})(StreamBadge || (StreamBadge = {})); +var StreamBadgeIcon = { + [StreamBadge.PLAYTIME]: BxIcon.PLAYTIME, + [StreamBadge.VIDEO]: BxIcon.DISPLAY, + [StreamBadge.BATTERY]: BxIcon.BATTERY, + [StreamBadge.IN]: BxIcon.DOWNLOAD, + [StreamBadge.OUT]: BxIcon.UPLOAD, + [StreamBadge.SERVER]: BxIcon.SERVER, + [StreamBadge.AUDIO]: BxIcon.AUDIO +}; + +class StreamBadges { + static instance; + static getInstance() { + if (!StreamBadges.instance) { + StreamBadges.instance = new StreamBadges; + } + return StreamBadges.instance; + } + #ipv6 = false; + #resolution = null; + #video = null; + #audio = null; + #region = ""; + startBatteryLevel = 100; + startTimestamp = 0; + #cachedDoms = {}; + #interval; + #REFRESH_INTERVAL = 3000; + setRegion(region) { + this.#region = region; + } + #renderBadge(name, value, color) { + if (name === StreamBadge.BREAK) { + return CE("div", { style: "display: block" }); + } + let $badge; + if (this.#cachedDoms[name]) { + $badge = this.#cachedDoms[name]; + $badge.lastElementChild.textContent = value; + return $badge; + } + $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"); + } + this.#cachedDoms[name] = $badge; + return $badge; + } + async#updateBadges(forceUpdate) { + if (!forceUpdate && !document.querySelector(".bx-badges")) { + this.#stop(); + return; + } + let now = +new Date; + const diffSeconds = Math.ceil((now - this.startTimestamp) / 1000); + const playtime = this.#secondsToHm(diffSeconds); + let batteryLevel = "100%"; + let batteryLevelInt = 100; + let isCharging = false; + if ("getBattery" in navigator) { + try { + const bm = await navigator.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 = { + [StreamBadge.IN]: totalIn ? this.#humanFileSize(totalIn) : null, + [StreamBadge.OUT]: totalOut ? this.#humanFileSize(totalOut) : null, + [StreamBadge.PLAYTIME]: playtime, + [StreamBadge.BATTERY]: batteryLevel + }; + let name; + for (name in badges) { + const value = badges[name]; + if (value === null) { + continue; + } + const $elm = this.#cachedDoms[name]; + $elm && ($elm.lastElementChild.textContent = value); + if (name === StreamBadge.BATTERY) { + $elm.setAttribute("data-charging", isCharging.toString()); + if (this.startBatteryLevel === 100 && batteryLevelInt === 100) { + $elm.style.display = "none"; + } else { + $elm.removeAttribute("style"); + } + } + } + } + #stop() { + this.#interval && clearInterval(this.#interval); + this.#interval = null; + } + #secondsToHm(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor(seconds % 3600 / 60) + 1; + const hDisplay = h > 0 ? `${h}h` : ""; + const mDisplay = m > 0 ? `${m}m` : ""; + return hDisplay + mDisplay; + } + #humanFileSize(size) { + 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]; + } + async render() { + if (!this.#video) { + await this.#getServerStats(); + } + 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})`; + } + } + let audio; + if (this.#audio) { + audio = this.#audio.codec; + const bitrate = this.#audio.bitrate / 1000; + audio += ` (${bitrate} kHz)`; + } + let batteryLevel = ""; + if ("getBattery" in navigator) { + batteryLevel = "100%"; + } + let server = this.#region; + server += "@" + (this.#ipv6 ? "IPv6" : "IPv4"); + const BADGES = [ + [StreamBadge.PLAYTIME, "1m", "#ff004d"], + [StreamBadge.BATTERY, batteryLevel, "#00b543"], + [StreamBadge.IN, this.#humanFileSize(0), "#29adff"], + [StreamBadge.OUT, this.#humanFileSize(0), "#ff77a8"], + [StreamBadge.BREAK], + [StreamBadge.SERVER, server, "#ff6c24"], + video ? [StreamBadge.VIDEO, video, "#742f29"] : null, + audio ? [StreamBadge.AUDIO, audio, "#5f574f"] : null + ]; + const $wrapper = CE("div", { class: "bx-badges" }); + BADGES.forEach((item2) => { + if (!item2) { + return; + } + const $badge = this.#renderBadge(...item2); + $wrapper.appendChild($badge); + }); + await this.#updateBadges(true); + this.#stop(); + this.#interval = window.setInterval(this.#updateBadges.bind(this), this.#REFRESH_INTERVAL); + return $wrapper; + } + async#getServerStats() { + const stats = await STATES.currentStream.peerConnection.getStats(); + const allVideoCodecs = {}; + let videoCodecId; + const allAudioCodecs = {}; + let audioCodecId; + const allCandidates = {}; + let candidateId; + stats.forEach((stat) => { + if (stat.type === "codec") { + const mimeType = stat.mimeType.split("/"); + if (mimeType[0] === "video") { + allVideoCodecs[stat.id] = stat; + } else if (mimeType[0] === "audio") { + allAudioCodecs[stat.id] = stat; + } + } else if (stat.type === "inbound-rtp" && stat.packetsReceived > 0) { + 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; + } + }); + if (videoCodecId) { + const videoStat = allVideoCodecs[videoCodecId]; + const 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; + } + this.#video = video; + } + if (audioCodecId) { + const audioStat = allAudioCodecs[audioCodecId]; + this.#audio = { + codec: audioStat.mimeType.substring(6), + bitrate: audioStat.clockRate + }; + } + if (candidateId) { + BxLogger.info("candidate", candidateId, allCandidates); + this.#ipv6 = allCandidates[candidateId].includes(":"); + } + } + static setupEvents() { + window.addEventListener(BxEvent.STREAM_PLAYING, (e) => { + const $video = e.$video; + const streamBadges = StreamBadges.getInstance(); + streamBadges.#resolution = { + width: $video.videoWidth, + height: $video.videoHeight + }; + streamBadges.startTimestamp = +new Date; + try { + "getBattery" in navigator && navigator.getBattery().then((bm) => { + streamBadges.startBatteryLevel = Math.round(bm.level * 100); + }); + } catch (e2) { + } + }); + } +} + // src/modules/stream/stream-ui.ts var cloneStreamHudButton = function($orgButton, label, svgIcon) { const $container = $orgButton.cloneNode(true); @@ -2683,6 +2755,7 @@ function injectStreamMenuButtons() { }; let $btnStreamSettings; let $btnStreamStats; + const streamStats = StreamStats.getInstance(); const observer = new MutationObserver((mutationList) => { mutationList.forEach((item2) => { if (item2.type !== "childList") { @@ -2728,7 +2801,7 @@ function injectStreamMenuButtons() { }); $btnCloseHud.insertAdjacentElement("afterend", $btnRefresh); 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; } @@ -2768,12 +2841,12 @@ function injectStreamMenuButtons() { $btnStreamStats.addEventListener("click", (e) => { hideGripHandle(); e.preventDefault(); - StreamStats.toggle(); - const btnStreamStatsOn2 = !StreamStats.isHidden() && !StreamStats.isGlancing(); + streamStats.toggle(); + const btnStreamStatsOn2 = !streamStats.isHidden() && !streamStats.isGlancing(); $btnStreamStats.classList.toggle("bx-stream-menu-button-on", btnStreamStatsOn2); }); } - const btnStreamStatsOn = !StreamStats.isHidden() && !StreamStats.isGlancing(); + const btnStreamStatsOn = !streamStats.isHidden() && !streamStats.isGlancing(); $btnStreamStats.classList.toggle("bx-stream-menu-button-on", btnStreamStatsOn); if ($orgButton) { const $btnParent = $orgButton.parentElement; @@ -3555,7 +3628,7 @@ class ControllerShortcut { Screenshot.takeScreenshot(); break; case ShortcutAction.STREAM_STATS_TOGGLE: - StreamStats.toggle(); + StreamStats.getInstance().toggle(); break; case ShortcutAction.STREAM_MICROPHONE_TOGGLE: MicrophoneShortcut.toggle(); @@ -4960,7 +5033,8 @@ var setupStreamSettingsDialog = function() { { pref: PrefKey.STATS_QUICK_GLANCE, onChange: (e) => { - e.target.checked ? StreamStats.quickGlanceSetup() : StreamStats.quickGlanceStop(); + const streamStats = StreamStats.getInstance(); + e.target.checked ? streamStats.quickGlanceSetup() : streamStats.quickGlanceStop(); } }, { @@ -5140,7 +5214,6 @@ function setupStreamUi() { preloadFonts(); window.addEventListener("resize", updateVideoPlayerCss); setupStreamSettingsDialog(); - StreamStats.render(); Screenshot.setup(); } updateVideoPlayerCss(); @@ -5787,14 +5860,15 @@ class XcloudInterceptor { const PREF_STREAM_PREFERRED_LOCALE = getPref(PrefKey.STREAM_PREFERRED_LOCALE); const url = typeof request === "string" ? request : request.url; const parsedUrl = new URL(url); - StreamBadges.region = parsedUrl.host.split(".", 1)[0]; + let badgeRegion = parsedUrl.host.split(".", 1)[0]; for (let regionName in STATES.serverRegions) { const region3 = STATES.serverRegions[regionName]; if (parsedUrl.origin == region3.baseUri) { - StreamBadges.region = regionName; + badgeRegion = regionName; break; } } + StreamBadges.getInstance().setRegion(badgeRegion); const clone = request.clone(); const body = await clone.json(); if (PREF_STREAM_TARGET_RESOLUTION !== "auto") { @@ -6745,6 +6819,10 @@ div[data-testid=media-container].bx-taking-screenshot:before { box-shadow: 0px 0px 6px #000; border-radius: 4px; } +.bx-badge svg { + width: 18px; + height: 18px; +} .bx-badge-name { background-color: #2d3036; display: inline-block; @@ -8775,6 +8853,26 @@ var observeRootDialog = function($root) { if (mutation.type !== "childList") { 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")) { + const $selectedTab = $addedElm.querySelector("div[class^=NavigationMenu] button[aria-selected=true"); + if ($selectedTab) { + let $elm = $selectedTab; + let index; + for (index = 0;$elm = $elm?.previousElementSibling; index++) + ; + if (index === 0) { + BxEvent.dispatch(window, BxEvent.XCLOUD_GUIDE_SHOWN, { where: XcloudGuideWhere.HOME }); + console.log("quit", $addedElm.querySelector("a[class*=QuitGameButton]")); + } + } + } + } + } + console.log("added", mutation.addedNodes); + console.log("removed", mutation.removedNodes); const shown = $root.firstElementChild && $root.firstElementChild.childElementCount > 0 || false; if (shown !== currentShown) { currentShown = shown; @@ -8938,7 +9036,7 @@ window.addEventListener(BxEvent.STREAM_STOPPED, (e) => { } STATES.currentStream.audioGainNode = null; STATES.currentStream.$video = null; - StreamStats.onStoppedPlaying(); + StreamStats.getInstance().onStoppedPlaying(); MouseCursorHider.stop(); TouchController.reset(); GameBar.getInstance().disable(); @@ -8946,4 +9044,8 @@ window.addEventListener(BxEvent.STREAM_STOPPED, (e) => { window.addEventListener(BxEvent.CAPTURE_SCREENSHOT, (e) => { Screenshot.takeScreenshot(); }); +window.addEventListener(BxEvent.XCLOUD_GUIDE_SHOWN, (e) => { + const where = e.where; + console.log("where", where); +}); main();