shaka-player
Version:
DASH/EME video player library
732 lines (610 loc) • 24.9 kB
JavaScript
/**
* @license
* Copyright 2016 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
describe('UI', function() {
/** @type {shaka.Player} */
let player;
/** @type {!Element} */
let cssLink;
beforeAll(async () => {
// Add css file
cssLink = document.createElement('link');
await shaka.test.Util.setupCSS(cssLink);
});
afterEach(async () => {
await shaka.test.Util.cleanupUI();
});
afterAll(() => {
document.head.removeChild(cssLink);
});
describe('constructed through API', function() {
/** @type {!HTMLElement} */
let videoContainer;
/** @type {!HTMLVideoElement} */
let video;
beforeEach(() => {
videoContainer =
/** @type {!HTMLElement} */ (document.createElement('div'));
document.body.appendChild(videoContainer);
video = shaka.util.Dom.createVideoElement();
videoContainer.appendChild(video);
createUIThroughAPI(videoContainer, video);
});
it('has all the basic elements', function() {
checkBasicUIElements(videoContainer);
});
});
describe('constructed through DOM auto-setup', function() {
describe('set up with one container', function() {
/** @type {!HTMLElement} */
let container;
beforeEach(async () => {
container =
/** @type {!HTMLElement} */ (document.createElement('div'));
document.body.appendChild(container);
await createUIThroughDOMAutoSetup([container], /* videos */ []);
});
it('has all the basic elements', function() {
checkBasicUIElements(container);
});
});
describe('set up with several containers', function() {
/** @type {!HTMLElement} */
let container1;
/** @type {!HTMLElement} */
let container2;
beforeEach(async () => {
container1 =
/** @type {!HTMLElement} */ (document.createElement('div'));
document.body.appendChild(container1);
container2 =
/** @type {!HTMLElement} */ (document.createElement('div'));
document.body.appendChild(container2);
await createUIThroughDOMAutoSetup(
[container1, container2], /* videos */[]);
});
it('has all the basic elements', function() {
checkBasicUIElements(container1);
checkBasicUIElements(container2);
});
});
describe('set up with one video', function() {
/** @type {!HTMLVideoElement} */
let video;
beforeEach(async () => {
video = shaka.util.Dom.createVideoElement();
document.body.appendChild(video);
await createUIThroughDOMAutoSetup(/* containers */ [], [video]);
});
it('has all the basic elements', function() {
checkBasicUIElements(
/** @type {!HTMLVideoElement} */ (video.parentElement));
});
});
describe('set up with several videos', function() {
/** @type {!Array.<!HTMLVideoElement>} */
let videos = [];
beforeEach(async () => {
// Four is just a random number I (ismena) came up with to test a
// multi-video use case. It could be replaces with any other
// (reasonable) number.
for (let i = 0; i < 4; i++) {
let video = /** @type {!HTMLVideoElement} */
(document.createElement('video'));
document.body.appendChild(video);
videos.push(video);
}
await createUIThroughDOMAutoSetup(/* containers */ [], videos);
});
it('has all the basic elements', function() {
videos.forEach(function(video) {
checkBasicUIElements(
/** @type {!HTMLVideoElement} */ (video.parentElement));
});
});
});
describe('set up with a video and a container', function() {
/** @type {!HTMLElement} */
let container;
/** @type {!HTMLVideoElement} */
let video;
beforeEach(async () => {
container =
/** @type {!HTMLElement} */ (document.createElement('div'));
document.body.appendChild(container);
video = shaka.util.Dom.createVideoElement();
container.appendChild(video);
await createUIThroughDOMAutoSetup([container], [video]);
});
it('has all the basic elements', function() {
checkBasicUIElements(container);
});
});
});
describe('controls', function() {
/** @type {!HTMLElement} */
let videoContainer;
/** @type {!HTMLVideoElement} */
let video;
beforeEach(function() {
videoContainer =
/** @type {!HTMLElement} */ (document.createElement('div'));
document.body.appendChild(videoContainer);
video = shaka.util.Dom.createVideoElement();
videoContainer.appendChild(video);
});
it('goes into fullscreen on double click', async () => {
const config = {
controlPanelElements: [
'overflow_menu',
],
overflowMenuButtons: [
'quality',
],
doubleClickForFullscreen: false,
};
const ui = createUIThroughAPI(videoContainer, video, config);
const controls = ui.getControls();
const spy = spyOn(controls, 'toggleFullScreen');
const controlsContainer =
videoContainer.querySelector('.shaka-controls-container');
// When double-click for fullscreen is disabled, it shouldn't happen.
shaka.test.UiUtils.simulateEvent(controlsContainer, 'dblclick');
await shaka.test.Util.delay(0.1);
expect(spy).not.toHaveBeenCalled();
// Change the configuration and try again.
config.doubleClickForFullscreen = true;
(/** @type {!shaka.ui.Overlay} */ (ui)).configure(config);
shaka.test.UiUtils.simulateEvent(controlsContainer, 'dblclick');
await shaka.test.Util.delay(0.1);
expect(spy).toHaveBeenCalledTimes(1);
});
describe('all the controls', function() {
/** @type {!HTMLElement} */
let controlsContainer;
beforeEach(function() {
createUIThroughAPI(videoContainer, video);
let controlsContainers =
videoContainer.getElementsByClassName('shaka-controls-container');
expect(controlsContainers.length).toBe(1);
controlsContainer = /** @type {!HTMLElement} */ (controlsContainers[0]);
});
it('stay visible if overflow menuButton is open', function() {
let overflowMenus =
videoContainer.getElementsByClassName('shaka-overflow-menu');
expect(overflowMenus.length).toBe(1);
let overflowMenu = /** @type {!HTMLElement} */ (overflowMenus[0]);
let overflowMenuButtons =
videoContainer.getElementsByClassName('shaka-overflow-menu-button');
expect(overflowMenuButtons.length).toBe(1);
let overflowMenuButton = overflowMenuButtons[0];
overflowMenuButton.click();
expect(overflowMenu.style.display).not.toEqual('none');
expect(controlsContainer.style.display).not.toEqual('none');
});
});
describe('overflow menu', function() {
/** @type {!HTMLElement} */
let overflowMenu;
beforeEach(function() {
let config = {
controlPanelElements: [
'overflow_menu',
],
};
createUIThroughAPI(videoContainer, video, config);
let overflowMenus =
videoContainer.getElementsByClassName('shaka-overflow-menu');
expect(overflowMenus.length).toBe(1);
overflowMenu = /** @type {!HTMLElement} */ (overflowMenus[0]);
});
it('has default buttons', function() {
confirmElementFound(overflowMenu, 'shaka-caption-button');
confirmElementFound(overflowMenu, 'shaka-resolution-button');
confirmElementFound(overflowMenu, 'shaka-language-button');
confirmElementFound(overflowMenu, 'shaka-pip-button');
});
it('becomes visible if overflowMenuButton was clicked', function() {
let display = window.getComputedStyle(overflowMenu, null).display;
expect(display).toEqual('none');
let overflowMenuButtons =
videoContainer.getElementsByClassName('shaka-overflow-menu-button');
expect(overflowMenuButtons.length).toBe(1);
let overflowMenuButton = overflowMenuButtons[0];
overflowMenuButton.click();
display = overflowMenu.style.display;
expect(display).not.toEqual('none');
});
it('allows picture-in-picture only when the content has video',
async () => {
// Load fake content that contains only audio.
const manifest = new shaka.test.ManifestGenerator()
.addPeriod(/* startTime= */ 0)
.addVariant(/* id= */ 0)
.addAudio(/* id= */ 1)
.build();
const parser = new shaka.test.FakeManifestParser(manifest);
const factory = function() { return parser; };
await player.load(/* uri= */ 'fake', /* startTime= */ 0, factory);
const pipButtons =
videoContainer.getElementsByClassName('shaka-pip-button');
expect(pipButtons.length).toBe(1);
const pipButton = pipButtons[0];
// The picture-in-picture button should not be shown when the content
// only has audio.
expect(pipButton.classList.contains('shaka-hidden')).toBe(true);
// The picture-in-picture window should not be open when the content
// only has audio.
expect(document.pictureInPictureElement).toBeFalsy();
});
it('is accessible', function() {
for (let button of overflowMenu.childNodes) {
expect(/** @type {!HTMLElement} */ (button)
.hasAttribute('aria-label')).toBe(true);
}
});
});
describe('controls-button-panel', function() {
/** @type {!HTMLElement} */
let controlsButtonPanel;
it('has default elements', function() {
createUIThroughAPI(videoContainer, video);
let controlsButtonPanels = videoContainer.getElementsByClassName(
'shaka-controls-button-panel');
expect(controlsButtonPanels.length).toBe(1);
controlsButtonPanel =
/** @type {!HTMLElement} */ (controlsButtonPanels[0]);
confirmElementFound(controlsButtonPanel, 'shaka-current-time');
confirmElementFound(controlsButtonPanel, 'shaka-mute-button');
confirmElementFound(controlsButtonPanel, 'shaka-volume-bar');
confirmElementFound(controlsButtonPanel, 'shaka-fullscreen-button');
confirmElementFound(controlsButtonPanel, 'shaka-overflow-menu-button');
});
it('is accessible', function() {
function confirmAriaLabel(className) {
const elements =
controlsButtonPanel.getElementsByClassName(className);
expect(elements.length).toBe(1);
expect(elements[0].hasAttribute('aria-label')).toBe(true);
}
const config = {
controlPanelElements: [
'mute',
'volume',
'fullscreen',
'overflow_menu',
'fast_forward',
'rewind',
],
};
createUIThroughAPI(videoContainer, video, config);
const controlsButtonPanels = videoContainer.getElementsByClassName(
'shaka-controls-button-panel');
expect(controlsButtonPanels.length).toBe(1);
controlsButtonPanel =
/** @type {!HTMLElement} */ (controlsButtonPanels[0]);
confirmAriaLabel('shaka-mute-button');
confirmAriaLabel('shaka-volume-bar');
confirmAriaLabel('shaka-fullscreen-button');
confirmAriaLabel('shaka-overflow-menu-button');
confirmAriaLabel('shaka-fast-forward-button');
confirmAriaLabel('shaka-rewind-button');
});
});
describe('resolutions menu', function() {
/** @type {!HTMLElement} */
let resolutionsMenu;
/** @type {shaka.ui.Controls} */
let controls;
beforeEach(function() {
let config = {
controlPanelElements: [
'overflow_menu',
],
overflowMenuButtons: [
'quality',
],
};
const ui = createUIThroughAPI(videoContainer, video, config);
controls = ui.getControls();
player = controls.getLocalPlayer();
let resolutionsMenus =
videoContainer.getElementsByClassName('shaka-resolutions');
expect(resolutionsMenus.length).toBe(1);
resolutionsMenu = /** @type {!HTMLElement} */ (resolutionsMenus[0]);
});
it('becomes visible if resolutionButton was clicked', function() {
let display = window.getComputedStyle(resolutionsMenu, null).display;
expect(display).toEqual('none');
let resolutionButtons =
videoContainer.getElementsByClassName('shaka-resolution-button');
expect(resolutionButtons.length).toBe(1);
let resolutionButton = resolutionButtons[0];
resolutionButton.click();
display = resolutionsMenu.style.display;
expect(display).not.toEqual('none');
});
it('clears the buffer when changing resolutions', async () => {
// Load fake content that has more than one quality level.
const manifest = new shaka.test.ManifestGenerator()
.addPeriod(0)
.addVariant(0)
.addVideo(1).size(320, 240)
.addVideo(2).size(640, 480)
.build();
const parser = new shaka.test.FakeManifestParser(manifest);
const factory = function() { return parser; };
await player.load(/* uri */ 'fake', /* startTime */ 0, factory);
const selectVariantTrack = spyOn(player, 'selectVariantTrack');
// There should be at least one explicit quality button.
const qualityButton =
videoContainer.querySelectorAll('button.explicit-resolution')[0];
expect(qualityButton).toBeDefined();
// Clicking this should select a track and clear the buffer.
expect(selectVariantTrack).not.toHaveBeenCalled();
qualityButton.click();
// The second argument is "clearBuffer", and should be true.
expect(selectVariantTrack).toHaveBeenCalledWith(
jasmine.any(Object), true);
});
it('displays resolutions based on current stream', async () => {
/* eslint-disable indent */
// A manifest with different resolutions at different
// languages/channel-counts to test the current resolution list is
// filtered.
const manifest = new shaka.test.ManifestGenerator()
.addPeriod(0)
.addVariant(0)
.primary()
.language('en')
.addVideo(1).size(320, 240)
.addAudio(3).channelsCount(2)
.addVariant(4)
.language('en')
.addVideo(5).size(640, 480)
.addAudio(6).channelsCount(2)
.addVariant(7) // Duplicate with 4
.language('en')
.addVideo(8).size(640, 480)
.addAudio(9).channelsCount(2)
.addVariant(10)
.language('en')
.addVideo(11).size(1280, 720)
.addAudio(12).channelsCount(1)
.addVariant(13)
.language('es')
.addVideo(14).size(960, 540)
.addAudio(15).channelsCount(2)
.addVariant(16)
.language('fr')
.addVideo(17).size(256, 144)
.addAudio(18).channelsCount(2)
.build();
/* eslint-enable indent */
const getResolutions = () => {
const resolutionButtons = videoContainer.querySelectorAll(
'button.explicit-resolution > span');
return Array.from(resolutionButtons)
.map((btn) => btn.innerText)
.sort();
};
await player.load(
/* uri= */ 'fake', /* startTime= */ 0, function() {
return new shaka.test.FakeManifestParser(manifest);
});
player.configure('abr.enabled', false);
const tracks = player.getVariantTracks();
const en2 =
tracks.find((t) => t.language == 'en' && t.channelsCount == 2);
const en1 =
tracks.find((t) => t.language == 'en' && t.channelsCount == 1);
const es = tracks.find((t) => t.language == 'es');
// There are 3 variants with English 2-channel, but one is a duplicate
// and shouldn't appear in the list.
goog.asserts.assert(en2, 'Unable to find tracks');
player.selectVariantTrack(en2, true);
await updateResolutionMenu();
expect(getResolutions()).toEqual(['240p', '480p']);
// There is 1 variant with English 1-channel.
goog.asserts.assert(en1, 'Unable to find tracks');
player.selectVariantTrack(en1, true);
await updateResolutionMenu();
expect(getResolutions()).toEqual(['720p']);
// There is 1 variant with Spanish 2-channel.
goog.asserts.assert(es, 'Unable to find tracks');
player.selectVariantTrack(es, true);
await updateResolutionMenu();
expect(getResolutions()).toEqual(['540p']);
});
/**
* Use internals to update the resolution menu. Our fake manifest can
* cause problems with startup where the Player will get stuck using
* "deferred" switches, so we won't get events and the resolution menu
* won't update.
*
* @suppress {accessControls}
*/
async function updateResolutionMenu() {
await shaka.test.Util.delay(0.1);
// TODO(#2089): We should be able to stop once we find one, but since
// there are multiple ResolutionMenu objects, we need to update all of
// them.
let found = false;
for (const elem of controls.elements_) {
if (elem instanceof shaka.ui.OverflowMenu) {
for (const child of elem.children_) {
if (child instanceof shaka.ui.ResolutionSelection) {
child.updateResolutionSelection_();
found = true;
}
}
}
}
goog.asserts.assert(found, 'Unable to find resolution menu');
}
});
// TODO: integration test to test audio language menu.
});
describe('customization', function() {
/** @type {!HTMLElement} */
let container;
/** @type {!HTMLMediaElement} */
let video;
/** @type {!Object} */
let config;
let warning;
let originalWarning;
beforeEach(function() {
originalWarning = shaka.log.warning;
warning = jasmine.createSpy('shaka.log.warning');
shaka.log.warning = shaka.test.Util.spyFunc(warning);
warning.calls.reset();
container =
/** @type {!HTMLElement} */ (document.createElement('div'));
document.body.appendChild(container);
video = shaka.util.Dom.createVideoElement();
container.appendChild(video);
});
afterEach(function() {
shaka.log.warning = originalWarning;
});
it('only the specified controls are created', function() {
config = {controlPanelElements: ['time_and_duration', 'mute']};
createUIThroughAPI(container, video, config);
// Only current time and mute button should've been created
confirmElementFound(container, 'shaka-current-time');
confirmElementFound(container, 'shaka-mute-button');
confirmElementMissing(container, 'shaka-volume-bar');
confirmElementMissing(container, 'shaka-fullscreen-button');
confirmElementMissing(container, 'shaka-overflow-menu-button');
});
it('only the specified overflow menu buttons are created', function() {
config = {overflowMenuButtons: ['cast']};
createUIThroughAPI(container, video, config);
confirmElementFound(container, 'shaka-cast-button');
confirmElementMissing(container, 'shaka-caption-button');
});
it('seek bar is not created unless configured', function() {
config = {addSeekBar: false};
createUIThroughAPI(container, video, config);
confirmElementMissing(container, 'shaka-seek-bar');
});
it('seek bar is created when configured', function() {
config = {addSeekBar: true};
createUIThroughAPI(container, video, config);
confirmElementFound(container, 'shaka-seek-bar');
});
it('settings menus are positioned lower when seek bar is absent',
function() {
config = {addSeekBar: false};
createUIThroughAPI(container, video, config);
function confirmLowPosition(className) {
const elements =
container.getElementsByClassName(className);
expect(elements.length).toBe(1);
expect(elements[0].classList.contains('shaka-low-position')).toBe(true);
}
confirmElementMissing(container, 'shaka-seek-bar');
confirmLowPosition('shaka-overflow-menu');
confirmLowPosition('shaka-resolutions');
confirmLowPosition('shaka-audio-languages');
confirmLowPosition('shaka-text-languages');
});
it('controls are created in specified order', function() {
config = {controlPanelElements: ['mute', 'time_and_duration',
'fullscreen']};
createUIThroughAPI(container, video, config);
let controlsButtonPanels =
container.getElementsByClassName('shaka-controls-button-panel');
expect(controlsButtonPanels.length).toBe(1);
let controlsButtonPanel =
/** @type {!HTMLElement} */ (controlsButtonPanels[0]);
let buttons = controlsButtonPanel.childNodes;
expect(buttons.length).toBe(3);
expect( /** @type {!HTMLElement} */ (buttons[0]).className)
.toContain('shaka-mute-button');
expect( /** @type {!HTMLElement} */ (buttons[1]).className)
.toContain('shaka-current-time');
expect( /** @type {!HTMLElement} */ (buttons[2]).className)
.toContain('shaka-fullscreen');
});
});
/**
* @param {!HTMLElement} container
* @suppress {visibility}
*/
function checkBasicUIElements(container) {
const videos = container.getElementsByTagName('video');
expect(videos.length).not.toBe(0);
confirmElementFound(container, 'shaka-play-button-container');
confirmElementFound(container, 'shaka-play-button');
confirmElementFound(container, 'shaka-spinner');
confirmElementFound(container, 'shaka-overflow-menu');
confirmElementFound(container, 'shaka-controls-button-panel');
confirmElementFound(container, 'shaka-seek-bar');
}
/**
* @param {!HTMLElement} videoContainer
* @param {!HTMLMediaElement} video
* @param {!Object=} config
* @return {!shaka.ui.Overlay}
*/
function createUIThroughAPI(videoContainer, video, config) {
player = new shaka.Player(video);
// Create UI
config = config || {};
const ui = new shaka.ui.Overlay(player, videoContainer, video);
ui.configure(config);
return ui;
}
/**
* @param {!Array.<!Element>} containers
* @param {!Array.<!Element>} videos
* @suppress {visibility}
*/
async function createUIThroughDOMAutoSetup(containers, videos) {
const eventManager = new shaka.util.EventManager();
const waiter = new shaka.test.Waiter(eventManager);
containers.forEach(function(container) {
container.setAttribute('data-shaka-player-container', '');
});
videos.forEach(function(video) {
video.setAttribute('data-shaka-player', '');
});
// Call UI's private method to scan the page for shaka
// elements and create the UI.
shaka.ui.Overlay.scanPageForShakaElements_();
await waiter.failOnTimeout(false).waitForEvent(document, 'shaka-ui-loaded');
}
/**
* @param {!HTMLElement} parent
* @param {string} className
* @suppress {visibility}
*/
function confirmElementFound(parent, className) {
const elements = parent.getElementsByClassName(className);
expect(elements.length).toBe(1);
}
/**
* @param {!HTMLElement} parent
* @param {string} className
* @suppress {visibility}
*/
function confirmElementMissing(parent, className) {
const elements = parent.getElementsByClassName(className);
expect(elements.length).toBe(0);
}
});