diff --git a/build/types/ui b/build/types/ui index 36bedbda9e..3658312575 100644 --- a/build/types/ui +++ b/build/types/ui @@ -9,6 +9,7 @@ +../../ui/big_play_button.js +../../ui/airplay_button.js +../../ui/cast_button.js ++../../ui/context_menu.js +../../ui/controls.js +../../ui/constants.js +../../ui/enums.js @@ -31,6 +32,7 @@ +../../ui/small_play_button.js +../../ui/skip_ad_button.js +../../ui/spacer.js ++../../ui/statistics_button.js +../../ui/text_selection.js +../../ui/ui.js +../../ui/ui_utils.js diff --git a/docs/tutorials/ui-customization.md b/docs/tutorials/ui-customization.md index 4c563a4292..bcbbe724ae 100644 --- a/docs/tutorials/ui-customization.md +++ b/docs/tutorials/ui-customization.md @@ -102,6 +102,35 @@ ui.configure(config); An important note: the 'overflow_menu' button needs to be part of the 'controlPanelElements' layout for the overflow menu to be available to the user. +#### Replacing the default context menu + +A custom context menu can be added through the `customContextMenu` boolean. Additionally, the `contextMenuElements` option can be used to add elements to it. Currently only the statistics button is available: +* Statistics: adds a button that displays statistics of the video. + +Example: +```js +const config = { + 'customContextMenu' : true, + 'contextMenuElements' : ['statistics'], +} +ui.configure(config); +``` + +#### Configuring Statistics +The list of statistics that are displayed when toggling the statistics button can be customized by specifying a `statisticsList` on the configuration. With the exception of `switchHistory` and `stateHistory`, all of the statistics from the {@link shaka.extern.Stats `Stats`} extern can be displayed. + +Example: +```js +// Add a context menu with the 'statistics' button that displays a container with +// the current 'width', 'height', 'playTime', and 'bufferingTime' values. +const config = { + 'customContextMenu' : true, + 'contextMenuElements' : ['statistics'], + 'statisticsList' : ['width', 'height', 'playTime', 'bufferingTime'], +} +ui.configure(config); +``` + The presence of the seek bar and the big play button in the center of the video element can be customized with `addSeekBar` and `addBigPlayButton` booleans in the config. diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index 458d6a6026..f4015e1d92 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -70,6 +70,7 @@ goog.require('shaka.ui.AdPosition'); goog.require('shaka.ui.AirPlayButton'); goog.require('shaka.ui.BigPlayButton'); goog.require('shaka.ui.CastButton'); +goog.require('shaka.ui.ContextMenu'); goog.require('shaka.ui.Element'); goog.require('shaka.ui.FastForwardButton'); goog.require('shaka.ui.FullscreenButton'); @@ -85,6 +86,7 @@ goog.require('shaka.ui.RewindButton'); goog.require('shaka.ui.SkipAdButton'); goog.require('shaka.ui.SmallPlayButton'); goog.require('shaka.ui.Spacer'); +goog.require('shaka.ui.StatisticsButton'); goog.require('shaka.ui.TextSelection'); goog.require('shaka.ui.VolumeBar'); goog.require('shaka.util.Dom'); diff --git a/test/ui/ui_unit.js b/test/ui/ui_unit.js index 6755598968..d05de14c13 100644 --- a/test/ui/ui_unit.js +++ b/test/ui/ui_unit.js @@ -685,6 +685,182 @@ describe('UI', () => { goog.asserts.assert(found, 'Unable to find resolution menu'); } }); + + describe('custom context menu', () => { + /** @type {!HTMLElement} */ + let controlsContainer; + /** @type {!HTMLElement} */ + let contextMenu; + + beforeEach(() => { + const config = { + customContextMenu: true, + contextMenuElements: [ + 'fakeElement', + 'statistics', + 'fakeElement', + ], + }; + const ui = UiUtils.createUIThroughAPI(videoContainer, video, config); + + controlsContainer = ui.getControls().getControlsContainer(); + + const contextMenus = + videoContainer.getElementsByClassName('shaka-context-menu'); + expect(contextMenus.length).toBe(1); + contextMenu = /** @type {!HTMLElement} */ + (contextMenus[0]); + }); + + it('responds to contextmenu event', () => { + expect(contextMenu.classList.contains('shaka-hidden')).toBe(true); + UiUtils.simulateEvent(controlsContainer, 'contextmenu'); + expect(contextMenu.classList.contains('shaka-hidden')).toBe(false); + UiUtils.simulateEvent(controlsContainer, 'contextmenu'); + expect(contextMenu.classList.contains('shaka-hidden')).toBe(true); + }); + it('hides on click event', () => { + UiUtils.simulateEvent(controlsContainer, 'contextmenu'); + UiUtils.simulateEvent(controlsContainer, 'click'); + expect(contextMenu.classList.contains('shaka-hidden')).toBe(true); + UiUtils.simulateEvent(controlsContainer, 'contextmenu'); + UiUtils.simulateEvent(window, 'click'); + expect(contextMenu.classList.contains('shaka-hidden')).toBe(true); + }); + it('builds internal elements', () => { + expect(contextMenu.childNodes.length).toBe(1); + + expect(contextMenu.childNodes[0]['className']) + .toBe('shaka-statistics-button'); + }); + }); + + describe('statistics context menu', () => { + /** @type {!HTMLElement} */ + let statisticsButton; + /** @type {!HTMLElement} */ + let statisticsContainer; + + beforeEach(() => { + const config = { + customContextMenu: true, + contextMenuElements: [ + 'statistics', + ], + statisticsList: Object.keys(new shaka.util.Stats().getBlob()), + }; + const ui = UiUtils.createUIThroughAPI(videoContainer, video, config); + player = ui.getControls().getLocalPlayer(); + + const statisticsButtons = + videoContainer.getElementsByClassName('shaka-statistics-button'); + expect(statisticsButtons.length).toBe(1); + statisticsButton = /** @type {!HTMLElement} */ + (statisticsButtons[0]); + + const statisticsContainers = + videoContainer.getElementsByClassName('shaka-statistics-container'); + expect(statisticsContainers.length).toBe(1); + statisticsContainer = /** @type {!HTMLElement} */ + (statisticsContainers[0]); + }); + + it('appears and disappears on toggle', () => { + expect(statisticsContainer.classList.contains('shaka-hidden')) + .toBe(true); + + statisticsButton.click(); + expect(statisticsContainer.classList.contains('shaka-hidden')) + .toBe(false); + + statisticsButton.click(); + expect(statisticsContainer.classList.contains('shaka-hidden')) + .toBe(true); + }); + + it('displays all the available statistics', () => { + const skippedStats = ['stateHistory', 'switchHistory']; + const nodes = statisticsContainer.childNodes; + let nodeIndex = 0; + + 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; + } + } + }); + it('is updated periodically', async () => { + function getStatsFromContainer() { + const nodes = statisticsContainer.childNodes; + width = nodes[0].childNodes[1].textContent.replace(' (px)', ''); + height = nodes[1].childNodes[1].textContent.replace(' (px)', ''); + bufferingTime = + nodes[13].childNodes[1].textContent.replace(' (s)', ''); + } + + /** @type {!string} */ + let width; + /** @type {!string} */ + let height; + /** @type {!string} */ + let bufferingTime; + /** @type {!string} */ + let lastBufferingTime; + + const manifest = + shaka.test.ManifestGenerator.generate((manifest) => { + manifest.addVariant(/* id= */ 0, (variant) => { + variant.addVideo(1, (stream) => { + stream.size(1920, 1080); + }); + }); + }); + + shaka.media.ManifestParser.registerParserByMime( + fakeMimeType, () => new shaka.test.FakeManifestParser(manifest)); + + await player.load( + /* uri= */ 'fake', /* startTime= */ 0, fakeMimeType); + + // Placeholder statistics are available before toggle + getStatsFromContainer(); + expect(width).toBe('NaN'); + expect(height).toBe('NaN'); + expect(bufferingTime).toBe('NaN'); + + // Statistics are displayed on toggle + statisticsButton.click(); + await Util.delay(0.2); + + getStatsFromContainer(); + expect(width).toBe('1920'); + expect(height).toBe('1080'); + expect(bufferingTime).toBeGreaterThanOrEqual(0.1); + + // Statistics are updated over time + lastBufferingTime = bufferingTime; + await Util.delay(0.2); + + getStatsFromContainer(); + expect(bufferingTime).toBeGreaterThan(lastBufferingTime); + + // Statistics stop updating when the container is hidden + statisticsButton.click(); + lastBufferingTime = bufferingTime; + await Util.delay(0.2); + + getStatsFromContainer(); + expect(bufferingTime).toBe(lastBufferingTime); + }); + }); }); diff --git a/ui/context_menu.js b/ui/context_menu.js new file mode 100644 index 0000000000..0a90ae5d57 --- /dev/null +++ b/ui/context_menu.js @@ -0,0 +1,109 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + + +goog.provide('shaka.ui.ContextMenu'); + +goog.require('goog.asserts'); +goog.require('shaka.log'); +goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Utils'); +goog.require('shaka.util.Dom'); +goog.requireType('shaka.ui.Controls'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.ContextMenu = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + /** @private {!shaka.extern.UIConfiguration} */ + this.config_ = this.controls.getConfig(); + + /** @private {HTMLElement} */ + this.controlsContainer_ = this.controls.getControlsContainer(); + + /** @private {!Array.} */ + this.children_ = []; + + /** @private {!HTMLElement} */ + this.contextMenu_ = shaka.util.Dom.createHTMLElement('div'); + this.contextMenu_.classList.add('shaka-no-propagation'); + this.contextMenu_.classList.add('shaka-context-menu'); + this.contextMenu_.classList.add('shaka-hidden'); + + this.controlsContainer_.appendChild(this.contextMenu_); + + this.eventManager.listen(this.controlsContainer_, 'contextmenu', (e) => { + if (this.contextMenu_.classList.contains('shaka-hidden')) { + e.preventDefault(); + + const controlsLocation = + this.controlsContainer_.getBoundingClientRect(); + this.contextMenu_.style.left = `${e.clientX - controlsLocation.left}px`; + this.contextMenu_.style.top = `${e.clientY - controlsLocation.top}px`; + + shaka.ui.Utils.setDisplay(this.contextMenu_, true); + } else { + shaka.ui.Utils.setDisplay(this.contextMenu_, false); + } + }); + + this.eventManager.listen(window, 'click', () => { + shaka.ui.Utils.setDisplay(this.contextMenu_, false); + }); + + this.createChildren_(); + } + + /** @override */ + release() { + this.controlsContainer_ = null; + + for (const element of this.children_) { + element.release(); + } + + this.children_ = []; + super.release(); + } + + /** + * @param {string} name + * @param {!shaka.extern.IUIElement.Factory} factory + * @export + */ + static registerElement(name, factory) { + shaka.ui.ContextMenu.elementNamesToFactories_.set(name, factory); + } + + /** + * @private + */ + createChildren_() { + for (const name of this.config_.contextMenuElements) { + const factory = + shaka.ui.ContextMenu.elementNamesToFactories_.get(name); + if (factory) { + goog.asserts.assert(this.controls, 'Controls should not be null!'); + this.children_.push(factory.create(this.contextMenu_, this.controls)); + } else { + shaka.log.alwaysWarn('Unrecognized context menu element:', name); + } + } + } +}; + +/** @private {!Map.} */ +shaka.ui.ContextMenu.elementNamesToFactories_ = new Map(); diff --git a/ui/controls.js b/ui/controls.js index b27143d20a..e87b4fce78 100644 --- a/ui/controls.js +++ b/ui/controls.js @@ -16,6 +16,7 @@ goog.require('shaka.log'); goog.require('shaka.ui.AdCounter'); goog.require('shaka.ui.AdPosition'); goog.require('shaka.ui.BigPlayButton'); +goog.require('shaka.ui.ContextMenu'); goog.require('shaka.ui.Locales'); goog.require('shaka.ui.Localization'); goog.require('shaka.ui.SeekBar'); @@ -323,6 +324,10 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.playButton_ = null; } + if (this.contextMenu_) { + this.contextMenu_ = null; + } + if (this.controlsContainer_) { shaka.util.Dom.removeAllChildren(this.controlsContainer_); this.releaseChildElements_(); @@ -651,6 +656,10 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.addPlayButton_(); } + if (this.config_.customContextMenu) { + this.addContextMenu_(); + } + if (!this.spinnerContainer_) { this.addBufferingSpinner_(); } @@ -709,6 +718,14 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.elements_.push(this.playButton_); } + /** @private */ + addContextMenu_() { + /** @private {shaka.ui.ContextMenu} */ + this.contextMenu_ = + new shaka.ui.ContextMenu(this.controlsButtonPanel_, this); + this.elements_.push(this.contextMenu_); + } + /** @private */ addScrimContainer_() { // This is the container that gets styled by CSS to have the diff --git a/ui/enums.js b/ui/enums.js index 29c1c6b210..f64d12ad1c 100644 --- a/ui/enums.js +++ b/ui/enums.js @@ -42,4 +42,6 @@ shaka.ui.Enums.MaterialDesignIcons = { 'UNLOOP': 'repeat_on', 'AIRPLAY': 'airplay', 'REPLAY': 'replay', + 'STATISTICS_ON': 'insert_chart_outlined', + 'STATISTICS_OFF': 'insert_chart', }; diff --git a/ui/externs/ui.js b/ui/externs/ui.js index 98dc9624bb..3780ce3387 100644 --- a/ui/externs/ui.js +++ b/ui/externs/ui.js @@ -63,8 +63,11 @@ shaka.extern.UIVolumeBarColors; * @typedef {{ * controlPanelElements: !Array., * overflowMenuButtons: !Array., + * contextMenuElements: !Array., + * statisticsList: !Array., * addSeekBar: boolean, * addBigPlayButton: boolean, + * customContextMenu: boolean, * castReceiverAppId: string, * clearBufferOnQualityChange: boolean, * showUnbufferedStart: boolean, @@ -82,11 +85,17 @@ shaka.extern.UIVolumeBarColors; * The ordered list of control panel elements of the UI. * @property {!Array.} overflowMenuButtons * The ordered list of the overflow menu buttons. + * @property {!Array.} contextMenuElements + * The ordered list of buttons in the context menu. + * @property {!Array.} statisticsList + * The ordered list of statistics present in the statistics container. * @property {boolean} addSeekBar * Whether or not a seek bar should be part of the UI. * @property {boolean} addBigPlayButton * Whether or not a big play button in the center of the video * should be part of the UI. + * @property {boolean} customContextMenu + * Whether or not a custom context menu replaces the default. * @property {string} castReceiverAppId * Receiver app id to use for the Chromecast support. * @property {boolean} clearBufferOnQualityChange diff --git a/ui/less/containers.less b/ui/less/containers.less index d912b12706..2d26924989 100644 --- a/ui/less/containers.less +++ b/ui/less/containers.less @@ -192,6 +192,72 @@ align-items: center; } +.shaka-statistics-container { + overflow-x: hidden; + overflow-y: auto; + + min-width: 300px; + + color: white; + background-color: rgba(35, 35, 35, 0.9); + + font-size: 14px; + + padding: 5px 10px; + border-radius: 2px; + + position: absolute; + z-index: 2; + left: 15px; + top: 15px; + + /* Fades out with the other controls. */ + .show-when-controls-shown(); + + div { + display: flex; + justify-content: space-between; + } + + span { + color: rgb(150, 150, 150); + } +} + +.shaka-context-menu { + background-color: rgba(35, 35, 35, 0.9); + + border-radius: 2px; + + position: absolute; + z-index: 3; + + button { + padding: 5px 10px; + + display: flex; + align-items: center; + + color: white; + background: transparent; + border: 0; + cursor: pointer; + + &:hover { + background-color: rgba(50, 50, 50, 0.9); + } + } + + label { + padding: 0 20px; + + align-items: flex-start; + + color: white; + cursor: pointer; + } +} + .shaka-scrim-container { margin: 0; .fill-container(); diff --git a/ui/locales/en.json b/ui/locales/en.json index acff4c6ed3..f968fafd3d 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -33,6 +33,7 @@ "SEEK": "Seek", "SKIP_AD": "Skip Ad", "SKIP_TO_LIVE": "Skip ahead to live", + "STATISTICS": "Statistics", "SUBTITLE_FORCED": "Forced", "UNDETERMINED_LANGUAGE": "Undetermined", "UNMUTE": "Unmute", diff --git a/ui/locales/source.json b/ui/locales/source.json index 0eaa2fde6b..1070d5a4c1 100644 --- a/ui/locales/source.json +++ b/ui/locales/source.json @@ -138,6 +138,10 @@ "description": "Label for a button used to skip ahead to the live broadcast.", "message": "Skip ahead to live" }, + "STATISTICS": { + "description": "Label for a button that displays statistics of the video.", + "message": "Statistics" + }, "SUBTITLE_FORCED": { "description": "Label used to identify a subtitle track that is forced to be shown.", "message": "Forced" diff --git a/ui/statistics_button.js b/ui/statistics_button.js new file mode 100644 index 0000000000..1f18085f65 --- /dev/null +++ b/ui/statistics_button.js @@ -0,0 +1,248 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + + +goog.provide('shaka.ui.StatisticsButton'); + +goog.require('shaka.log'); +goog.require('shaka.ui.ContextMenu'); +goog.require('shaka.ui.Controls'); +goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Enums'); +goog.require('shaka.ui.Locales'); +goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.Utils'); +goog.require('shaka.util.Dom'); +goog.require('shaka.util.Timer'); +goog.requireType('shaka.ui.Controls'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.StatisticsButton = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + /** @private {!HTMLButtonElement} */ + this.button_ = shaka.util.Dom.createButton(); + this.button_.classList.add('shaka-statistics-button'); + + /** @private {!HTMLElement} */ + this.icon_ = shaka.util.Dom.createHTMLElement('i'); + this.icon_.classList.add('material-icons-round'); + this.icon_.textContent = + shaka.ui.Enums.MaterialDesignIcons.STATISTICS_ON; + this.button_.appendChild(this.icon_); + + const label = shaka.util.Dom.createHTMLElement('label'); + label.classList.add('shaka-overflow-button-label'); + + /** @private {!HTMLElement} */ + this.nameSpan_ = shaka.util.Dom.createHTMLElement('span'); + label.appendChild(this.nameSpan_); + + /** @private {!HTMLElement} */ + this.stateSpan_ = shaka.util.Dom.createHTMLElement('span'); + label.appendChild(this.stateSpan_); + + this.button_.appendChild(label); + + this.parent.appendChild(this.button_); + + /** @private {!HTMLElement} */ + this.container_ = shaka.util.Dom.createHTMLElement('div'); + this.container_.classList.add('shaka-no-propagation'); + this.container_.classList.add('shaka-show-controls-on-mouse-over'); + this.container_.classList.add('shaka-statistics-container'); + this.container_.classList.add('shaka-hidden'); + + const controlsContainer = this.controls.getControlsContainer(); + controlsContainer.appendChild(this.container_); + + /** @private {!Array} */ + this.statisticsList_ = []; + + /** @private {!Array} */ + this.skippedStats_ = ['stateHistory', 'switchHistory']; + + /** @private {!Object.} */ + this.currentStats_ = this.player.getStats(); + + /** @private {!Object.} */ + this.displayedElements_ = {}; + + + const parsePx = (name) => { + return this.currentStats_[name] + ' (px)'; + }; + + const parsePercent = (name) => { + return this.currentStats_[name] + ' (%)'; + }; + + const parseFrames = (name) => { + return this.currentStats_[name] + ' (frames)'; + }; + + const parseSeconds = (name) => { + return this.currentStats_[name].toFixed(2) + ' (s)'; + }; + + const parseBits = (name) => { + return Math.round(this.currentStats_[name] / 1000) + ' (kbits/s)'; + }; + + const parseTime = (name) => { + return shaka.ui.Utils.buildTimeString( + this.currentStats_[name], false) + ' (m)'; + }; + + /** @private {!Object.} */ + this.parseFrom_ = { + 'width': parsePx, + 'height': parsePx, + 'completionPercent': parsePercent, + 'bufferingTime': parseSeconds, + 'drmTimeSeconds': parseSeconds, + 'licenseTime': parseSeconds, + 'liveLatency': parseSeconds, + 'loadLatency': parseSeconds, + 'manifestTimeSeconds': parseSeconds, + 'estimatedBandwidth': parseBits, + 'streamBandwidth': parseBits, + 'maxSegmentDuration': parseTime, + 'pauseTime': parseTime, + 'playTime': parseTime, + 'corruptedFrames': parseFrames, + 'decodedFrames': parseFrames, + 'droppedFrames': parseFrames, + }; + + /** @private {shaka.util.Timer} */ + this.timer_ = new shaka.util.Timer(() => { + this.onTimerTick_(); + }); + + this.updateLocalizedStrings_(); + + this.loadContainer_(); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => { + this.updateLocalizedStrings_(); + }); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => { + this.updateLocalizedStrings_(); + }); + + this.eventManager.listen(this.button_, 'click', () => { + this.onClick_(); + this.updateLocalizedStrings_(); + }); + } + + /** @private */ + onClick_() { + shaka.ui.Utils.setDisplay(this.parent, false); + + if (this.container_.classList.contains('shaka-hidden')) { + this.icon_.textContent = + shaka.ui.Enums.MaterialDesignIcons.STATISTICS_OFF; + this.timer_.tickEvery(0.1); + shaka.ui.Utils.setDisplay(this.container_, true); + } else { + this.icon_.textContent = + shaka.ui.Enums.MaterialDesignIcons.STATISTICS_ON; + this.timer_.stop(); + shaka.ui.Utils.setDisplay(this.container_, false); + } + } + + /** @private */ + updateLocalizedStrings_() { + const LocIds = shaka.ui.Locales.Ids; + + this.nameSpan_.textContent = + this.localization.resolve(LocIds.STATISTICS); + + this.button_.ariaLabel = this.localization.resolve(LocIds.STATISTICS); + + const labelText = this.container_.classList.contains('shaka-hidden') ? + LocIds.OFF : LocIds.ON; + this.stateSpan_.textContent = this.localization.resolve(labelText); + } + + /** @private */ + generateComponent_(name) { + const section = shaka.util.Dom.createHTMLElement('div'); + + const label = shaka.util.Dom.createHTMLElement('label'); + label.textContent = name + ':'; + section.appendChild(label); + + const value = shaka.util.Dom.createHTMLElement('span'); + value.textContent = this.parseFrom_[name](name); + section.appendChild(value); + + this.displayedElements_[name] = value; + + return section; + } + + /** @private */ + loadContainer_() { + for (const name of this.controls.getConfig().statisticsList) { + if (name in this.currentStats_ && !this.skippedStats_.includes(name)) { + this.container_.appendChild(this.generateComponent_(name)); + this.statisticsList_.push(name); + } else { + shaka.log.alwaysWarn('Unrecognized statistic element:', name); + } + } + } + + /** @private */ + onTimerTick_() { + this.currentStats_ = this.player.getStats(); + + for (const name of this.statisticsList_) { + this.displayedElements_[name].textContent = + this.parseFrom_[name](name); + } + } + + /** @override */ + release() { + this.timer_.stop(); + this.timer_ = null; + super.release(); + } +}; + + +/** + * @implements {shaka.extern.IUIElement.Factory} + * @final + */ +shaka.ui.StatisticsButton.Factory = class { + /** @override */ + create(rootElement, controls) { + return new shaka.ui.StatisticsButton(rootElement, controls); + } +}; + + +shaka.ui.ContextMenu.registerElement( + 'statistics', new shaka.ui.StatisticsButton.Factory()); diff --git a/ui/ui.js b/ui/ui.js index a6977db005..c0ec067be0 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -187,8 +187,31 @@ shaka.ui.Overlay = class { 'cast', 'playback_rate', ], + statisticsList: [ + 'width', + 'height', + 'corruptedFrames', + 'decodedFrames', + 'droppedFrames', + 'drmTimeSeconds', + 'licenseTime', + 'liveLatency', + 'loadLatency', + 'bufferingTime', + 'manifestTimeSeconds', + 'estimatedBandwidth', + 'streamBandwidth', + 'maxSegmentDuration', + 'pauseTime', + 'playTime', + 'completionPercent', + ], + contextMenuElements: [ + 'statistics', + ], addSeekBar: true, addBigPlayButton: false, + customContextMenu: false, castReceiverAppId: '', clearBufferOnQualityChange: true, showUnbufferedStart: false,