Skip to content

Commit

Permalink
feat(ui): Add right-click context menu, statistics button (shaka-proj…
Browse files Browse the repository at this point in the history
  • Loading branch information
nbcl committed Aug 5, 2021
1 parent 0535e2f commit afb9310
Show file tree
Hide file tree
Showing 13 changed files with 688 additions and 0 deletions.
2 changes: 2 additions & 0 deletions build/types/ui
Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/tutorials/ui-customization.md
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions shaka-player.uncompiled.js
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
176 changes: 176 additions & 0 deletions test/ui/ui_unit.js
Expand Up @@ -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);
});
});
});


Expand Down
109 changes: 109 additions & 0 deletions 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.<shaka.extern.IUIElement>} */
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.<string, !shaka.extern.IUIElement.Factory>} */
shaka.ui.ContextMenu.elementNamesToFactories_ = new Map();

0 comments on commit afb9310

Please sign in to comment.