/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
goog.provide('shaka.ui.Overlay');
goog.provide('shaka.ui.Overlay.FailReasonCode');
goog.provide('shaka.ui.Overlay.TrackLabelFormat');
goog.require('goog.asserts');
goog.require('shaka.Player');
goog.require('shaka.log');
goog.require('shaka.polyfill');
goog.require('shaka.ui.Controls');
goog.require('shaka.util.ConfigUtils');
goog.require('shaka.util.Dom');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.Platform');
/**
* @implements {shaka.util.IDestroyable}
* @export
*/
shaka.ui.Overlay = class {
/**
* @param {!shaka.Player} player
* @param {!HTMLElement} videoContainer
* @param {!HTMLMediaElement} video
* @param {?HTMLCanvasElement=} vrCanvas
*/
constructor(player, videoContainer, video, vrCanvas = null) {
/** @private {shaka.Player} */
this.player_ = player;
/** @private {HTMLElement} */
this.videoContainer_ = videoContainer;
/** @private {!shaka.extern.UIConfiguration} */
this.config_ = this.defaultConfig_();
// Make sure this container is discoverable and that the UI can be reached
// through it.
videoContainer['dataset']['shakaPlayerContainer'] = '';
videoContainer['ui'] = this;
// Tag the container for mobile platforms, to allow different styles.
if (this.isMobile()) {
videoContainer.classList.add('shaka-mobile');
}
/** @private {shaka.ui.Controls} */
this.controls_ = new shaka.ui.Controls(
player, videoContainer, video, vrCanvas, this.config_);
// Run the initial setup so that no configure() call is required for default
// settings.
this.configure({});
// If the browser's native controls are disabled, use UI TextDisplayer.
if (!video.controls) {
player.setVideoContainer(videoContainer);
}
videoContainer['ui'] = this;
video['ui'] = this;
}
/**
* @override
* @export
*/
async destroy() {
if (this.controls_) {
await this.controls_.destroy();
}
this.controls_ = null;
if (this.player_) {
await this.player_.destroy();
}
this.player_ = null;
}
/**
* Detects if this is a mobile platform, in case you want to choose a
* different UI configuration on mobile devices.
*
* @return {boolean}
* @export
*/
isMobile() {
return shaka.util.Platform.isMobile();
}
/**
* @return {!shaka.extern.UIConfiguration}
* @export
*/
getConfiguration() {
const ret = this.defaultConfig_();
shaka.util.ConfigUtils.mergeConfigObjects(
ret, this.config_, this.defaultConfig_(),
/* overrides= */ {}, /* path= */ '');
return ret;
}
/**
* @param {string|!Object} config This should either be a field name or an
* object following the form of {@link shaka.extern.UIConfiguration}, where
* you may omit any field you do not wish to change.
* @param {*=} value This should be provided if the previous parameter
* was a string field name.
* @export
*/
configure(config, value) {
goog.asserts.assert(typeof(config) == 'object' || arguments.length == 2,
'String configs should have values!');
// ('fieldName', value) format
if (arguments.length == 2 && typeof(config) == 'string') {
config = shaka.util.ConfigUtils.convertToConfigObject(config, value);
}
goog.asserts.assert(typeof(config) == 'object', 'Should be an object!');
shaka.util.ConfigUtils.mergeConfigObjects(
this.config_, config, this.defaultConfig_(),
/* overrides= */ {}, /* path= */ '');
// If a cast receiver app id has been given, add a cast button to the UI
if (this.config_.castReceiverAppId &&
!this.config_.overflowMenuButtons.includes('cast')) {
this.config_.overflowMenuButtons.push('cast');
}
goog.asserts.assert(this.player_ != null, 'Should have a player!');
this.controls_.configure(this.config_);
this.controls_.dispatchEvent(new shaka.util.FakeEvent('uiupdated'));
}
/**
* @return {shaka.ui.Controls}
* @export
*/
getControls() {
return this.controls_;
}
/**
* Enable or disable the custom controls.
*
* @param {boolean} enabled
* @export
*/
setEnabled(enabled) {
this.controls_.setEnabledShakaControls(enabled);
}
/**
* @return {!shaka.extern.UIConfiguration}
* @private
*/
defaultConfig_() {
const config = {
controlPanelElements: [
'play_pause',
'time_and_duration',
'spacer',
'mute',
'volume',
'fullscreen',
'overflow_menu',
],
overflowMenuButtons: [
'captions',
'quality',
'language',
'chapter',
'picture_in_picture',
'cast',
'playback_rate',
'recenter_vr',
'toggle_stereoscopic',
],
statisticsList: [
'width',
'height',
'corruptedFrames',
'decodedFrames',
'droppedFrames',
'drmTimeSeconds',
'licenseTime',
'liveLatency',
'loadLatency',
'bufferingTime',
'manifestTimeSeconds',
'estimatedBandwidth',
'streamBandwidth',
'maxSegmentDuration',
'pauseTime',
'playTime',
'completionPercent',
'manifestSizeBytes',
'bytesDownloaded',
'nonFatalErrorCount',
'manifestPeriodCount',
'manifestGapCount',
],
adStatisticsList: [
'loadTimes',
'averageLoadTime',
'started',
'playedCompletely',
'skipped',
'errors',
],
contextMenuElements: [
'loop',
'picture_in_picture',
'save_video_frame',
'statistics',
'ad_statistics',
],
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
fastForwardRates: [2, 4, 8, 1],
rewindRates: [-1, -2, -4, -8],
addSeekBar: true,
addBigPlayButton: false,
customContextMenu: false,
castReceiverAppId: '',
castAndroidReceiverCompatible: false,
clearBufferOnQualityChange: true,
showUnbufferedStart: false,
seekBarColors: {
base: 'rgba(255, 255, 255, 0.3)',
buffered: 'rgba(255, 255, 255, 0.54)',
played: 'rgb(255, 255, 255)',
adBreaks: 'rgb(255, 204, 0)',
},
volumeBarColors: {
base: 'rgba(255, 255, 255, 0.54)',
level: 'rgb(255, 255, 255)',
},
trackLabelFormat: shaka.ui.Overlay.TrackLabelFormat.LANGUAGE,
textTrackLabelFormat: shaka.ui.Overlay.TrackLabelFormat.LANGUAGE,
fadeDelay: 0,
doubleClickForFullscreen: true,
singleClickForPlayAndPause: true,
enableKeyboardPlaybackControls: true,
enableFullscreenOnRotation: true,
forceLandscapeOnFullscreen: true,
enableTooltips: false,
keyboardSeekDistance: 5,
keyboardLargeSeekDistance: 60,
fullScreenElement: this.videoContainer_,
preferDocumentPictureInPicture: true,
showAudioChannelCountVariants: true,
seekOnTaps: navigator.maxTouchPoints > 0,
tapSeekDistance: 10,
refreshTickInSeconds: 0.125,
displayInVrMode: false,
defaultVrProjectionMode: 'equirectangular',
setupMediaSession: true,
preferVideoFullScreenInVisionOS: false,
};
// eslint-disable-next-line no-restricted-syntax
if ('remote' in HTMLMediaElement.prototype) {
config.overflowMenuButtons.push('remote');
} else if (window.WebKitPlaybackTargetAvailabilityEvent) {
config.overflowMenuButtons.push('airplay');
}
// On mobile, by default, hide the volume slide and the small play/pause
// button and show the big play/pause button in the center.
// This is in line with default styles in Chrome.
if (this.isMobile()) {
config.addBigPlayButton = true;
config.controlPanelElements = config.controlPanelElements.filter(
(name) => name != 'play_pause' && name != 'volume');
}
// Set this button here to push it at the end.
config.overflowMenuButtons.push('save_video_frame');
return config;
}
/**
* @private
*/
static async scanPageForShakaElements_() {
// Install built-in polyfills to patch browser incompatibilities.
shaka.polyfill.installAll();
// Check to see if the browser supports the basic APIs Shaka needs.
if (!shaka.Player.isBrowserSupported()) {
shaka.log.error('Shaka Player does not support this browser. ' +
'Please see https://tinyurl.com/y7s4j9tr for the list of ' +
'supported browsers.');
// After scanning the page for elements, fire a special "loaded" event for
// when the load fails. This will allow the page to react to the failure.
shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-load-failed',
shaka.ui.Overlay.FailReasonCode.NO_BROWSER_SUPPORT);
return;
}
// Look for elements marked 'data-shaka-player-container'
// on the page. These will be used to create our default
// UI.
const containers = document.querySelectorAll(
'[data-shaka-player-container]');
// Look for elements marked 'data-shaka-player'. They will
// either be used in our default UI or with native browser
// controls.
const videos = document.querySelectorAll(
'[data-shaka-player]');
// Look for elements marked 'data-shaka-player-canvas'
// on the page. These will be used to create our default
// UI.
const canvases = document.querySelectorAll(
'[data-shaka-player-canvas]');
// Look for elements marked 'data-shaka-player-vr-canvas'
// on the page. These will be used to create our default
// UI.
const vrCanvases = document.querySelectorAll(
'[data-shaka-player-vr-canvas]');
if (!videos.length && !containers.length) {
// No elements have been tagged with shaka attributes.
} else if (videos.length && !containers.length) {
// Just the video elements were provided.
for (const video of videos) {
// If the app has already manually created a UI for this element,
// don't create another one.
if (video['ui']) {
continue;
}
goog.asserts.assert(video.tagName.toLowerCase() == 'video',
'Should be a video element!');
const container = document.createElement('div');
const videoParent = video.parentElement;
videoParent.replaceChild(container, video);
container.appendChild(video);
const {lcevcCanvas, vrCanvas} =
shaka.ui.Overlay.findOrMakeSpecialCanvases_(
container, canvases, vrCanvases);
shaka.ui.Overlay.setupUIandAutoLoad_(
container, video, lcevcCanvas, vrCanvas);
}
} else {
for (const container of containers) {
// If the app has already manually created a UI for this element,
// don't create another one.
if (container['ui']) {
continue;
}
goog.asserts.assert(container.tagName.toLowerCase() == 'div',
'Container should be a div!');
let currentVideo = null;
for (const video of videos) {
goog.asserts.assert(video.tagName.toLowerCase() == 'video',
'Should be a video element!');
if (video.parentElement == container) {
currentVideo = video;
break;
}
}
if (!currentVideo) {
currentVideo = document.createElement('video');
currentVideo.setAttribute('playsinline', '');
container.appendChild(currentVideo);
}
const {lcevcCanvas, vrCanvas} =
shaka.ui.Overlay.findOrMakeSpecialCanvases_(
container, canvases, vrCanvases);
try {
// eslint-disable-next-line no-await-in-loop
await shaka.ui.Overlay.setupUIandAutoLoad_(
container, currentVideo, lcevcCanvas, vrCanvas);
} catch (e) {
// This can fail if, for example, not every player file has loaded.
// Ad-block is a likely cause for this sort of failure.
shaka.log.error('Error setting up Shaka Player', e);
shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-load-failed',
shaka.ui.Overlay.FailReasonCode.PLAYER_FAILED_TO_LOAD);
return;
}
}
}
// After scanning the page for elements, fire the "loaded" event. This will
// let apps know they can use the UI library programmatically now, even if
// they didn't have any Shaka-related elements declared in their HTML.
shaka.ui.Overlay.dispatchLoadedEvent_('shaka-ui-loaded');
}
/**
* @param {string} eventName
* @param {shaka.ui.Overlay.FailReasonCode=} reasonCode
* @private
*/
static dispatchLoadedEvent_(eventName, reasonCode) {
let detail = null;
if (reasonCode != undefined) {
detail = {
'reasonCode': reasonCode,
};
}
const uiLoadedEvent = new CustomEvent(eventName, {detail});
document.dispatchEvent(uiLoadedEvent);
}
/**
* @param {!Element} container
* @param {!Element} video
* @param {!Element} lcevcCanvas
* @param {!Element} vrCanvas
* @private
*/
static async setupUIandAutoLoad_(container, video, lcevcCanvas, vrCanvas) {
// Create the UI
const player = new shaka.Player();
const ui = new shaka.ui.Overlay(player,
shaka.util.Dom.asHTMLElement(container),
shaka.util.Dom.asHTMLMediaElement(video),
shaka.util.Dom.asHTMLCanvasElement(vrCanvas));
// Attach Canvas used for LCEVC Decoding
player.attachCanvas(/** @type {HTMLCanvasElement} */(lcevcCanvas));
// Get and configure cast app id.
let castAppId = '';
// Get and configure cast Android Receiver Compatibility
let castAndroidReceiverCompatible = false;
// Cast receiver id can be specified on either container or video.
// It should not be provided on both. If it was, we will use the last
// one we saw.
if (container['dataset'] &&
container['dataset']['shakaPlayerCastReceiverId']) {
castAppId = container['dataset']['shakaPlayerCastReceiverId'];
castAndroidReceiverCompatible =
container['dataset']['shakaPlayerCastAndroidReceiverCompatible'] ===
'true';
} else if (video['dataset'] &&
video['dataset']['shakaPlayerCastReceiverId']) {
castAppId = video['dataset']['shakaPlayerCastReceiverId'];
castAndroidReceiverCompatible =
video['dataset']['shakaPlayerCastAndroidReceiverCompatible'] === 'true';
}
if (castAppId.length) {
ui.configure({castReceiverAppId: castAppId,
castAndroidReceiverCompatible: castAndroidReceiverCompatible});
}
if (shaka.util.Dom.asHTMLMediaElement(video).controls) {
ui.getControls().setEnabledNativeControls(true);
}
// Get the source and load it
// Source can be specified either on the video element:
// <video src='foo.m2u8'></video>
// or as a separate element inside the video element:
// <video>
// <source src='foo.m2u8'/>
// </video>
// It should not be specified on both.
const urls = [];
const src = video.getAttribute('src');
if (src) {
urls.push(src);
video.removeAttribute('src');
}
for (const source of video.getElementsByTagName('source')) {
urls.push(/** @type {!HTMLSourceElement} */ (source).src);
video.removeChild(source);
}
await player.attach(shaka.util.Dom.asHTMLMediaElement(video));
for (const url of urls) {
try { // eslint-disable-next-line no-await-in-loop
await ui.getControls().getPlayer().load(url);
break;
} catch (e) {
shaka.log.error('Error auto-loading asset', e);
}
}
}
/**
* @param {!Element} container
* @param {!NodeList.<!Element>} canvases
* @param {!NodeList.<!Element>} vrCanvases
* @return {{lcevcCanvas: !Element, vrCanvas: !Element}}
* @private
*/
static findOrMakeSpecialCanvases_(container, canvases, vrCanvases) {
let lcevcCanvas = null;
for (const canvas of canvases) {
goog.asserts.assert(canvas.tagName.toLowerCase() == 'canvas',
'Should be a canvas element!');
if (canvas.parentElement == container) {
lcevcCanvas = canvas;
break;
}
}
if (!lcevcCanvas) {
lcevcCanvas = document.createElement('canvas');
lcevcCanvas.classList.add('shaka-canvas-container');
container.appendChild(lcevcCanvas);
}
let vrCanvas = null;
for (const canvas of vrCanvases) {
goog.asserts.assert(canvas.tagName.toLowerCase() == 'canvas',
'Should be a canvas element!');
if (canvas.parentElement == container) {
vrCanvas = canvas;
break;
}
}
if (!vrCanvas) {
vrCanvas = document.createElement('canvas');
vrCanvas.classList.add('shaka-vr-canvas-container');
container.appendChild(vrCanvas);
}
return {
lcevcCanvas,
vrCanvas,
};
}
};
/**
* Describes what information should show up in labels for selecting audio
* variants and text tracks.
*
* @enum {number}
* @export
*/
shaka.ui.Overlay.TrackLabelFormat = {
'LANGUAGE': 0,
'ROLE': 1,
'LANGUAGE_ROLE': 2,
'LABEL': 3,
};
/**
* Describes the possible reasons that the UI might fail to load.
*
* @enum {number}
* @export
*/
shaka.ui.Overlay.FailReasonCode = {
'NO_BROWSER_SUPPORT': 0,
'PLAYER_FAILED_TO_LOAD': 1,
};
if (document.readyState == 'complete') {
// Don't fire this event synchronously. In a compiled bundle, the "shaka"
// namespace might not be exported to the window until after this point.
(async () => {
await Promise.resolve();
shaka.ui.Overlay.scanPageForShakaElements_();
})();
} else {
window.addEventListener('load', shaka.ui.Overlay.scanPageForShakaElements_);
}