UNPKG

shaka-player

Version:
1,489 lines (1,327 loc) 61.3 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shakaDemo.Main'); goog.require('ShakaDemoAssetInfo'); goog.require('goog.asserts'); goog.require('shakaDemo.CloseButton'); goog.require('shakaDemo.Utils'); goog.require('shakaDemo.Visualizer'); goog.require('shakaDemo.VisualizerButton'); /** * Shaka Player demo, main section. * This controls the header and the footer, and contains all methods that should * be shared by multiple page layouts (loading assets, setting/checking * configuration, etc). */ shakaDemo.Main = class { /** */ constructor() { /** @private {HTMLVideoElement} */ this.video_ = null; /** @private {HTMLElement} */ this.container_ = null; /** @private {shaka.Player} */ this.player_ = null; /** @type {?ShakaDemoAssetInfo} */ this.selectedAsset = null; /** @type {shaka.ui.Localization} */ this.localization_ = null; /** * The configuration asked for by the user. I.e., not from the asset. * @private {shaka.extern.PlayerConfiguration} */ this.desiredConfig_; /** @private {shaka.extern.PlayerConfiguration} */ this.defaultConfig_; /** @private {boolean} */ this.fullyLoaded_ = false; /** @private {?shaka.ui.Controls} */ this.controls_ = null; /** @private {?Array.<shaka.extern.StoredContent>} */ this.initialStoredList_; /** @private {boolean} */ this.trickPlayControlsEnabled_ = false; /** @private {boolean} */ this.nativeControlsEnabled_ = false; /** @private {shaka.extern.SupportType} */ this.support_; /** @private {string} */ this.uiLocale_ = ''; /** @private {boolean} */ this.noInput_ = false; /** @private {!HTMLAnchorElement} */ this.errorDisplayLink_ = /** @type {!HTMLAnchorElement} */( document.getElementById('error-display-link')); /** @private {?number} */ this.currentErrorSeverity_ = null; // Override the icon for the MDL library's menu button. // eslint-disable-next-line no-restricted-syntax MaterialLayout.prototype.Constant_.MENU_ICON = 'settings'; /** @private {?shakaDemo.Visualizer} */ this.visualizer_ = null; } /** * This function contains the steps of initialization that should be followed * whether or not the demo successfully set up. * @private */ initCommon_() { // Display uncaught exceptions. Note that this doesn't seem to work in IE. // See shakaDemo.Main.initWrapper for a failsafe that works for init-time // errors on IE. window.addEventListener('error', (event) => { const errorEvent = /** @type {!ErrorEvent} */(event); // Exception to the exceptions we catch: ChromeVox (screenreader) always // throws an error as of Chrome 73. Screen these out since they are // unrelated to our application and we can't control them. if (errorEvent.message.includes('cvox.Api')) { return; } this.onError_(/** @type {!shaka.util.Error} */ (errorEvent.error)); }); // Set up event listeners. document.getElementById('error-display-close-button').addEventListener( 'click', (event) => this.closeError_()); // Set up version strings in the appropriate divs. this.setUpVersionStrings_(); } /** * Set up the application with errors to show that load failed. * This does not dispatch the shaka-main-loaded event, so it will not cause * the nav bar buttons to be set up. * @param {!shaka.ui.Overlay.FailReasonCode} reasonCode */ initFailed(reasonCode) { this.initCommon_(); // Set up version links, so the user can switch to compiled mode if // necessary. this.makeVersionLinks_(); const errorCloseButton = document.getElementById('error-display-close-button'); errorCloseButton.style.display = 'none'; // Update the componentHandler, to account for any new MDL elements added. componentHandler.upgradeDom(); // Disable elements that should not be used. const elementsToDisable = []; const disableClass = 'should-disable-on-fail'; for (const element of document.getElementsByClassName(disableClass)) { elementsToDisable.push(element); } // The hamburger menu close button is added programmatically by MDL, and // thus isn't given our 'disableonfail' class. for (const element of document.getElementsByClassName( 'mdl-layout__drawer-button')) { elementsToDisable.push(element); } for (const element of elementsToDisable) { element.tabIndex = -1; element.classList.add('disabled-by-fail'); } // Process a synthetic error about lack of browser support. const severity = shaka.util.Error.Severity.CRITICAL; let href = ''; let message = ''; switch (reasonCode) { case shaka.ui.Overlay.FailReasonCode.NO_BROWSER_SUPPORT: message = 'Your browser is not supported!'; href = 'https://github.com/shaka-project/shaka-player#' + 'platform-and-browser-support-matrix'; break; case shaka.ui.Overlay.FailReasonCode.PLAYER_FAILED_TO_LOAD: message = 'Shaka Player failed to load! If you are using an adblocker' + ', try switching to compiled mode at the bottom of the page.'; break; } this.handleError_(severity, message, href); } /** * Initialize the application. */ async init() { this.initCommon_(); this.support_ = await shaka.Player.probeSupport(); this.video_ = /** @type {!HTMLVideoElement} */ (document.getElementById('video')); this.video_.poster = shakaDemo.Main.mainPoster_; this.container_ = /** @type {!HTMLElement} */( document.getElementsByClassName('video-container')[0]); if (navigator.serviceWorker) { console.debug('Registering service worker.'); // NOTE: This can sometimes hang on iOS 12, so let's not wait for it to // complete before setting up the app. We don't even use the Promise // result or react to the registration failure except to log it. navigator.serviceWorker.register('service_worker.js'); } // Optionally enter noinput mode. This has to happen before setting up the // player. this.noInput_ = 'noinput' in this.getParams_(); this.setupPlayer_(); this.readHash_(); window.addEventListener('hashchange', () => this.hashChanged_()); await this.setupStorage_(); this.setupBugButton_(); if (this.noInput_) { // Set the page to noInput mode, disabling the header and footer. const hideClass = 'should-hide-in-no-input-mode'; for (const element of document.getElementsByClassName(hideClass)) { this.hideElement_(element); } const showClass = 'should-show-in-no-input-mode'; for (const element of document.getElementsByClassName(showClass)) { this.showElement_(element); } // Also fullscreen the container. this.container_.classList.add('no-input-sized'); document.getElementById('video-bar').classList.add('no-input-sized'); } else { goog.asserts.assert(this.player_, 'Player should exist by now.'); // Make the visualizer element. const vCanvas = /** @type {!HTMLCanvasElement} */ ( document.getElementById('visualizer-canvas')); const vDiv = /** @type {!HTMLElement} */ ( document.getElementById('visualizer-div')); const vControlsDiv = /** @type {!HTMLElement} */ ( document.getElementById('visualizer-controls-div')); const vScreenshotDiv = /** @type {!HTMLElement} */ ( document.getElementById('visualizer-screenshot-div')); /** @private {?shakaDemo.Visualizer} */ this.visualizer_ = new shakaDemo.Visualizer( vCanvas, vDiv, vScreenshotDiv, vControlsDiv, this.video_, this.player_); } // The main page is loaded. Dispatch an event, so the various // configurations will load themselves. this.dispatchEventWithName_('shaka-main-loaded'); // Wait for one interruptor cycle, so that the tabs have time to load. // This ensures that, for example, if there is an asset playing at page // load time, the video will scroll into view second, and the page won't // scroll away from the video. await Promise.resolve(); // Update the componentHandler, to account for any new MDL elements added. componentHandler.upgradeDom(); const asset = this.getLastAssetFromHash_(); this.fullyLoaded_ = true; this.remakeHash(); if (asset && !this.selectedAsset) { // If an asset has begun loading in the meantime (for example, due to // re-joining an existing cast session), don't play this. this.loadAsset(asset); } } /** * @param {string} url * @return {!Promise.<string>} * @private */ async loadText_(url) { const netEngine = new shaka.net.NetworkingEngine(); const retryParams = shaka.net.NetworkingEngine.defaultRetryParameters(); const request = shaka.net.NetworkingEngine.makeRequest([url], retryParams); const requestType = shaka.net.NetworkingEngine.RequestType.APP; const operation = netEngine.request(requestType, request); const response = await operation.promise; const text = shaka.util.StringUtils.fromUTF8(response.data); await netEngine.destroy(); return text; } /** @private */ async reportBug_() { // Fetch the special bug template. let text = await this.loadText_('autoTemplate.txt'); // Fill in what parts of the template we can. const fillInTemplate = (replaceString, value) => { text = text.replace(replaceString, value); }; fillInTemplate('RE:player', shaka.Player.version); if (this.selectedAsset) { const uriLines = []; const addLine = (key, value) => { uriLines.push(key + '= `' + value + '`'); }; addLine('uri', this.selectedAsset.manifestUri); if (this.selectedAsset.adTagUri) { addLine('ad tag uri', this.selectedAsset.adTagUri); } if (this.selectedAsset.licenseServers.size) { const uri = this.selectedAsset.licenseServers.values().next().value; addLine('license server', uri); for (const drmSystem of this.selectedAsset.licenseServers.keys()) { if (!shakaDemo.Main.commonDrmSystems.includes(drmSystem)) { addLine('drm system', drmSystem); break; } } } if (this.selectedAsset.certificateUri) { addLine('certificate', this.selectedAsset.certificateUri); } fillInTemplate('RE:uris', uriLines.join('\n')); } else { fillInTemplate('RE:uris', 'No asset'); } fillInTemplate('RE:browser', navigator.userAgent); if (this.selectedAsset && this.selectedAsset.source == shakaAssets.Source.CUSTOM) { // This is a custom asset, so add a comment warning about custom assets. const warning = await this.loadText_('customWarning.txt'); fillInTemplate('RE:customwarning', warning); } else { // No need for any warnings. So remove it (and the newline after it). fillInTemplate('RE:customwarning\n', ''); } const urlTerms = []; urlTerms.push('labels=type%3A+bug'); urlTerms.push('body=' + encodeURIComponent(text)); const url = 'https://github.com/shaka-project/shaka-player/issues/new?' + urlTerms.join('&'); // Navigate to the github issue opening interface, with the // partially-filled template as a preset body, opening in another tab. window.open(url, '_blank'); } /** @private */ setupBugButton_() { const bugButton = document.getElementById('bug-button'); bugButton.addEventListener('click', () => this.reportBug_()); // The button should be disabled when offline, as we can't report bugs in // that state. if (!navigator.onLine) { bugButton.setAttribute('disabled', ''); } window.addEventListener('online', () => { bugButton.removeAttribute('disabled'); }); window.addEventListener('offline', () => { bugButton.setAttribute('disabled', ''); }); } /** @private */ configureUI_() { const video = /** @type {!HTMLVideoElement} */ (this.video_); const ui = video['ui']; const uiConfig = ui.getConfiguration(); // Remove any trick play configurations from a previous config. uiConfig.addSeekBar = true; uiConfig.controlPanelElements = uiConfig.controlPanelElements.filter((element) => { return element != 'rewind' && element != 'fast_forward'; }); if (this.trickPlayControlsEnabled_) { // Trick mode controls don't have a seek bar. uiConfig.addSeekBar = false; // Replace the position the play_pause button was at with a full suite of // trick play controls, including rewind and fast-forward. const index = uiConfig.controlPanelElements.indexOf('play_pause'); uiConfig.controlPanelElements.splice( index, 1, 'rewind', 'play_pause', 'fast_forward'); } if (!uiConfig.controlPanelElements.includes('close')) { uiConfig.controlPanelElements.push('close'); } if (!uiConfig.overflowMenuButtons.includes('visualizer')) { uiConfig.overflowMenuButtons.push('visualizer'); } ui.configure(uiConfig); } /** @private */ setupPlayer_() { const video = /** @type {!HTMLVideoElement} */ (this.video_); const ui = video['ui']; this.player_ = ui.getControls().getPlayer(); if (!this.noInput_) { // Don't add the close button if in noInput mode; it doesn't make much // sense to stop playing a video if you can't start playing other videos. // Register custom controls to the UI. const closeFactory = new shakaDemo.CloseButton.Factory(); shaka.ui.Controls.registerElement('close', closeFactory); const visualizerFactory = new shakaDemo.VisualizerButton.Factory(); shaka.ui.OverflowMenu.registerElement('visualizer', visualizerFactory); // Configure UI. this.configureUI_(); } // Add application-level default configs here. These are not the library // defaults, but they are the application defaults. This will affect the // default values assigned to UI config elements as well as the decision // about what values to place in the URL hash. this.player_.configure( 'manifest.dash.clockSyncUri', 'https://shaka-player-demo.appspot.com/time.txt'); // Get default config. this.defaultConfig_ = this.player_.getConfiguration(); this.desiredConfig_ = this.player_.getConfiguration(); const languages = navigator.languages || ['en-us']; this.configure('preferredAudioLanguage', languages[0]); this.configure('preferredTextLanguage', languages[0]); this.uiLocale_ = languages[0]; // TODO(#1591): Support multiple language preferences const onErrorEvent = (event) => this.onErrorEvent_(event); this.player_.addEventListener('error', onErrorEvent); // Listen to events on controls. this.controls_ = ui.getControls(); this.controls_.addEventListener('error', onErrorEvent); this.controls_.addEventListener('caststatuschanged', (event) => { this.onCastStatusChange_(event['newStatus']); }); this.localization_ = this.controls_.getLocalization(); const drawerCloseButton = document.getElementById('drawer-close-button'); drawerCloseButton.addEventListener('click', () => { const layout = document.getElementById('main-layout'); layout.MaterialLayout.toggleDrawer(); this.dispatchEventWithName_('shaka-main-drawer-state-change'); this.hideElement_(drawerCloseButton); }); // Dispatch drawer state change events when the drawer button or obfuscator // are pressed also. const drawerButton = document.querySelector('.mdl-layout__drawer-button'); goog.asserts.assert(drawerButton, 'There should be a drawer button.'); const openDrawer = () => { this.dispatchEventWithName_('shaka-main-drawer-state-change'); this.showElement_(drawerCloseButton); }; // Listen to both the "click" and "keydown" events on the drawer button, // since the element is actually a div rather than a button, which means // that it doesn't fire "click" events when activated by keyboard input. drawerButton.addEventListener('click', openDrawer); drawerButton.addEventListener('keydown', (event) => { const key = (/** @type {!KeyboardEvent} */ (event)).key; // Ignore "keydown" input for keys that won't trigger the button (i.e. // anything besides spacebar or enter). if (key == ' ' || key == 'Spacebar' || key == 'Enter') { openDrawer(); } }); const obfuscator = document.querySelector('.mdl-layout__obfuscator'); goog.asserts.assert(obfuscator, 'There should be an obfuscator.'); obfuscator.addEventListener('click', () => { this.dispatchEventWithName_('shaka-main-drawer-state-change'); this.hideElement_(drawerCloseButton); }); this.hideElement_(drawerCloseButton); } /** @return {boolean} */ getIsDrawerOpen() { const drawer = document.querySelector('.mdl-layout__drawer'); goog.asserts.assert(drawer, 'There should be a drawer.'); return drawer.classList.contains('is-visible'); } /** * Gets a unique storage identifier for an asset. * @param {!ShakaDemoAssetInfo} asset * @return {string} * @private */ getIdentifierFromAsset_(asset) { // Custom assets can't have special characters like [ or ] in their name, // and none of the default assets will have that in their name, so we can // be sure that no asset will have [CUSTOM] in its name. return asset.name + (asset.source == shakaAssets.Source.CUSTOM ? ' [CUSTOM]' : ''); } /** * Creates a storage instance. * If and only if storage is not available, this will return null. * These storage instances are meant to be used once and then destroyed, using * the |Storage.destroy| method. * @return {?shaka.offline.Storage} * @private */ makeStorageInstance_() { if (!shaka.offline.Storage.support()) { return null; } const storage = new shaka.offline.Storage(); // Configure the storage instance. /** * @param {string} identifier * @return {?ShakaDemoAssetInfo} */ const getAssetWithIdentifier = (identifier) => { for (const asset of shakaAssets.testAssets) { if (this.getIdentifierFromAsset_(asset) == identifier) { return asset; } } if (shakaDemoCustom) { for (const asset of shakaDemoCustom.assets()) { if (this.getIdentifierFromAsset_(asset) == identifier) { return asset; } } } return null; }; /** * @param {shaka.extern.StoredContent} content * @param {number} progress */ const progressCallback = (content, progress) => { const identifier = content.appMetadata['identifier']; const asset = getAssetWithIdentifier(identifier); if (asset) { asset.storedProgress = progress; this.dispatchEventWithName_('shaka-main-offline-progress'); } }; storage.configure(this.desiredConfig_); storage.configure('offline.progressCallback', progressCallback); return storage; } /** * Attaches callbacks to an asset so that it can be downloaded online. * This method does not verify whether storage is or is not possible. * Also, if an asset has an associated offline version, load it with that * info. * @param {!ShakaDemoAssetInfo} asset */ setupOfflineSupport(asset) { if (!this.initialStoredList_) { // Storage failed to set up, so nothing happened. return; } // If the list of stored content does not contain this asset, then make sure // that the asset's |storedContent| value is null. Custom assets that were // once stored might have that object serialized with their other data. asset.storedContent = null; for (const storedContent of this.initialStoredList_) { const identifier = storedContent.appMetadata['identifier']; if (this.getIdentifierFromAsset_(asset) == identifier) { asset.storedContent = storedContent; } } asset.storeCallback = async () => { const storage = this.makeStorageInstance_(); if (!storage) { return; } try { await this.drmConfiguration_(asset, storage); const metadata = { 'identifier': this.getIdentifierFromAsset_(asset), 'downloaded': new Date(), }; asset.storedProgress = 0; this.dispatchEventWithName_('shaka-main-offline-progress'); const start = Date.now(); const stored = await storage.store(asset.manifestUri, metadata).promise; const end = Date.now(); console.log('Download time:', end - start); asset.storedContent = stored; } catch (error) { this.onError_(/** @type {!shaka.util.Error} */ (error)); asset.storedContent = null; } storage.destroy(); asset.storedProgress = 1; this.dispatchEventWithName_('shaka-main-offline-progress'); }; asset.unstoreCallback = async () => { if (asset == this.selectedAsset) { this.unload(); } if (asset.storedContent && asset.storedContent.offlineUri) { const storage = this.makeStorageInstance_(); if (!storage) { return; } try { asset.storedProgress = 0; this.dispatchEventWithName_('shaka-main-offline-progress'); await storage.remove(asset.storedContent.offlineUri); asset.storedContent = null; } catch (error) { this.onError_(/** @type {!shaka.util.Error} */ (error)); // Presumably, if deleting the asset fails, it still exists? } storage.destroy(); asset.storedProgress = 1; this.dispatchEventWithName_('shaka-main-offline-progress'); } }; } /** * @return {!Promise} * @private */ async setupStorage_() { // Load stored asset infos. const storage = this.makeStorageInstance_(); if (!storage) { return; } try { this.initialStoredList_ = await storage.list(); } catch (error) { // If this operation errors, it means that storage (while supported) is // being held up by some kind of error. // Log that error, and then pretend that storage is unsupported. console.error(error); this.initialStoredList_ = null; } finally { storage.destroy(); } // Setup asset callbacks for storage, for the test assets. for (const asset of shakaAssets.testAssets) { if (this.getAssetUnsupportedReason(asset, /* needOffline= */ true)) { // Don't bother setting up the callbacks. continue; } this.setupOfflineSupport(asset); } } /** @private */ hashChanged_() { this.readHash_(); this.dispatchEventWithName_('shaka-main-config-change'); } /** * Get why the asset is unplayable, if it is unplayable. * * @param {!ShakaDemoAssetInfo} asset * @param {boolean} needOffline True if offline support is required. * @return {?string} unsupportedReason * Null if asset is supported. */ getAssetUnsupportedReason(asset, needOffline) { if (needOffline && (!shaka.offline.Storage.support() || !this.initialStoredList_)) { return 'Your browser does not support offline storage.'; } if (asset.source == shakaAssets.Source.CUSTOM) { // We can't be sure if custom assets are supported or not. Just assume // they are. return null; } // Is the asset disabled? if (asset.disabled) { return 'This asset is disabled.'; } if (needOffline && !asset.features.includes(shakaAssets.Feature.OFFLINE)) { return 'This asset cannot be downloaded.'; } if (!asset.isClear() && !asset.isAes128()) { const hasSupportedDRM = asset.drm.some((drm) => { return this.support_.drm[shakaAssets.identifierForKeySystem(drm)]; }); if (!hasSupportedDRM) { return 'Your browser does not support the required key systems.'; } if (needOffline) { const hasSupportedOfflineDRM = asset.drm.some((drm) => { const identifier = shakaAssets.identifierForKeySystem(drm); return this.support_.drm[identifier] && this.support_.drm[identifier].persistentState; }); if (!hasSupportedOfflineDRM) { return 'Your browser does not support offline licenses for the ' + 'required key systems.'; } } } // Does the browser support the asset's manifest type? if (asset.features.includes(shakaAssets.Feature.DASH) && !this.support_.manifest['mpd']) { return 'Your browser does not support MPEG-DASH manifests.'; } if (asset.features.includes(shakaAssets.Feature.HLS) && !this.support_.manifest['m3u8']) { return 'Your browser does not support HLS manifests.'; } if (asset.features.includes(shakaAssets.Feature.MSS) && !this.support_.manifest['ism']) { return 'Your browser does not support MSS manifests.'; } // Does the asset contain a playable mime type? const mimeTypes = []; if (asset.features.includes(shakaAssets.Feature.WEBM)) { mimeTypes.push('video/webm'); } if (asset.features.includes(shakaAssets.Feature.MP4)) { mimeTypes.push('video/mp4'); } if (asset.features.includes(shakaAssets.Feature.MP2TS)) { mimeTypes.push('video/mp2t'); } if (asset.features.includes(shakaAssets.Feature.CONTAINERLESS)) { mimeTypes.push('audio/aac'); } const hasSupportedMimeType = mimeTypes.some((type) => { return this.support_.media[type]; }); if (!hasSupportedMimeType) { return 'Your browser does not support the required video format.'; } return null; } /** * Enable or disable the UI's trick play controls. * * @param {boolean} enabled */ setTrickPlayControlsEnabled(enabled) { this.trickPlayControlsEnabled_ = enabled; // Configure the UI, to add or remove the controls. this.configureUI_(); this.remakeHash(); } /** * Get if the trick play controls are enabled. * * @return {boolean} enabled */ getTrickPlayControlsEnabled() { return this.trickPlayControlsEnabled_; } /** * Enable or disable the native controls. * Goes into effect during the next load. * * @param {boolean} enabled */ setNativeControlsEnabled(enabled) { this.nativeControlsEnabled_ = enabled; this.remakeHash(); } /** * Get if the native controls are enabled. * * @return {boolean} enabled */ getNativeControlsEnabled() { return this.nativeControlsEnabled_; } /** @param {string} locale */ setUILocale(locale) { this.uiLocale_ = locale; // Fall back to browser languages after the demo page setting. const preferredLocales = [locale].concat(navigator.languages); this.localization_.changeLocale(preferredLocales); } /** @return {string} */ getUILocale() { return this.uiLocale_; } /** * @return {?ShakaDemoAssetInfo} * @private */ getLastAssetFromHash_() { const params = this.getParams_(); const manifest = params['asset']; const adTagUri = params['adTagUri']; if (manifest) { // See if it's a default asset. for (const asset of shakaAssets.testAssets) { if (asset.manifestUri == manifest && asset.adTagUri == adTagUri) { return asset; } } // See if it's a custom asset saved here. for (const asset of shakaDemoCustom.assets()) { if (asset.manifestUri == manifest) { return asset; } } // Construct a new asset. const asset = new ShakaDemoAssetInfo( /* name= */ 'loaded asset', /* iconUri= */ '', /* manifestUri= */ manifest, /* source= */ shakaAssets.Source.CUSTOM); if ('license' in params) { let drmSystems = shakaDemo.Main.commonDrmSystems; if ('drmSystem' in params) { drmSystems = [params['drmSystem']]; } for (const drmSystem of drmSystems) { asset.addLicenseServer(drmSystem, params['license']); } } if ('certificate' in params) { asset.addCertificateUri(params['certificate']); } return asset; } return null; } /** @private */ readHash_() { const params = this.getParams_(); if (this.player_) { const readParam = (hashName, configName) => { if (hashName in params) { const existing = this.getCurrentConfigValue(configName); // Translate the param string into a non-string value if appropriate. // Determine what type the parsed value should be based on the current // value. let value = params[hashName]; if (typeof existing == 'boolean') { value = value == 'true'; } else if (typeof existing == 'number') { value = parseFloat(value); } this.configure(configName, value); } }; const config = this.player_.getConfiguration(); shakaDemo.Utils.runThroughHashParams(readParam, config); const advanced = this.getCurrentConfigValue('drm.advanced'); if (advanced) { for (const drmSystem of shakaDemo.Main.commonDrmSystems) { if (!advanced[drmSystem]) { advanced[drmSystem] = shakaDemo.Main.defaultAdvancedDrmConfig(); } if ('videoRobustness' in params) { advanced[drmSystem].videoRobustness = params['videoRobustness']; } if ('audioRobustness' in params) { advanced[drmSystem].audioRobustness = params['audioRobustness']; } } } } if ('lang' in params) { // Load the legacy 'lang' hash value. const lang = params['lang']; this.configure('preferredAudioLanguage', lang); this.configure('preferredTextLanguage', lang); this.setUILocale(lang); } if ('uilang' in params) { this.setUILocale(params['uilang']); // TODO(#1591): Support multiple language preferences } if ('noadaptation' in params) { this.configure('abr.enabled', false); } // Add compiled/uncompiled links. this.makeVersionLinks_(); // Disable custom controls. this.nativeControlsEnabled_ = 'nativecontrols' in params; // Enable trick play. if ('trickplay' in params) { this.trickPlayControlsEnabled_ = true; this.configureUI_(); } // Check if uncompiled mode is supported. if (!shakaDemo.Utils.browserSupportsUncompiledMode()) { const uncompiledLink = document.getElementById('uncompiled-link'); goog.asserts.assert( uncompiledLink instanceof HTMLAnchorElement, 'Wrong element type!'); uncompiledLink.setAttribute('disabled', ''); uncompiledLink.removeAttribute('href'); uncompiledLink.title = 'requires a newer browser'; } if (shaka.log) { if ('vv' in params) { shaka.log.setLevel(shaka.log.Level.V2); } else if ('v' in params) { shaka.log.setLevel(shaka.log.Level.V1); } else if ('debug' in params) { shaka.log.setLevel(shaka.log.Level.DEBUG); } else if ('info' in params) { shaka.log.setLevel(shaka.log.Level.INFO); } } } /** @private */ makeVersionLinks_() { const params = this.getParams_(); let buildType = 'uncompiled'; if ('build' in params) { buildType = params['build']; } else if ('compiled' in params) { buildType = 'compiled'; } for (const type of ['compiled', 'debug_compiled', 'uncompiled']) { const elem = document.getElementById(type.split('_').join('-') + '-link'); goog.asserts.assert( elem instanceof HTMLAnchorElement, 'Wrong element type!'); if (buildType == type) { elem.setAttribute('disabled', ''); elem.removeAttribute('href'); elem.title = 'currently selected'; } else { elem.removeAttribute('disabled'); elem.addEventListener('click', () => { const rawParams = location.hash.substr(1).split(';'); const newParams = rawParams.filter((param) => { // Remove current build type param(s). return param != 'compiled' && param.split('=')[0] != 'build'; }); newParams.push('build=' + type); this.setNewHashSilent_(newParams.join(';')); location.reload(); return false; }); } } } /** * @return {!Object.<string, string>} params * @private */ getParams_() { // Read URL parameters. let fields = location.search.substr(1); fields = fields ? fields.split(';') : []; let fragments = location.hash.substr(1); fragments = fragments ? fragments.split(';') : []; // Because they are being concatenated in this order, if both an // URL fragment and an URL parameter of the same type are present // the URL fragment takes precendence. /** @type {!Array.<string>} */ const combined = fields.concat(fragments); const params = {}; for (const line of combined) { const kv = line.split('='); params[kv[0]] = kv.slice(1).join('='); } return params; } /** * Recovers the value from the given config field, from an arbitrary config * object. * This uses the same syntax as setting a single configuration field. * @param {string} valueName * @param {?shaka.extern.PlayerConfiguration} configObject * @return {*} * @private */ getValueFromGivenConfig_(valueName, configObject) { let objOn = configObject; let valueNameOn = valueName; while (valueNameOn) { // Split using a regex that only matches the first period. const split = valueNameOn.split(/\.(.+)/); if (split.length == 3) { valueNameOn = split[1]; objOn = objOn[split[0]]; } else { return objOn[split[0]]; } } return undefined; } /** * Recovers the value from the given config field. * This uses the same syntax as setting a single configuration field. * @example getCurrentConfigValue('abr.bandwidthDowngradeTarget') * @param {string} valueName * @return {*} */ getCurrentConfigValue(valueName) { const config = this.desiredConfig_; return this.getValueFromGivenConfig_(valueName, config); } /** * @param {string} valueName */ resetConfiguration(valueName) { this.configure(valueName, undefined); } /** * @param {string|!Object} config * @param {*=} value */ configure(config, value) { if (arguments.length == 2 && typeof(config) == 'string') { config = shaka.util.ConfigUtils.convertToConfigObject(config, value); } const asObj = /** @type {!Object} */ (config); shaka.util.PlayerConfiguration.mergeConfigObjects( this.desiredConfig_, asObj, this.defaultConfig_); this.player_.configure(config, value); } /** @return {!shaka.extern.PlayerConfiguration} */ getConfiguration() { return this.desiredConfig_; } /** * @param {string} uri * @param {!shaka.net.NetworkingEngine} netEngine * @return {!Promise.<!ArrayBuffer>} * @private */ async requestCertificate_(uri, netEngine) { const requestType = shaka.net.NetworkingEngine.RequestType.APP; const request = /** @type {shaka.extern.Request} */ ({uris: [uri]}); const response = await netEngine.request(requestType, request).promise; return response.data; } /** @return {boolean} */ getIsVisualizerActive() { if (this.visualizer_) { return this.visualizer_.active; } return false; } /** @param {boolean} active */ setIsVisualizerActive(active) { if (this.visualizer_) { const wasActive = this.visualizer_.active; this.visualizer_.active = active; if (wasActive != active) { if (active) { this.visualizer_.start(); } else { this.visualizer_.stop(); } } } } /** Unload the currently-playing asset. */ unload() { if (this.visualizer_) { this.visualizer_.stop(); } this.selectedAsset = null; const videoBar = document.getElementById('video-bar'); this.hideElement_(videoBar); this.video_.poster = shakaDemo.Main.mainPoster_; if (document.fullscreenElement) { document.exitFullscreen(); } if (this.video_.webkitDisplayingFullscreen) { this.video_.webkitExitFullscreen(); } if (document.pictureInPictureElement) { document.exitPictureInPicture(); } if (window.documentPictureInPicture && window.documentPictureInPicture.window) { window.documentPictureInPicture.window.close(); } this.player_.unload(); // The currently-selected asset changed, so update asset cards. this.dispatchEventWithName_('shaka-main-selected-asset-changed'); // Unset media session title, but only if the browser supports that API. if (navigator.mediaSession) { navigator.mediaSession.metadata = null; } // Remake hash, to change the current asset. this.remakeHash(); } /** * @param {ShakaDemoAssetInfo} asset * @param {shaka.offline.Storage=} storage * @return {!Promise} * @private */ async drmConfiguration_(asset, storage) { const netEngine = storage ? storage.getNetworkingEngine() : this.player_.getNetworkingEngine(); goog.asserts.assert(netEngine, 'There should be a net engine.'); asset.applyFilters(netEngine); const assetConfig = asset.getConfiguration(); if (storage) { storage.configure(assetConfig); } else { // Remove all not-player-applied configurations, by resetting the // configuration then re-applying the desired configuration. this.player_.resetConfiguration(); this.readHash_(); this.player_.configure(assetConfig); } const config = storage ? storage.getConfiguration() : this.player_.getConfiguration(); // Change the config's serverCertificate fields based on // asset.certificateUri. if (asset.certificateUri) { // Fetch the certificate, and apply it to the configuration. const certificate = await this.requestCertificate_( asset.certificateUri, netEngine); const certArray = shaka.util.BufferUtils.toUint8(certificate); for (const drmSystem of asset.licenseServers.keys()) { config.drm.advanced[drmSystem] = config.drm.advanced[drmSystem] || {}; config.drm.advanced[drmSystem].serverCertificate = certArray; } } else { // Remove any server certificates. for (const drmSystem of asset.licenseServers.keys()) { if (config.drm.advanced[drmSystem]) { delete config.drm.advanced[drmSystem].serverCertificate; } } } if (storage) { storage.configure(config); } else { this.player_.configure('drm.advanced', config.drm.advanced); } this.remakeHash(); } /** * Performs all visual operations that should be performed when a new asset * begins playing. The video bar is un-hidden, the screen is scrolled, and so * on. * * @private */ showPlayer_() { const videoBar = document.getElementById('video-bar'); this.showElement_(videoBar); this.closeError_(); this.video_.poster = shakaDemo.Main.mainPoster_; // Scroll to the top of the page, so that if the page is scrolled down, // the user won't need to manually scroll up to see the video. videoBar.scrollIntoView({behavior: 'smooth', block: 'start'}); } /** * @param {ShakaDemoAssetInfo} asset */ async loadAsset(asset) { try { this.selectedAsset = asset; this.showPlayer_(); // The currently-selected asset changed, so update asset cards. this.dispatchEventWithName_('shaka-main-selected-asset-changed'); // Enable the correct set of controls before loading. // The video container influences the TextDisplayer used. if (this.nativeControlsEnabled_) { this.controls_.setEnabledShakaControls(false); this.controls_.setEnabledNativeControls(true); // This will force the player to use SimpleTextDisplayer. this.player_.setVideoContainer(null); } else { this.controls_.setEnabledShakaControls(true); this.controls_.setEnabledNativeControls(false); // This will force the player to use UITextDisplayer. this.player_.setVideoContainer(this.container_); } await this.drmConfiguration_(asset); this.controls_.getCastProxy().setAppData({'asset': asset}); // Finally, the asset can be loaded. let manifestUri = asset.manifestUri; // If we have an offline copy, use that. If the offlineUri field is null, // we are still downloading it. if (asset.storedContent && asset.storedContent.offlineUri) { manifestUri = asset.storedContent.offlineUri; } // If it's a server side dai asset, request ad-containing manifest // from the ad manager. if (asset.imaAssetKey || (asset.imaContentSrcId && asset.imaVideoId)) { manifestUri = await this.getManifestUriFromAdManager_(asset); } // If it's a MediaTailor asset, request ad-containing manifest // from the ad manager. if (asset.mediaTailorUrl) { manifestUri = await this.getManifestUriFromMediaTailorAdManager_(asset); } await this.player_.load( manifestUri, /* startTime= */ null, asset.mimeType || undefined); if (this.player_.isAudioOnly()) { this.video_.poster = shakaDemo.Main.audioOnlyPoster_; } for (const extraText of asset.extraText) { this.player_.addTextTrackAsync(extraText.uri, extraText.language, extraText.kind, extraText.mime, extraText.codecs); } for (const extraThumbnail of asset.extraThumbnail) { this.player_.addThumbnailsTrack(extraThumbnail); } // If the asset has an ad tag attached to it, load the ads const adManager = this.player_.getAdManager(); if (adManager && asset.adTagUri) { try { // If IMA is blocked by an AdBlocker, init() will throw. // If that happens, just proceed to load. goog.asserts.assert(this.video_ != null, 'this.video should exist!'); adManager.initClientSide( this.controls_.getClientSideAdContainer(), this.video_, /** adsRenderingSettings= **/ null); const adRequest = new google.ima.AdsRequest(); adRequest.adTagUrl = asset.adTagUri; adManager.requestClientSideAds(adRequest); } catch (error) { console.log(error); console.warn('Ads code has been prevented from running. ' + 'Proceeding without ads.'); } } // Set media session title, but only if the browser supports that API. if (navigator.mediaSession) { const metadata = { title: asset.name, artwork: [{src: asset.iconUri}], }; metadata.artist = asset.source; navigator.mediaSession.metadata = new MediaMetadata(metadata); } if (this.visualizer_ && this.visualizer_.active) { this.visualizer_.start(); } } catch (reason) { const error = /** @type {!shaka.util.Error} */ (reason); if (error.code == shaka.util.Error.Code.LOAD_INTERRUPTED) { // Don't use shaka.log, which is not present in compiled builds. console.debug('load() interrupted'); } else { this.onError_(error); } } // Remake hash, to change the current asset. this.remakeHash(); } /** Remakes the location's hash. */ remakeHash() { if (!this.fullyLoaded_) { // Don't remake the hash until the demo page is fully loaded. return; } const params = []; if (this.player_) { const setParam = (hashName, configName) => { const currentValue = this.getCurrentConfigValue(configName); const defaultConfig = this.defaultConfig_; const defaultValue = this.getValueFromGivenConfig_(configName, defaultConfig); // NaN != NaN, so there has to be a special check for it to prevent // false positives. const bothAreNaN = isNaN(currentValue) && isNaN(defaultValue); // Strings count as NaN too, so check for them specifically. const bothAreStrings = (typeof currentValue) == 'string' && (typeof defaultValue) == 'string'; if (currentValue != defaultValue && (!bothAreNaN || bothAreStrings)) { // Don't bother saving in the hash unless it's a non-default value. params.push(hashName + '=' + currentValue); } }; const config = this.player_.getConfiguration(); shakaDemo.Utils.runThroughHashParams(setParam, config); const advanced = this.getCurrentConfigValue('drm.advanced'); if (advanced) { for (const drmSystem of shakaDemo.Main.commonDrmSystems) { const advancedFor = advanced[drmSystem]; if (advancedFor) { if (advancedFor.videoRobustness) { params.push('videoRobustness=' + advancedFor.videoRobustness); } if (advancedFor.audioRobustness) { params.push('audioRobustness=' + advancedFor.audioRobustness); } break; } } } } if (!this.getCurrentConfigValue('abr.enabled')) { params.push('noadaptation'); } params.push('uilang=' + this.getUILocale()); if (this.selectedAsset) { const isDefault = shakaAssets.testAssets.includes(this.selectedAsset); params.push('asset=' + this.selectedAsset.manifestUri); if (this.selectedAsset.adTagUri) { params.push('adTagUri=' + this.selectedAsset.adTagUri); } if (!isDefault && this.selectedAsset.licenseServers.size) { const uri = this.selectedAsset.licenseServers.values().next().value; params.push('license=' + uri); for (const drmSystem of this.selectedAsset.licenseServers.keys()) { if (!shakaDemo.Main.commonDrmSystems.includes(drmSystem)) { params.push('drmSystem=' + drmSystem); break; } } } if (!isDefault && this.selectedAsset.certificateUri) { params.push('certificate=' + this.selectedAsset.certificateUri); } } const navButtons = document.getElementById('nav-button-container'); for (const button of navButtons.childNodes) { if (button.nodeType == Node.ELEMENT_NODE) { goog.asserts.assert( button instanceof HTMLElement, 'Wrong node type!'); if (button.classList.contains('mdl-button--accent')) { params.push('panel=' + button.getAttribute('tab-identifier')); const hashValues = button.getAttribute('tab-hash'); if (hashValues) { params.push('panelData=' + hashValues); } break; } } } for (const type of ['compiled', 'debug_compiled', 'uncompiled']) { const elem = document.getElementById(type.split('_').join('-') + '-link'); if (elem.hasAttribute('disabled')) { params.push('build=' + type); } } if (this.noInput_) { params.push('noinput'); } if (this.nativeControlsEnabled_) { params.push('nativecontrols'); } if (this.trickPlayControlsEnabled_) { params.push('trickplay'); } // MAX_LOG_LEVEL is the default starting log level. Only save the log level // if it's different from this default. if (shaka.log && shaka.log.currentLevel != shaka.log.MAX_LOG_LEVEL) { switch (shaka.log.currentLevel) { case shaka.log.Level.INFO: params.push('info'); break; case shaka.log.Level.DEBUG: params.push('debug'); break; case shaka.log.Level.V2: params.push('vv'); break; case shaka.log.Level.V1: params.push('v'); break; } } this.setNewHashSilent_(params.join(';')); } /** * Sets the hash to a given value WITHOUT triggering a |hashchange| event. * @param {string} hash * @private */ setNewHashSilent_(hash) { const state = null; const title = ''; // Unused; just needed to make Closure happy. const newURL = document.location.pathname + '#' + hash; // Calling history.replaceState can change the URL or hash of the page // without actually triggering any changes; it won't make the page navigate, // or trigger a |hashchange| event. history.replaceState(state, title, newURL); } /** * Gets the hamburger menu's content div, so that the caller to add elements * to it. * There is no guarantee that the caller is the only entity that has added * contents to the hamburger menu. * @return {!HTMLDivElement} The container for the hamburger menu. */ getHamburgerMenu() { const menu = document.getElementById('hamburger-menu-contents'); return /** @type {!HTMLDivElement} */ (menu); } /** * @param {Element} element * @private */ hideElement_(element) { element.classList.add('hidden'); } /** * @param {Element} element * @private */ showElement_(element) { element.classList.remove('hidden'); } /** * @param {ShakaDemoAssetInfo} asset * @retu