From 686d550a3a774e2e8c747adae0d4fa58438c61d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Velad=20Galv=C3=A1n?= Date: Thu, 28 May 2026 20:45:43 +0200 Subject: [PATCH] feat(UI): Modernize the statistics panel (#10144) New design: Design --- test/ui/ui_unit.js | 44 ++++++++--- ui/ad_statistics_button.js | 49 ++++++++---- ui/less/containers.less | 128 +++++++++++++++++++----------- ui/statistics_button.js | 157 ++++++++++++++++++++++++++++++++++--- ui/ui.js | 2 + 5 files changed, 292 insertions(+), 88 deletions(-) diff --git a/test/ui/ui_unit.js b/test/ui/ui_unit.js index 155fb8ada..dc9838d08 100644 --- a/test/ui/ui_unit.js +++ b/test/ui/ui_unit.js @@ -1290,22 +1290,40 @@ describe('UI', () => { it('displays all the available statistics', () => { const skippedStats = ['stateHistory', 'switchHistory']; - const nodes = statisticsContainer.childNodes; - // First index is close button. - let nodeIndex = 1; + + /** + * Returns the stat node by label name. + * @param {string} name + * @return {?Node} + */ + function getStatsElementByName(name) { + const nodes = statisticsContainer.childNodes; + + for (const node of nodes) { + if (node.hasChildNodes() && + node.childNodes.length >= 2 && + node.childNodes[0].textContent.replace(':', '') == name) { + return node; + } + } + + return null; + } for (const statistic in new shaka.util.Stats().getBlob()) { - if (!skippedStats.includes(statistic)) { - // Text content of label (without ':') is a valid statistic - const label = nodes[nodeIndex].childNodes[0].textContent; - expect(label.replace(':', '')).toBe(statistic); - - // Value has been parsed and it is not the default 'NaN' - const value = nodes[nodeIndex].childNodes[1].textContent; - expect(value).not.toBe('NaN'); - - nodeIndex += 1; + if (skippedStats.includes(statistic)) { + continue; } + + const node = getStatsElementByName(statistic); + + expect(node).not.toBe(null); + + const label = node.childNodes[0].textContent; + expect(label.replace(':', '')).toBe(statistic); + + const value = node.childNodes[1].textContent; + expect(value).not.toBe('NaN'); } }); diff --git a/ui/ad_statistics_button.js b/ui/ad_statistics_button.js index 9f6a388c4..f834ceac9 100644 --- a/ui/ad_statistics_button.js +++ b/ui/ad_statistics_button.js @@ -83,6 +83,10 @@ shaka.ui.AdStatisticsButton = class extends shaka.ui.Element { /** @private {!Map} */ this.displayedElements_ = new Map(); + /** @private {!HTMLElement} */ + this.headerTitle_ = shaka.util.Dom.createHTMLElement('span'); + this.headerTitle_.classList.add('shaka-statistics-title'); + const parseLoadTimes = (name) => { let totalTime = 0; const loadTimes = @@ -109,13 +113,9 @@ shaka.ui.AdStatisticsButton = class extends shaka.ui.Element { /** @private {shaka.util.Timer} */ this.timer_ = new shaka.util.Timer(() => { - this.onTimerTick_(); + this.updateStats_(); }); - this.updateLocalizedStrings(); - - this.loadContainer_(); - this.eventManager.listen(this.button_, 'click', () => { if (!this.controls.isOpaque()) { return; @@ -125,13 +125,20 @@ shaka.ui.AdStatisticsButton = class extends shaka.ui.Element { }); this.eventManager.listen(this.player, 'loading', () => { - shaka.ui.Utils.setDisplay(this.button_, false); + this.updateStats_(); + this.checkAvailability(); }); this.eventManager.listen( this.adManager, shaka.ads.Utils.AD_STARTED, () => { - shaka.ui.Utils.setDisplay(this.button_, true); + this.updateStats_(); + this.checkAvailability(); }); + + this.updateLocalizedStrings(); + this.updateStats_(); + this.loadContainer_(); + this.checkAvailability(); } /** @private */ @@ -156,6 +163,9 @@ shaka.ui.AdStatisticsButton = class extends shaka.ui.Element { this.nameSpan_.textContent = this.localization.resolve(LocIds.AD_STATISTICS); + this.headerTitle_.textContent = + this.localization.resolve(LocIds.AD_STATISTICS); + this.button_.ariaLabel = this.localization.resolve(LocIds.AD_STATISTICS); const labelText = this.container_.classList.contains('shaka-hidden') ? @@ -165,9 +175,15 @@ shaka.ui.AdStatisticsButton = class extends shaka.ui.Element { /** @override */ checkAvailability() { - shaka.ui.Utils.setDisplay( - this.button_, - !this.isSubMenuOpened && this.currentStats_.started > 0); + const hasStats = this.currentStats_.started > 0 || + this.currentStats_.overlayAds > 0 || + this.currentStats_.playedCompletely > 0 || + this.currentStats_.skipped > 0 || + this.currentStats_.errors > 0; + shaka.ui.Utils.setDisplay(this.button_, !this.isSubMenuOpened && hasStats); + if (hasStats && !this.container_.classList.contains('shaka-hidden')) { + this.onClick_(); + } } /** @@ -193,15 +209,16 @@ shaka.ui.AdStatisticsButton = class extends shaka.ui.Element { /** @private */ loadContainer_() { - const closeElement = shaka.util.Dom.createHTMLElement('div'); - closeElement.classList.add('shaka-no-propagation'); - closeElement.classList.add('shaka-statistics-close'); - const icon = new shaka.ui.Icon(closeElement, + const header = shaka.util.Dom.createHTMLElement('div'); + header.classList.add('shaka-statistics-header'); + header.classList.add('shaka-no-propagation'); + header.appendChild(this.headerTitle_); + const icon = new shaka.ui.Icon(header, shaka.ui.Enums.MaterialDesignSVGIcons['CLOSE']); const iconElement = icon.getSvgElement(); iconElement.classList.add('material-icons', 'notranslate'); - this.container_.appendChild(closeElement); + this.container_.appendChild(header); this.eventManager.listen(iconElement, 'click', () => { this.onClick_(); }); @@ -216,7 +233,7 @@ shaka.ui.AdStatisticsButton = class extends shaka.ui.Element { } /** @private */ - onTimerTick_() { + updateStats_() { this.currentStats_ = this.adManager.getStats(); for (const name of this.statisticsList_) { diff --git a/ui/less/containers.less b/ui/less/containers.less index 76179f22c..323aa9497 100644 --- a/ui/less/containers.less +++ b/ui/less/containers.less @@ -463,78 +463,112 @@ display: none; } -.shaka-statistics-container { - overflow-x: hidden; - overflow-y: auto; - - scrollbar-color: @general-font-color @general-background-color; - scrollbar-width: thin; - - min-width: 300px; - - color: @general-font-color; - background-color: rgba(35, 35, 35, 90%); - - font-size: @general-font-size; - - padding: 5px 10px; - border-radius: 2px; - - position: absolute; - z-index: 2; - left: 15px; - top: 15px; - - max-height: calc(100% - 115px); - - /* Fades out with the other controls. */ - .show-when-controls-shown(); - - div { - display: flex; - justify-content: space-between; - } - - span { - color: rgb(150, 150, 150); - } -} - +.shaka-statistics-container, .shaka-ad-statistics-container { overflow-x: hidden; overflow-y: auto; - scrollbar-color: @general-font-color @general-background-color; + scrollbar-color: rgba(255, 255, 255, 15%) transparent; scrollbar-width: thin; - min-width: 150px; - color: @general-font-color; - background-color: rgba(35, 35, 35, 90%); + background-color: rgba(12, 14, 18, 88%); + border: 1px solid rgba(255, 255, 255, 8%); + box-shadow: 0 4px 24px rgba(0, 0, 0, 50%); font-size: @general-font-size; + font-family: monospace; - padding: 5px 10px; - border-radius: 2px; + padding: 0; + border-radius: 10px; position: absolute; z-index: 2; - right: 15px; top: 15px; max-height: calc(100% - 115px); - /* Fades out with the other controls. */ .show-when-controls-shown(); + /* Header bar (close button row) */ + .shaka-statistics-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 7%); + + svg { + width: 16px; + height: 16px; + opacity: 0.35; + cursor: pointer; + transition: opacity 0.15s; + + &:hover { + opacity: 1; + } + } + } + + /* Section group labels */ + .shaka-statistics-section-label { + display: block; + padding: 8px 14px 3px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgba(255, 255, 255, 70%); + } + + /* Dividers between groups */ + .shaka-statistics-divider { + display: block; + height: 1px; + background: rgba(255, 255, 255, 6%); + margin: 6px 14px; + padding: 0; + } + + /* Stat rows */ div { display: flex; justify-content: space-between; + align-items: center; + padding: 3px 14px; + gap: 16px; + transition: background 0.1s; + + &:hover { + background: rgba(255, 255, 255, 3%); + } } - span { - color: rgb(150, 150, 150); + /* Row labels */ + label { + font-size: 11px; + color: rgba(255, 255, 255, 45%); + white-space: nowrap; } + + /* Row values */ + span { + font-size: 11px; + color: rgba(255, 255, 255, 90%); + font-variant-numeric: tabular-nums; + text-align: right; + } +} + +.shaka-statistics-container { + min-width: 280px; + left: 15px; +} + +.shaka-ad-statistics-container { + min-width: 220px; + right: 15px; } .shaka-scrim-container { diff --git a/ui/statistics_button.js b/ui/statistics_button.js index 926d57496..6561befb5 100644 --- a/ui/statistics_button.js +++ b/ui/statistics_button.js @@ -83,6 +83,9 @@ shaka.ui.StatisticsButton = class extends shaka.ui.Element { /** @private {!Map} */ this.displayedElements_ = new Map(); + /** @private {!HTMLElement} */ + this.headerTitle_ = shaka.util.Dom.createHTMLElement('span'); + this.headerTitle_.classList.add('shaka-statistics-title'); const parsePx = (name) => { return this.currentStats_[name] + ' (px)'; @@ -173,7 +176,7 @@ shaka.ui.StatisticsButton = class extends shaka.ui.Element { /** @private {shaka.util.Timer} */ this.timer_ = new shaka.util.Timer(() => { - this.onTimerTick_(); + this.updateStats_(); }); this.updateLocalizedStrings(); @@ -208,6 +211,9 @@ shaka.ui.StatisticsButton = class extends shaka.ui.Element { this.nameSpan_.textContent = this.localization.resolve(LocIds.STATISTICS); + this.headerTitle_.textContent = + this.localization.resolve(LocIds.STATISTICS); + this.button_.ariaLabel = this.localization.resolve(LocIds.STATISTICS); const labelText = this.container_.classList.contains('shaka-hidden') ? @@ -238,30 +244,157 @@ shaka.ui.StatisticsButton = class extends shaka.ui.Element { /** @private */ loadContainer_() { - const closeElement = shaka.util.Dom.createHTMLElement('div'); - closeElement.classList.add('shaka-no-propagation'); - closeElement.classList.add('shaka-statistics-close'); - const icon = new shaka.ui.Icon(closeElement, + const header = shaka.util.Dom.createHTMLElement('div'); + header.classList.add('shaka-statistics-header'); + header.classList.add('shaka-no-propagation'); + header.appendChild(this.headerTitle_); + const icon = new shaka.ui.Icon(header, shaka.ui.Enums.MaterialDesignSVGIcons['CLOSE']); const iconElement = icon.getSvgElement(); iconElement.classList.add('material-icons', 'notranslate'); - this.container_.appendChild(closeElement); + this.container_.appendChild(header); this.eventManager.listen(iconElement, 'click', () => { this.onClick_(); }); - for (const name of this.controls.getConfig().statisticsList) { - if (name in this.currentStats_ && !this.skippedStats_.includes(name)) { - const element = this.generateComponent_(name); - this.container_.appendChild(element); + + /** + * @const {!Array<{label: string, stats: !Array}>} + */ + const groups = [ + { + label: 'Video', + stats: [ + 'width', + 'height', + 'currentCodecs', + ], + }, + { + label: 'Network', + stats: [ + 'estimatedBandwidth', + 'streamBandwidth', + 'maxSegmentDuration', + 'bytesDownloaded', + ], + }, + { + label: 'Load', + stats: [ + 'loadLatency', + 'timeToFirstFrame', + 'manifestTimeSeconds', + 'drmTimeSeconds', + 'licenseTime', + ], + }, + { + label: 'Playback', + stats: [ + 'playTime', + 'pauseTime', + 'bufferingTime', + 'liveLatency', + 'completionPercent', + ], + }, + { + label: 'Frames', + stats: [ + 'decodedFrames', + 'droppedFrames', + 'corruptedFrames', + ], + }, + { + label: 'Stability', + stats: [ + 'stallsDetected', + 'gapsJumped', + 'nonFatalErrorCount', + ], + }, + { + label: 'Manifest', + stats: [ + 'manifestSizeBytes', + 'manifestPeriodCount', + 'manifestGapCount', + ], + }, + ]; + + const configList = this.controls.getConfig().statisticsList; + + /** @type {!Set} Track which stats have already been rendered */ + const rendered = new Set(); + + /** @param {string} labelText */ + const appendSectionLabel = (labelText) => { + const el = shaka.util.Dom.createHTMLElement('div'); + el.classList.add('shaka-statistics-section-label'); + el.textContent = labelText; + this.container_.appendChild(el); + }; + + const appendDivider = () => { + const el = shaka.util.Dom.createHTMLElement('div'); + el.classList.add('shaka-statistics-divider'); + this.container_.appendChild(el); + }; + + let firstGroup = true; + + for (const group of groups) { + // Only render stats that the integrator has configured AND that the + // player is actually reporting (skipping stateHistory / switchHistory). + const groupStats = group.stats.filter((name) => + configList.includes(name) && + name in this.currentStats_ && + !this.skippedStats_.includes(name)); + + if (groupStats.length === 0) { + continue; + } + + if (!firstGroup) { + appendDivider(); + } + firstGroup = false; + + appendSectionLabel(group.label); + + for (const name of groupStats) { + this.container_.appendChild(this.generateComponent_(name)); this.statisticsList_.push(name); - } else { + rendered.add(name); + } + } + + // Any configured stat not covered by the groups above goes into 'Other'. + const remaining = configList.filter((name) => + !rendered.has(name) && + name in this.currentStats_ && + !this.skippedStats_.includes(name)); + + if (remaining.length > 0) { + appendDivider(); + appendSectionLabel('Other'); + for (const name of remaining) { + this.container_.appendChild(this.generateComponent_(name)); + this.statisticsList_.push(name); + } + } + + for (const name of configList) { + if (!(name in this.currentStats_)) { shaka.log.alwaysWarn('Unrecognized statistic element:', name); } } } /** @private */ - onTimerTick_() { + updateStats_() { this.currentStats_ = this.player.getStats(); for (const name of this.statisticsList_) { diff --git a/ui/ui.js b/ui/ui.js index e49a8d05d..0ac6f75cc 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -345,6 +345,8 @@ shaka.ui.Overlay = class { 'nonFatalErrorCount', 'manifestPeriodCount', 'manifestGapCount', + 'gapsJumped', + 'stallsDetected', ], adStatisticsList: [ 'loadTimes',