From 4d41b7b90cb763ae4fd544c7dee801b2d8f610e7 Mon Sep 17 00:00:00 2001 From: Sandra Lokshina Date: Tue, 29 Jan 2019 09:23:00 -0800 Subject: [PATCH] Refactor the overflow menu to use components design. Closes #1673. Change-Id: I030745def928796a6abc813a91fb163cb2d76175 --- build/types/ui | 6 + karma.conf.js | 1 + shaka-player.uncompiled.js | 12 +- ui/audio_language_selection.js | 209 ++++++ ui/cast_button.js | 184 +++++ ui/controls.js | 70 +- ui/language_utils.js | 140 ++++ ui/overflow_menu.js | 1148 +------------------------------- ui/pip_button.js | 217 ++++++ ui/resolution_selection.js | 299 +++++++++ ui/text_selection.js | 290 ++++++++ ui/ui.js | 1 + ui/ui_utils.js | 54 ++ 13 files changed, 1454 insertions(+), 1177 deletions(-) create mode 100644 ui/audio_language_selection.js create mode 100644 ui/cast_button.js create mode 100644 ui/language_utils.js create mode 100644 ui/pip_button.js create mode 100644 ui/resolution_selection.js create mode 100644 ui/text_selection.js diff --git a/build/types/ui b/build/types/ui index 3e0af302b..f11ff9157 100644 --- a/build/types/ui +++ b/build/types/ui @@ -1,21 +1,27 @@ # UI library. +../../third_party/language-mapping-list/language-mapping-list.js ++../../ui/audio_language_selection.js +../../ui/externs/ui.js ++../../ui/cast_button.js +../../ui/controls.js +../../ui/constants.js +../../ui/enums.js +../../ui/element.js +../../ui/fast_forward_button.js +../../ui/fullscreen_button.js ++../../ui/language_utils.js +../../ui/localization.js +../../ui/locales.js +../../ui/mute_button.js +../../ui/overflow_menu.js ++../../ui/pip_button.js +../../ui/presentation_time.js ++../../ui/resolution_selection.js +../../ui/rewind_button.js +../../ui/spacer.js +../../ui/text_displayer.js ++../../ui/text_selection.js +../../ui/ui.js +../../ui/ui_utils.js +../../ui/volume_bar.js diff --git a/karma.conf.js b/karma.conf.js index 978227bd0..c2b72b4e8 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -111,6 +111,7 @@ module.exports = function(config) { {pattern: 'ui/**/*.css', included: false}, {pattern: 'ui/**/*.less', included: false}, {pattern: 'third_party/closure/goog/**/*.js', included: false}, + {pattern: 'third_party/language-mapping-list/**/*.js', included: false}, {pattern: 'test/test/assets/*', included: false}, {pattern: 'dist/shaka-player.ui.js', included: false}, {pattern: 'node_modules/**/*.js', included: false}, diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index 8490917b8..2c0925ef2 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -61,16 +61,22 @@ goog.require('shaka.text.SimpleTextDisplayer'); goog.require('shaka.text.TextEngine'); goog.require('shaka.text.TtmlTextParser'); goog.require('shaka.text.VttTextParser'); -goog.require('shaka.ui.Overlay'); -goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.Controls'); +goog.require('shaka.ui.OverflowMenu'); +goog.require('shaka.ui.AudioLanguageSelection'); +goog.require('shaka.ui.CastButton'); goog.require('shaka.ui.Element'); goog.require('shaka.ui.FastForwardButton'); goog.require('shaka.ui.FullscreenButton'); +goog.require('shaka.ui.Localization'); goog.require('shaka.ui.MuteButton'); -goog.require('shaka.ui.OverflowMenu'); +goog.require('shaka.ui.Overlay'); +goog.require('shaka.ui.PipButton'); goog.require('shaka.ui.PresentationTimeTracker'); +goog.require('shaka.ui.ResolutionSelection'); goog.require('shaka.ui.RewindButton'); goog.require('shaka.ui.Spacer'); +goog.require('shaka.ui.TextSelection'); goog.require('shaka.ui.VolumeBar'); goog.require('shaka.util.Error'); goog.require('shaka.util.Iterables'); diff --git a/ui/audio_language_selection.js b/ui/audio_language_selection.js new file mode 100644 index 000000000..bc31a4985 --- /dev/null +++ b/ui/audio_language_selection.js @@ -0,0 +1,209 @@ +/** + * @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. + */ + + +goog.provide('shaka.ui.AudioLanguageSelection'); + +goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Enums'); +goog.require('shaka.ui.LanguageUtils'); +goog.require('shaka.ui.Locales'); +goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.OverflowMenu'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.AudioLanguageSelection = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + this.addLanguagesButton_(); + + this.addAudioLangMenu_(); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => { + this.updateLocalizedStrings_(); + // TODO: is there a more efficient way of updating just the strings + // we need instead of running the whole language update? + this.updateAudioLanguages_(); + }); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => { + this.updateLocalizedStrings_(); + // TODO: is there a more efficient way of updating just the strings + // we need instead of running the whole language update? + this.updateAudioLanguages_(); + }); + + + this.eventManager.listen(this.player, 'trackschanged', () => { + this.updateAudioLanguages_(); + }); + + this.eventManager.listen(this.player, 'variantchanged', () => { + this.updateAudioLanguages_(); + }); + + this.eventManager.listen(this.languagesButton_, 'click', () => { + this.onLanguagesClick_(); + }); + + // Set up all the strings in the user's preferred language. + this.updateLocalizedStrings_(); + } + + + /** + * @private + */ + addAudioLangMenu_() { + /** @private {!HTMLElement} */ + this.audioLangMenu_ = shaka.ui.Utils.createHTMLElement('div'); + this.audioLangMenu_.classList.add('shaka-audio-languages'); + this.audioLangMenu_.classList.add('shaka-no-propagation'); + this.audioLangMenu_.classList.add('shaka-show-controls-on-mouse-over'); + this.audioLangMenu_.classList.add('shaka-settings-menu'); + + /** @private {!HTMLElement} */ + this.backFromLanguageButton_ = shaka.ui.Utils.createHTMLElement('button'); + this.backFromLanguageButton_.classList.add('shaka-back-to-overflow-button'); + this.audioLangMenu_.appendChild(this.backFromLanguageButton_); + + const backIcon = shaka.ui.Utils.createHTMLElement('i'); + backIcon.classList.add('material-icons'); + backIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.BACK; + this.backFromLanguageButton_.appendChild(backIcon); + + /** @private {!HTMLElement} */ + this.backFromLanguageSpan_ = shaka.ui.Utils.createHTMLElement('span'); + this.backFromLanguageButton_.appendChild(this.backFromLanguageSpan_); + + const controlsContainer = this.controls.getControlsContainer(); + controlsContainer.appendChild(this.audioLangMenu_); + } + + + /** + * @private + */ + addLanguagesButton_() { + /** @private {!HTMLElement} */ + this.languagesButton_ = shaka.ui.Utils.createHTMLElement('button'); + this.languagesButton_.classList.add('shaka-language-button'); + + const icon = shaka.ui.Utils.createHTMLElement('i'); + icon.classList.add('material-icons'); + icon.textContent = shaka.ui.Enums.MaterialDesignIcons.LANGUAGE; + this.languagesButton_.appendChild(icon); + + const label = shaka.ui.Utils.createHTMLElement('label'); + label.classList.add('shaka-overflow-button-label'); + + /** @private {!HTMLElement} */ + this.languageNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); + this.languageNameSpan_.classList.add('languageSpan'); + label.appendChild(this.languageNameSpan_); + + /** @private {!HTMLElement} */ + this.currentAudioLanguage_ = shaka.ui.Utils.createHTMLElement('span'); + this.currentAudioLanguage_.classList.add('shaka-current-selection-span'); + const language = this.player.getConfiguration().preferredAudioLanguage; + this.currentAudioLanguage_.textContent = + shaka.ui.LanguageUtils.getLanguageName(language, this.localization); + label.appendChild(this.currentAudioLanguage_); + + this.languagesButton_.appendChild(label); + + this.parent.appendChild(this.languagesButton_); + } + + /** @private */ + onLanguagesClick_() { + this.controls.dispatchEvent(new shaka.util.FakeEvent('submenuopen')); + shaka.ui.Utils.setDisplay(this.audioLangMenu_, true); + // Focus on the currently selected language button. + shaka.ui.Utils.focusOnTheChosenItem(this.audioLangMenu_); + } + + /** @private */ + updateAudioLanguages_() { + const tracks = this.player.getVariantTracks(); + + const languagesAndRoles = this.player.getAudioLanguagesAndRoles(); + const languages = languagesAndRoles.map((langAndRole) => { + return langAndRole.language; + }); + + shaka.ui.LanguageUtils.updateLanguages(tracks, this.audioLangMenu_, + languages, + this.onAudioLanguageSelected_.bind(this), /* updateChosen */ true, + this.currentAudioLanguage_, + this.localization); + shaka.ui.Utils.focusOnTheChosenItem(this.audioLangMenu_); + } + + + /** + * @param {string} language + * @private + */ + onAudioLanguageSelected_(language) { + this.player.selectAudioLanguage(language); + } + + + /** + * @private + */ + updateLocalizedStrings_() { + const LocIds = shaka.ui.Locales.Ids; + + this.backFromLanguageButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_BACK)); + this.languagesButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_LANGUAGE)); + this.languageNameSpan_.textContent = + this.localization.resolve(LocIds.LABEL_LANGUAGE); + this.backFromLanguageSpan_.textContent = + this.localization.resolve(LocIds.LABEL_LANGUAGE); + } +}; + + +/** + * @implements {shaka.extern.IUIElement.Factory} + * @final + */ +shaka.ui.AudioLanguageSelection.Factory = class { + /** @override */ + create(rootElement, controls) { + return new shaka.ui.AudioLanguageSelection(rootElement, controls); + } +}; + +shaka.ui.OverflowMenu.registerElement( + 'language', new shaka.ui.AudioLanguageSelection.Factory()); diff --git a/ui/cast_button.js b/ui/cast_button.js new file mode 100644 index 000000000..aeaa963fc --- /dev/null +++ b/ui/cast_button.js @@ -0,0 +1,184 @@ +/** + * @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. + */ + + +goog.provide('shaka.ui.CastButton'); + +goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Locales'); +goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.OverflowMenu'); +goog.require('shaka.ui.Utils'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.CastButton = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + /** @private {!shaka.cast.CastProxy} */ + this.castProxy_ = this.controls.getCastProxy(); + + /** @private {!HTMLElement} */ + this.castButton_ = shaka.ui.Utils.createHTMLElement('button'); + this.castButton_.classList.add('shaka-cast-button'); + this.castButton_.setAttribute('aria-pressed', 'false'); + + /** @private {!HTMLElement} */ + this.castIcon_ = shaka.ui.Utils.createHTMLElement('i'); + this.castIcon_.classList.add('material-icons'); + this.castIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.CAST; + this.castButton_.appendChild(this.castIcon_); + + const label = shaka.ui.Utils.createHTMLElement('label'); + label.classList.add('shaka-overflow-button-label'); + this.castNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); + label.appendChild(this.castNameSpan_); + + this.castCurrentSelectionSpan_ = + shaka.ui.Utils.createHTMLElement('span'); + this.castCurrentSelectionSpan_.classList.add( + 'shaka-current-selection-span'); + label.appendChild(this.castCurrentSelectionSpan_); + this.castButton_.appendChild(label); + this.parent.appendChild(this.castButton_); + + // Setup strings in the correct language + this.updateLocalizedStrings_(); + + // Setup button display and state according to the current cast status + this.onCastStatusChange_(); + + 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.castButton_, 'click', () => { + this.onCastClick_(); + }); + + this.eventManager.listen(this.controls, 'caststatuschanged', () => { + this.onCastStatusChange_(); + }); + } + + + /** @private */ + async onCastClick_() { + if (this.castProxy_.isCasting()) { + this.castProxy_.suggestDisconnect(); + } else { + try { + this.castButton_.disabled = true; + await this.castProxy_.cast(); + this.castButton_.disabled = false; + } catch (error) { + this.castButton_.disabled = false; + if (error.code != shaka.util.Error.Code.CAST_CANCELED_BY_USER) { + this.controls.dispatchEvent(new shaka.util.FakeEvent('error', { + errorDetails: error, + })); + } + } + } + } + + + /** + * @private + */ + onCastStatusChange_() { + const canCast = this.castProxy_.canCast() && this.controls.isCastAllowed(); + const isCasting = this.castProxy_.isCasting(); + const materialDesignIcons = shaka.ui.Enums.MaterialDesignIcons; + shaka.ui.Utils.setDisplay(this.castButton_, canCast); + this.castIcon_.textContent = isCasting ? + materialDesignIcons.EXIT_CAST : + materialDesignIcons.CAST; + + // Aria-pressed set to true when casting, set to false otherwise. + if (canCast) { + if (isCasting) { + this.castButton_.setAttribute('aria-pressed', 'true'); + } else { + this.castButton_.setAttribute('aria-pressed', 'false'); + } + } + + this.setCurrentCastSelection_(); + } + + + /** + * @private + */ + setCurrentCastSelection_() { + if (this.castProxy_.isCasting()) { + this.castCurrentSelectionSpan_.textContent = + this.castProxy_.receiverName(); + } else { + this.castCurrentSelectionSpan_.textContent = + this.localization.resolve(shaka.ui.Locales.Ids.LABEL_NOT_CASTING); + } + } + + + /** + * @private + */ + updateLocalizedStrings_() { + const LocIds = shaka.ui.Locales.Ids; + + this.castButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_CAST)); + this.castNameSpan_.textContent = + this.localization.resolve(LocIds.LABEL_CAST); + + // If we're not casting, string "not casting" will be displayed, + // which needs localization. + this.setCurrentCastSelection_(); + } +}; + + +/** + * @implements {shaka.extern.IUIElement.Factory} + * @final + */ +shaka.ui.CastButton.Factory = class { + /** @override */ + create(rootElement, controls) { + return new shaka.ui.CastButton(rootElement, controls); + } +}; + +shaka.ui.OverflowMenu.registerElement( + 'cast', new shaka.ui.CastButton.Factory()); diff --git a/ui/controls.js b/ui/controls.js index fd2544c8e..42e8d8ff6 100644 --- a/ui/controls.js +++ b/ui/controls.js @@ -17,6 +17,7 @@ goog.provide('shaka.ui.Controls'); +goog.provide('shaka.ui.ControlsPanel'); goog.require('goog.asserts'); goog.require('shaka.ui.Constants'); @@ -109,7 +110,7 @@ shaka.ui.Controls = function(player, videoContainer, video, config) { this.hideSettingsMenusTimer_ = new shaka.util.Timer(() => { /** type {function(!HTMLElement)} */ const hide = (control) => { - shaka.ui.Controls.setDisplay(control, /* visible= */ false); + shaka.ui.Utils.setDisplay(control, /* visible= */ false); }; for (const menu of this.settingsMenus_) { @@ -170,7 +171,7 @@ goog.inherits(shaka.ui.Controls, shaka.util.FakeEventTarget); /** @private {!Map.} */ -shaka.ui.Controls.elementNamesToFactories_ = new Map(); +shaka.ui.ControlsPanel.elementNamesToFactories_ = new Map(); /** @@ -216,7 +217,7 @@ shaka.ui.Controls.prototype.destroy = function() { * @export */ shaka.ui.Controls.registerElement = function(name, factory) { - shaka.ui.Controls.elementNamesToFactories_.set(name, factory); + shaka.ui.ControlsPanel.elementNamesToFactories_.set(name, factory); }; @@ -255,7 +256,7 @@ shaka.ui.Controls.prototype.loadComplete = function() { shaka.ui.Controls.prototype.setEnabledShakaControls = function(enabled) { this.enabled_ = enabled; if (enabled) { - shaka.ui.Controls.setDisplay( + shaka.ui.Utils.setDisplay( this.controlsButtonPanel_.parentElement, true); // If we're hiding native controls, make sure the video element itself is @@ -263,7 +264,7 @@ shaka.ui.Controls.prototype.setEnabledShakaControls = function(enabled) { this.video_.tabIndex = -1; this.video_.controls = false; } else { - shaka.ui.Controls.setDisplay( + shaka.ui.Utils.setDisplay( this.controlsButtonPanel_.parentElement, false); } @@ -406,29 +407,6 @@ shaka.ui.Controls.prototype.setLastTouchEventTime = function(time) { }; -/** - * Depending on the value of display, sets/removes css class of element to - * either display it or hide. - * - * @param {Element} element - * @param {boolean} display - * @export - */ -shaka.ui.Controls.setDisplay = function(element, display) { - if (!element) return; - if (display) { - element.classList.add('shaka-displayed'); - // Removing a non-existent class doesn't throw, so, even if - // the element is not hidden, this should be fine. Same for displayed - // below. - element.classList.remove('shaka-hidden'); - } else { - element.classList.add('shaka-hidden'); - element.classList.remove('shaka-displayed'); - } -}; - - /** * Display controls even if css says overwise. * Normally, controls opacity is controled by CSS, but there are @@ -607,14 +585,8 @@ shaka.ui.Controls.prototype.addControlsButtonPanel_ = function() { // Create the elements specified by controlPanelElements for (let i = 0; i < this.config_.controlPanelElements.length; i++) { const name = this.config_.controlPanelElements[i]; - if (shaka.ui.Controls.elementNamesToFactories_.get(name)) { - if (shaka.ui.Controls.controlPanelElements_.indexOf(name) == -1) { - // Not a control panel element, skip - shaka.log.warning('Element is not part of control panel ' + - 'elements and will be skipped', name); - continue; - } - const factory = shaka.ui.Controls.elementNamesToFactories_.get(name); + if (shaka.ui.ControlsPanel.elementNamesToFactories_.get(name)) { + const factory = shaka.ui.ControlsPanel.elementNamesToFactories_.get(name); this.elements_.push(factory.create(this.controlsButtonPanel_, this)); } } @@ -748,15 +720,6 @@ shaka.ui.Controls.prototype.addSeekBar_ = function() { }; -/** - * Checks if the cast proxy can cast. - * @return {boolean} - */ -shaka.ui.Controls.prototype.canCast = function() { - return this.castProxy_.canCast(); -}; - - /** * Hiding the cursor when the mouse stops moving seems to be the only decent UX * in fullscreen mode. Since we can't use pure CSS for that, we use events both @@ -1135,12 +1098,12 @@ shaka.ui.Controls.prototype.updateTimeAndSeekRange_ = function() { const seekRange = this.player_.seekRange(); const seekWindow = seekRange.end - seekRange.start; if (seekWindow < Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR) { - shaka.ui.Controls.setDisplay(this.seekBarContainer_, false); + shaka.ui.Utils.setDisplay(this.seekBarContainer_, false); for (let menu of this.settingsMenus_) { menu.classList.add('shaka-low-position'); } } else { - shaka.ui.Controls.setDisplay(this.seekBarContainer_, true); + shaka.ui.Utils.setDisplay(this.seekBarContainer_, true); for (let menu of this.settingsMenus_) { menu.classList.remove('shaka-low-position'); } @@ -1321,16 +1284,3 @@ shaka.ui.Controls.createLocalization_ = function() { return localization; }; - - -/** @private {!Array.} */ -shaka.ui.Controls.controlPanelElements_ = [ - 'time_and_duration', - 'mute', - 'volume', - 'fullscreen', - 'overflow_menu', - 'rewind', - 'fast_forward', - 'spacer', -]; diff --git a/ui/language_utils.js b/ui/language_utils.js new file mode 100644 index 000000000..6e778e60b --- /dev/null +++ b/ui/language_utils.js @@ -0,0 +1,140 @@ +/** + * @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. + */ + + +goog.provide('shaka.ui.LanguageUtils'); + +goog.require('mozilla.LanguageMapping'); + + +shaka.ui.LanguageUtils = class { + /** + * @param {!Array.} tracks + * @param {!HTMLElement} langMenu + * @param {!Array.} languages + * @param {function(string)} onLanguageSelected + * @param {boolean} updateChosen + * @param {!HTMLElement} currentSelectionElement + * @param {shaka.ui.Localization} localization + */ + // TODO: Do the benefits of having this common code in a method still + // outweigh the complexity of the parameter list? + static updateLanguages(tracks, langMenu, languages, onLanguageSelected, + updateChosen, currentSelectionElement, localization) { + // Using array.filter(f)[0] as an alternative to array.find(f) which is + // not supported in IE11. + const activeTracks = tracks.filter(function(track) { + return track.active == true; + }); + const selectedTrack = activeTracks[0]; + + // Remove old languages + // 1. Save the back to menu button + const backButton = shaka.ui.Utils.getFirstDescendantWithClassName( + langMenu, 'shaka-back-to-overflow-button'); + + // 2. Remove everything + while (langMenu.firstChild) { + langMenu.removeChild(langMenu.firstChild); + } + + // 3. Add the backTo Menu button back + langMenu.appendChild(backButton); + + // 4. Add new buttons + languages.forEach((language) => { + let button = shaka.ui.Utils.createHTMLElement('button'); + button.addEventListener('click', () => { onLanguageSelected(language); }); + + let span = shaka.ui.Utils.createHTMLElement('span'); + span.textContent = + shaka.ui.LanguageUtils.getLanguageName(language, localization); + button.appendChild(span); + + if (updateChosen && (language == selectedTrack.language)) { + button.appendChild(shaka.ui.Utils.checkmarkIcon()); + span.classList.add('shaka-chosen-item'); + button.setAttribute('aria-selected', 'true'); + currentSelectionElement.textContent = span.textContent; + } + langMenu.appendChild(button); + }); + } + + + /** + * Returns the language's name for itself in its own script (autoglottonym), + * if we have it. + * + * If the locale, including region, can be mapped to a name, we return a very + * specific name including the region. For example, "de-AT" would map to + * "Deutsch (Österreich)" or Austrian German. + * + * If only the language part of the locale is in our map, we append the locale + * itself for specificity. For example, "ar-EG" (Egyptian Arabic) would map + * to "ﺎﻠﻋﺮﺒﻳﺓ (ar-EG)". In this way, multiple versions of Arabic whose + * regions are not in our map would not all look the same in the language + * list, but could be distinguished by their locale. + * + * Finally, if language part of the locale is not in our map, we label it + * "unknown", as translated to the UI locale, and we append the locale itself + * for specificity. For example, "sjn" would map to "Unknown (sjn)". In this + * way, multiple unrecognized languages would not all look the same in the + * language list, but could be distinguished by their locale. + * + * @param {string} locale + * @param {shaka.ui.Localization} localization + * @return {string} The language's name for itself in its own script, or as + * close as we can get with the information we have. + */ + static getLanguageName(locale, localization) { + if (!locale && !localization) { + return ''; + } + + // Shorthand for resolving a localization ID. + const resolve = (id) => localization.resolve(id); + + // Handle some special cases first. These are reserved language tags that + // are used to indicate something that isn't one specific language. + switch (locale) { + case 'mul': + return resolve(shaka.ui.Locales.Ids.LABEL_MULTIPLE_LANGUAGES); + case 'zxx': + return resolve(shaka.ui.Locales.Ids.LABEL_NOT_APPLICABLE); + } + + // Extract the base language from the locale as a fallback step. + const language = shaka.util.LanguageUtils.getBase(locale); + + // First try to resolve the full language name. + // If that fails, try the base. + // Finally, report "unknown". + // When there is a loss of specificity (either to a base language or to + // "unknown"), we should append the original language code. + // Otherwise, there may be multiple identical-looking items in the list. + if (locale in mozilla.LanguageMapping) { + return mozilla.LanguageMapping[locale].nativeName; + } else if (language in mozilla.LanguageMapping) { + return mozilla.LanguageMapping[language].nativeName + + ' (' + locale + ')'; + } else { + return resolve(shaka.ui.Locales.Ids.LABEL_UNKNOWN_LANGUAGE) + + ' (' + locale + ')'; + } + } +}; diff --git a/ui/overflow_menu.js b/ui/overflow_menu.js index 2cf56b2d2..6ffbc1946 100644 --- a/ui/overflow_menu.js +++ b/ui/overflow_menu.js @@ -18,8 +18,8 @@ goog.provide('shaka.ui.OverflowMenu'); -goog.require('mozilla.LanguageMapping'); goog.require('shaka.ui.Constants'); +goog.require('shaka.ui.Controls'); goog.require('shaka.ui.Element'); goog.require('shaka.ui.Enums'); goog.require('shaka.ui.Locales'); @@ -43,23 +43,6 @@ goog.require('shaka.ui.Utils'); /** @private {!shaka.extern.UIConfiguration} */ this.config_ = this.controls.getConfig(); - /** @private {!HTMLMediaElement} */ - this.localVideo_ = this.controls.getLocalVideo(); - - /** @private {!shaka.cast.CastProxy} */ - this.castProxy_ = this.controls.getCastProxy(); - - this.initOptionalElementsToNull_(); - - /** @private {!Map.} */ - this.elementNamesToFunctions_ = new Map([ - ['captions', () => { this.addCaptionButton_(); }], - ['cast', () => { this.addCastButton_(); }], - ['quality', () => { this.addResolutionButton_(); }], - ['language', () => { this.addLanguagesButton_(); }], - ['picture_in_picture', () => { this.addPipButton_(); }], - ]); - /** @private {!HTMLElement} */ this.controlsContainer_ = this.controls.getControlsContainer(); @@ -67,6 +50,8 @@ goog.require('shaka.ui.Utils'); this.addOverflowMenu_(); + this.createChildren_(); + /** @private {!NodeList.} */ this.backToOverflowMenuButtons_ = this.controls.getVideoContainer(). getElementsByClassName('shaka-back-to-overflow-button'); @@ -77,7 +62,7 @@ goog.require('shaka.ui.Utils'); button.addEventListener('click', () => { // Hide the submenus, display the overflow menu this.controls.hideSettingsMenus(); - shaka.ui.Controls.setDisplay(this.overflowMenu_, true); + shaka.ui.Utils.setDisplay(this.overflowMenu_, true); // If there are back to overflow menu buttons, there must be // overflow menu buttons, but oh well @@ -93,64 +78,22 @@ goog.require('shaka.ui.Utils'); this.eventManager.listen( this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => { - this.updateLocalizedStrings_(); + this.updateAriaLabel_(); }); this.eventManager.listen( this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => { - this.updateLocalizedStrings_(); + this.updateAriaLabel_(); }); + this.eventManager.listen( - this.localVideo_, 'enterpictureinpicture', () => { - this.onEnterPictureInPicture_(); + this.controls, 'submenuopen', () => { + // Hide the main overflow menu if one of the sub menus has + // been opened. + shaka.ui.Utils.setDisplay(this.overflowMenu_, false); }); - this.eventManager.listen( - this.localVideo_, 'leavepictureinpicture', () => { - this.onLeavePictureInPicture_(); - }); - - if (this.castButton_) { - this.eventManager.listen( - this.castButton_, 'click', () => { - this.onCastClick_(); - }); - } - - if (this.captionButton_) { - this.eventManager.listen( - this.captionButton_, 'click', () => { - this.onCaptionClick_(); - }); - } - - if (this.pipButton_) { - this.eventManager.listen( - this.pipButton_, 'click', () => { - this.onPipClick_(); - }); - } - - this.eventManager.listen( - this.player, 'texttrackvisibility', () => { - this.onCaptionStateChange_(); - }); - - this.eventManager.listen( - this.player, 'trackschanged', () => { - this.onTracksChange_(); - }); - - this.eventManager.listen( - this.player, 'variantchanged', () => { - this.onVariantChange_(); - }); - - this.eventManager.listen( - this.player, 'textchanged', () => { - this.updateTextLanguages_(); - }); this.eventManager.listen( this.overflowMenu_, 'touchstart', (event) => { @@ -158,168 +101,30 @@ goog.require('shaka.ui.Utils'); event.stopPropagation(); }); - this.eventManager.listen( - this.overflowMenuButton_, 'click', () => { + this.eventManager.listen(this.overflowMenuButton_, 'click', () => { this.onOverflowMenuButtonClick_(); }); - if (this.resolutionButton_) { - this.eventManager.listen( - this.resolutionButton_, 'click', () => { - this.onResolutionClick_(); - }); - } - - if (this.languagesButton_) { - this.eventManager.listen( - this.languagesButton_, 'click', () => { - this.onLanguagesClick_(); - }); - } - - this.eventManager.listen( - this.controls, 'caststatuschanged', (e) => { - this.onCastStatusChange_(e); - }); - - this.eventManager.listen( this.controlsContainer_, 'touchstart', (event) => { // If the overflow menu is showing, hide it on a touch event if (this.overflowMenu_.classList.contains('shaka-displayed')) { - shaka.ui.Controls.setDisplay(this.overflowMenu_, false); + shaka.ui.Utils.setDisplay(this.overflowMenu_, false); // Stop this event from becoming a click event. event.preventDefault(); } }); - // Initialize caption state with a fake event. - this.onCaptionStateChange_(); - - const LocIds = shaka.ui.Locales.Ids; - - /** @private {!Map.} */ - this.ariaLabels_ = new Map() - .set(this.captionButton_, LocIds.ARIA_LABEL_CAPTIONS) - .set(this.backFromCaptionsButton_, LocIds.ARIA_LABEL_BACK) - .set(this.backFromResolutionButton_, LocIds.ARIA_LABEL_BACK) - .set(this.backFromLanguageButton_, LocIds.ARIA_LABEL_BACK) - .set(this.resolutionButton_, LocIds.ARIA_LABEL_RESOLUTION) - .set(this.languagesButton_, LocIds.ARIA_LABEL_LANGUAGE) - .set(this.castButton_, LocIds.ARIA_LABEL_CAST) - .set(this.overflowMenuButton_, LocIds.ARIA_LABEL_MORE_SETTINGS); - - /** @private {!Map.} */ - this.textContentToLocalize_ = new Map() - .set(this.captionsNameSpan_, LocIds.LABEL_CAPTIONS) - .set(this.backFromCaptionsSpan_, LocIds.LABEL_CAPTIONS) - .set(this.captionsOffSpan_, LocIds.LABEL_CAPTIONS_OFF) - .set(this.castNameSpan_, LocIds.LABEL_CAST) - .set(this.backFromResolutionSpan_, LocIds.LABEL_RESOLUTION) - .set(this.resolutionNameSpan_, LocIds.LABEL_RESOLUTION) - .set(this.abrOnSpan_, LocIds.LABEL_AUTO_QUALITY) - .set(this.languageNameSpan_, LocIds.LABEL_LANGUAGE) - .set(this.backFromLanguageSpan_, LocIds.LABEL_LANGUAGE) - .set(this.pipNameSpan_, LocIds.LABEL_PICTURE_IN_PICTURE); - - // Set all the localized strings with currently preferred language - this.updateLocalizedStrings_(); + this.updateAriaLabel_(); } - /** - * @private + * @param {string} name + * @param {!shaka.extern.IUIElement.Factory} factory + * @export */ - initOptionalElementsToNull_() { - /** @private {HTMLElement} */ - this.captionButton_ = null; - - /** @private {HTMLElement} */ - this.captionIcon_ = null; - - /** @private {HTMLElement} */ - this.castButton_ = null; - - /** @private {HTMLElement} */ - this.castIcon_ = null; - - /** @private {HTMLElement} */ - this.overflowMenuButton_ = null; - - /** @private {HTMLElement} */ - this.resolutionButton_ = null; - - /** @private {HTMLElement} */ - this.languagesButton_ = null; - - /** @private {HTMLElement} */ - this.resolutionMenu_ = null; - - /** @private {HTMLElement} */ - this.audioLangMenu_ = null; - - /** @private {HTMLElement} */ - this.textLangMenu_ = null; - - /** @private {HTMLElement} */ - this.currentResolution_ = null; - - /** @private {HTMLElement} */ - this.castNameSpan_ = null; - - /** @private {HTMLElement} */ - this.currentAudioLanguage_ = null; - - /** @private {HTMLElement} */ - this.currentCaptions_ = null; - - /** @private {HTMLElement} */ - this.captionsNameSpan_ = null; - - /** @private {HTMLElement} */ - this.backFromCaptionsSpan_ = null; - - /** @private {HTMLElement} */ - this.backFromResolutionButton_ = null; - - /** @private {HTMLElement} */ - this.backFromLanguageButton_ = null; - - /** @private {HTMLElement} */ - this.captionsOffSpan_ = null; - - /** @private {HTMLElement} */ - this.castCurrentSelectionSpan_ = null; - - /** @private {HTMLElement} */ - this.backFromResolutionSpan_ = null; - - /** @private {HTMLElement} */ - this.resolutionNameSpan_ = null; - - /** @private {HTMLElement} */ - this.languageNameSpan_ = null; - - /** @private {HTMLElement} */ - this.backFromLanguageSpan_ = null; - - /** @private {HTMLElement} */ - this.abrOnSpan_ = null; - - /** @private {HTMLElement} */ - this.backFromCaptionsButton_ = null; - - /** @private {HTMLElement} */ - this.pipButton_ = null; - - /** @private {HTMLElement} */ - this.pipNameSpan_ = null; - - /** @private {HTMLElement} */ - this.currentPipState_ = null; - - /** @private {HTMLElement} */ - this.pipIcon_ = null; + static registerElement(name, factory) { + shaka.ui.OverflowMenu.elementNamesToFactories_.set(name, factory); } @@ -334,26 +139,6 @@ goog.require('shaka.ui.Utils'); this.overflowMenu_.classList.add('shaka-show-controls-on-mouse-over'); this.overflowMenu_.classList.add('shaka-settings-menu'); this.controlsContainer_.appendChild(this.overflowMenu_); - - for (let i = 0; i < this.config_.overflowMenuButtons.length; i++) { - const name = this.config_.overflowMenuButtons[i]; - if (this.elementNamesToFunctions_.get(name)) { - this.elementNamesToFunctions_.get(name)(); - } - } - - // Add settings menus - if (this.config_.overflowMenuButtons.indexOf('quality') > -1) { - this.addResolutionMenu_(); - } - - if (this.config_.overflowMenuButtons.indexOf('language') > -1) { - this.addAudioLangMenu_(); - } - - if (this.config_.overflowMenuButtons.indexOf('captions') > -1) { - this.addTextLangMenu_(); - } } @@ -361,6 +146,7 @@ goog.require('shaka.ui.Utils'); * @private */ addOverflowMenuButton_() { + /** @private {!HTMLElement} */ this.overflowMenuButton_ = shaka.ui.Utils.createHTMLElement('button'); this.overflowMenuButton_.classList.add('shaka-overflow-menu-button'); this.overflowMenuButton_.classList.add('shaka-no-propagation'); @@ -374,763 +160,14 @@ goog.require('shaka.ui.Utils'); /** * @private */ - addCaptionButton_() { - this.captionButton_ = shaka.ui.Utils.createHTMLElement('button'); - this.captionButton_.classList.add('shaka-caption-button'); - this.captionIcon_ = shaka.ui.Utils.createHTMLElement('i'); - this.captionIcon_.classList.add('material-icons'); - this.captionIcon_.textContent = - shaka.ui.Enums.MaterialDesignIcons.CLOSED_CAPTIONS; - - this.captionButton_.appendChild(this.captionIcon_); - - const label = shaka.ui.Utils.createHTMLElement('label'); - label.classList.add('shaka-overflow-button-label'); - - this.captionsNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); - - label.appendChild(this.captionsNameSpan_); - - this.currentCaptions_ = shaka.ui.Utils.createHTMLElement('span'); - this.currentCaptions_.classList.add('shaka-current-selection-span'); - label.appendChild(this.currentCaptions_); - this.captionButton_.appendChild(label); - this.overflowMenu_.appendChild(this.captionButton_); - } - - - /** - * @private - */ - addTextLangMenu_() { - this.textLangMenu_ = shaka.ui.Utils.createHTMLElement('div'); - this.textLangMenu_.classList.add('shaka-text-languages'); - this.textLangMenu_.classList.add('shaka-no-propagation'); - this.textLangMenu_.classList.add('shaka-show-controls-on-mouse-over'); - this.textLangMenu_.classList.add('shaka-settings-menu'); - - this.backFromCaptionsButton_ = shaka.ui.Utils.createHTMLElement('button'); - this.backFromCaptionsButton_.classList.add('shaka-back-to-overflow-button'); - this.textLangMenu_.appendChild(this.backFromCaptionsButton_); - - const backIcon = shaka.ui.Utils.createHTMLElement('i'); - backIcon.classList.add('material-icons'); - backIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.BACK; - this.backFromCaptionsButton_.appendChild(backIcon); - - this.backFromCaptionsSpan_ = shaka.ui.Utils.createHTMLElement('span'); - this.backFromCaptionsButton_.appendChild(this.backFromCaptionsSpan_); - - // Add the off option - const off = shaka.ui.Utils.createHTMLElement('button'); - off.setAttribute('aria-selected', 'true'); - this.textLangMenu_.appendChild(off); - - const chosenIcon = shaka.ui.Utils.createHTMLElement('i'); - chosenIcon.classList.add('material-icons'); - chosenIcon.classList.add('shaka-chosen-item'); - // This text content is actually a material design icon. - chosenIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.CHECKMARK; - // Screen reader should ignore 'done'. - chosenIcon.setAttribute('aria-hidden', 'true'); - off.appendChild(chosenIcon); - - this.captionsOffSpan_ = shaka.ui.Utils.createHTMLElement('span'); - - this.captionsOffSpan_.classList.add('shaka-auto-span'); - off.appendChild(this.captionsOffSpan_); - - this.controlsContainer_.appendChild(this.textLangMenu_); - } - - - /** - * @private - */ - addCastButton_() { - this.castButton_ = shaka.ui.Utils.createHTMLElement('button'); - - this.castButton_.classList.add('shaka-cast-button'); - if (!this.controls.canCast()) { - this.castButton_.classList.add('shaka-hidden'); - } - this.castButton_.setAttribute('aria-pressed', 'false'); - - this.castIcon_ = shaka.ui.Utils.createHTMLElement('i'); - this.castIcon_.classList.add('material-icons'); - // This text content is actually a material design icon. - this.castIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.CAST; - this.castButton_.appendChild(this.castIcon_); - - const label = shaka.ui.Utils.createHTMLElement('label'); - label.classList.add('shaka-overflow-button-label'); - this.castNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); - label.appendChild(this.castNameSpan_); - - this.castCurrentSelectionSpan_ = - shaka.ui.Utils.createHTMLElement('span'); - this.castCurrentSelectionSpan_.classList.add( - 'shaka-current-selection-span'); - label.appendChild(this.castCurrentSelectionSpan_); - this.castButton_.appendChild(label); - this.overflowMenu_.appendChild(this.castButton_); - } - - - /** - * @private - */ - addResolutionMenu_() { - this.resolutionMenu_ = shaka.ui.Utils.createHTMLElement('div'); - this.resolutionMenu_.classList.add('shaka-resolutions'); - this.resolutionMenu_.classList.add('shaka-no-propagation'); - this.resolutionMenu_.classList.add('shaka-show-controls-on-mouse-over'); - this.resolutionMenu_.classList.add('shaka-settings-menu'); - - this.backFromResolutionButton_ = - shaka.ui.Utils.createHTMLElement('button'); - this.backFromResolutionButton_.classList.add( - 'shaka-back-to-overflow-button'); - this.resolutionMenu_.appendChild(this.backFromResolutionButton_); - - const backIcon = shaka.ui.Utils.createHTMLElement('i'); - backIcon.classList.add('material-icons'); - backIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.BACK; - this.backFromResolutionButton_.appendChild(backIcon); - - this.backFromResolutionSpan_ = shaka.ui.Utils.createHTMLElement('span'); - this.backFromResolutionButton_.appendChild(this.backFromResolutionSpan_); - - - // Add the abr option - const auto = shaka.ui.Utils.createHTMLElement('button'); - auto.setAttribute('aria-selected', 'true'); - this.resolutionMenu_.appendChild(auto); - - const chosenIcon = shaka.ui.Utils.createHTMLElement('i'); - chosenIcon.classList.add('material-icons'); - chosenIcon.classList.add('shaka-chosen-item'); - chosenIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.CHECKMARK; - // Screen reader should ignore the checkmark. - chosenIcon.setAttribute('aria-hidden', 'true'); - auto.appendChild(chosenIcon); - - this.abrOnSpan_ = shaka.ui.Utils.createHTMLElement('span'); - this.abrOnSpan_.classList.add('shaka-auto-span'); - auto.appendChild(this.abrOnSpan_); - - this.controlsContainer_.appendChild(this.resolutionMenu_); - } - - - /** - * @private - */ - addResolutionButton_() { - this.resolutionButton_ = shaka.ui.Utils.createHTMLElement('button'); - - this.resolutionButton_.classList.add('shaka-resolution-button'); - - const icon = shaka.ui.Utils.createHTMLElement('i'); - icon.classList.add('material-icons'); - icon.textContent = shaka.ui.Enums.MaterialDesignIcons.RESOLUTION; - this.resolutionButton_.appendChild(icon); - - const label = shaka.ui.Utils.createHTMLElement('label'); - label.classList.add('shaka-overflow-button-label'); - this.resolutionNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); - label.appendChild(this.resolutionNameSpan_); - - this.currentResolution_ = shaka.ui.Utils.createHTMLElement('span'); - this.currentResolution_.classList.add('shaka-current-selection-span'); - label.appendChild(this.currentResolution_); - this.resolutionButton_.appendChild(label); - - this.overflowMenu_.appendChild(this.resolutionButton_); - } - - - /** - * @private - */ - addAudioLangMenu_() { - this.audioLangMenu_ = shaka.ui.Utils.createHTMLElement('div'); - this.audioLangMenu_.classList.add('shaka-audio-languages'); - this.audioLangMenu_.classList.add('shaka-no-propagation'); - this.audioLangMenu_.classList.add('shaka-show-controls-on-mouse-over'); - this.audioLangMenu_.classList.add('shaka-settings-menu'); - - this.backFromLanguageButton_ = shaka.ui.Utils.createHTMLElement('button'); - this.backFromLanguageButton_.classList.add('shaka-back-to-overflow-button'); - this.audioLangMenu_.appendChild(this.backFromLanguageButton_); - - const backIcon = shaka.ui.Utils.createHTMLElement('i'); - backIcon.classList.add('material-icons'); - backIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.BACK; - this.backFromLanguageButton_.appendChild(backIcon); - - this.backFromLanguageSpan_ = shaka.ui.Utils.createHTMLElement('span'); - this.backFromLanguageButton_.appendChild(this.backFromLanguageSpan_); - - this.controlsContainer_.appendChild(this.audioLangMenu_); - } - - - /** - * @private - */ - addLanguagesButton_() { - this.languagesButton_ = shaka.ui.Utils.createHTMLElement('button'); - this.languagesButton_.classList.add('shaka-language-button'); - - const icon = shaka.ui.Utils.createHTMLElement('i'); - icon.classList.add('material-icons'); - icon.textContent = shaka.ui.Enums.MaterialDesignIcons.LANGUAGE; - this.languagesButton_.appendChild(icon); - - const label = shaka.ui.Utils.createHTMLElement('label'); - label.classList.add('shaka-overflow-button-label'); - this.languageNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); - this.languageNameSpan_.classList.add('languageSpan'); - label.appendChild(this.languageNameSpan_); - - this.currentAudioLanguage_ = shaka.ui.Utils.createHTMLElement('span'); - this.currentAudioLanguage_.classList.add('shaka-current-selection-span'); - const language = this.player.getConfiguration().preferredAudioLanguage; - this.currentAudioLanguage_.textContent = this.getLanguageName_(language); - label.appendChild(this.currentAudioLanguage_); - - this.languagesButton_.appendChild(label); - - this.overflowMenu_.appendChild(this.languagesButton_); - } - - - /** - * @private - */ - addPipButton_() { - const LocIds = shaka.ui.Locales.Ids; - this.pipButton_ = shaka.ui.Utils.createHTMLElement('button'); - this.pipButton_.classList.add('shaka-pip-button'); - - this.pipIcon_ = shaka.ui.Utils.createHTMLElement('i'); - this.pipIcon_.classList.add('material-icons'); - // This text content is actually a material design icon. - // DO NOT LOCALIZE - this.pipIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.PIP; - this.pipButton_.appendChild(this.pipIcon_); - - const label = shaka.ui.Utils.createHTMLElement('label'); - label.classList.add('shaka-overflow-button-label'); - this.pipNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); - this.pipNameSpan_.textContent = - this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE); - label.appendChild(this.pipNameSpan_); - - this.currentPipState_ = shaka.ui.Utils.createHTMLElement('span'); - this.currentPipState_.classList.add('shaka-current-selection-span'); - this.currentPipState_.textContent = - this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE_OFF); - label.appendChild(this.currentPipState_); - - this.pipButton_.appendChild(label); - - this.overflowMenu_.appendChild(this.pipButton_); - - // Don't display the button if PiP is not supported or not allowed - // TODO: Can this ever change? Is it worth creating the button if the below - // condition is true? - if (!this.isPipAllowed_()) { - shaka.ui.Controls.setDisplay(this.pipButton_, false); - } - } - - - /** - * @return {boolean} - * @private - */ - isPipAllowed_() { - return document.pictureInPictureEnabled && - !this.video.disablePictureInPicture; - } - - - /** @private */ - onCaptionClick_() { - shaka.ui.Controls.setDisplay(this.overflowMenu_, false); - shaka.ui.Controls.setDisplay(this.textLangMenu_, true); - // Focus on the currently selected language button. - this.focusOnTheChosenItem_(this.textLangMenu_); - } - - - /** @private */ - onResolutionClick_() { - shaka.ui.Controls.setDisplay(this.overflowMenu_, false); - shaka.ui.Controls.setDisplay(this.resolutionMenu_, true); - // Focus on the currently selected resolution button. - this.focusOnTheChosenItem_(this.resolutionMenu_); - } - - - /** @private */ - onLanguagesClick_() { - shaka.ui.Controls.setDisplay(this.overflowMenu_, false); - shaka.ui.Controls.setDisplay(this.audioLangMenu_, true); - // Focus on the currently selected language button. - this.focusOnTheChosenItem_(this.audioLangMenu_); - } - - - /** @private */ - onTracksChange_() { - // TS content might have captions embedded in video stream, we can't know - // until we start transmuxing. So, always show caption button if we're - // playing TS content. - if (this.captionButton_) { - if (shaka.ui.Utils.isTsContent(this.player)) { - shaka.ui.Controls.setDisplay(this.captionButton_, true); - } else { - let hasText = this.player.getTextTracks().length; - shaka.ui.Controls.setDisplay(this.captionButton_, hasText > 0); + createChildren_() { + for (let i = 0; i < this.config_.overflowMenuButtons.length; i++) { + const name = this.config_.overflowMenuButtons[i]; + if (shaka.ui.OverflowMenu.elementNamesToFactories_.get(name)) { + const factory = shaka.ui.OverflowMenu.elementNamesToFactories_.get(name); + factory.create(this.overflowMenu_, this.controls); } } - - // Update language and resolution selections - this.updateResolutionSelection_(); - this.updateAudioLanguages_(); - this.updateTextLanguages_(); - } - - - /** @private */ - onVariantChange_() { - // Update language and resolution selections - this.updateResolutionSelection_(); - this.updateAudioLanguages_(); - } - - - /** @private */ - updateResolutionSelection_() { - // Only applicable if resolution button is a part of the UI - if (!this.resolutionButton_ || !this.resolutionMenu_) { - return; - } - - let tracks = this.player.getVariantTracks(); - // Hide resolution menu and button for audio-only content. - if (tracks.length && !tracks[0].height) { - shaka.ui.Controls.setDisplay(this.resolutionMenu_, false); - shaka.ui.Controls.setDisplay(this.resolutionButton_, false); - return; - } - tracks.sort(function(t1, t2) { - return t1.height - t2.height; - }); - tracks.reverse(); - - // If there is a selected variant track, then we filtering out any tracks in - // a different language. Then we use those remaining tracks to display the - // available resolutions. - const selectedTrack = tracks.find((track) => track.active); - if (selectedTrack) { - const language = selectedTrack.language; - // Filter by current audio language. - tracks = tracks.filter(function(track) { - return track.language == language; - }); - } - - // Remove old shaka-resolutions - // 1. Save the back to menu button - const backButton = shaka.ui.Utils.getFirstDescendantWithClassName( - this.resolutionMenu_, 'shaka-back-to-overflow-button'); - - // 2. Remove everything - while (this.resolutionMenu_.firstChild) { - this.resolutionMenu_.removeChild(this.resolutionMenu_.firstChild); - } - - // 3. Add the backTo Menu button back - this.resolutionMenu_.appendChild(backButton); - - const abrEnabled = this.player.getConfiguration().abr.enabled; - - // Add new ones - tracks.forEach((track) => { - let button = shaka.ui.Utils.createHTMLElement('button'); - button.classList.add('explicit-resolution'); - button.addEventListener('click', - this.onTrackSelected_.bind(this, track)); - - let span = shaka.ui.Utils.createHTMLElement('span'); - span.textContent = track.height + 'p'; - button.appendChild(span); - - if (!abrEnabled && track == selectedTrack) { - // If abr is disabled, mark the selected track's - // resolution. - button.setAttribute('aria-selected', 'true'); - button.appendChild(this.chosenIcon_()); - span.classList.add('shaka-chosen-item'); - this.currentResolution_.textContent = span.textContent; - } - this.resolutionMenu_.appendChild(button); - }); - - // Add the Auto button - let autoButton = shaka.ui.Utils.createHTMLElement('button'); - autoButton.addEventListener('click', function() { - let config = {abr: {enabled: true}}; - this.player.configure(config); - this.updateResolutionSelection_(); - }.bind(this)); - - let autoSpan = shaka.ui.Utils.createHTMLElement('span'); - autoSpan.textContent = - this.localization.resolve(shaka.ui.Locales.Ids.LABEL_AUTO_QUALITY); - autoButton.appendChild(autoSpan); - - // If abr is enabled reflect it by marking 'Auto' - // as selected. - if (abrEnabled) { - autoButton.setAttribute('aria-selected', 'true'); - autoButton.appendChild(this.chosenIcon_()); - - autoSpan.classList.add('shaka-chosen-item'); - - this.currentResolution_.textContent = - this.localization.resolve(shaka.ui.Locales.Ids.LABEL_AUTO_QUALITY); - } - - this.resolutionMenu_.appendChild(autoButton); - this.focusOnTheChosenItem_(this.resolutionMenu_); - } - - - /** @private */ - updateAudioLanguages_() { - // Only applicable if language button is a part of the UI - if (!this.languagesButton_ || - !this.audioLangMenu_ || !this.currentAudioLanguage_) { - return; - } - - const tracks = this.player.getVariantTracks(); - - const languagesAndRoles = this.player.getAudioLanguagesAndRoles(); - const languages = languagesAndRoles.map((langAndRole) => { - return langAndRole.language; - }); - - this.updateLanguages_(tracks, this.audioLangMenu_, languages, - this.onAudioLanguageSelected_, /* updateChosen */ true, - this.currentAudioLanguage_); - this.focusOnTheChosenItem_(this.audioLangMenu_); - } - - - /** @private */ - updateTextLanguages_() { - // Only applicable if captions button is a part of the UI - if (!this.captionButton_ || !this.textLangMenu_ || - !this.currentCaptions_) { - return; - } - - const tracks = this.player.getTextTracks(); - - const languagesAndRoles = this.player.getTextLanguagesAndRoles(); - const languages = languagesAndRoles.map((langAndRole) => { - return langAndRole.language; - }); - - this.updateLanguages_(tracks, this.textLangMenu_, languages, - this.onTextLanguageSelected_, - /* Don't mark current text language as chosen unless - captions are enabled */ - this.player.isTextTrackVisible(), - this.currentCaptions_); - - // Add the Off button - let offButton = shaka.ui.Utils.createHTMLElement('button'); - offButton.addEventListener('click', () => { - this.player.setTextTrackVisibility(false); - this.updateTextLanguages_(); - }); - - offButton.appendChild(this.captionsOffSpan_); - - this.textLangMenu_.appendChild(offButton); - - if (!this.player.isTextTrackVisible()) { - offButton.setAttribute('aria-selected', 'true'); - offButton.appendChild(this.chosenIcon_()); - this.captionsOffSpan_.classList.add('shaka-chosen-item'); - this.currentCaptions_.textContent = - this.localization.resolve(shaka.ui.Locales.Ids.LABEL_CAPTIONS_OFF); - } - - this.focusOnTheChosenItem_(this.textLangMenu_); - } - - - /** - * @param {!Array.} tracks - * @param {!HTMLElement} langMenu - * @param {!Array.} languages - * @param {function(string)} onLanguageSelected - * @param {boolean} updateChosen - * @param {!HTMLElement} currentSelectionElement - * @private - */ - updateLanguages_(tracks, langMenu, languages, onLanguageSelected, - updateChosen, currentSelectionElement) { - // Using array.filter(f)[0] as an alternative to array.find(f) which is - // not supported in IE11. - const activeTracks = tracks.filter(function(track) { - return track.active == true; - }); - const selectedTrack = activeTracks[0]; - - // Remove old languages - // 1. Save the back to menu button - const backButton = shaka.ui.Utils.getFirstDescendantWithClassName( - langMenu, 'shaka-back-to-overflow-button'); - - // 2. Remove everything - while (langMenu.firstChild) { - langMenu.removeChild(langMenu.firstChild); - } - - // 3. Add the backTo Menu button back - langMenu.appendChild(backButton); - - // 4. Add new buttons - languages.forEach((language) => { - let button = shaka.ui.Utils.createHTMLElement('button'); - button.addEventListener('click', onLanguageSelected.bind(this, language)); - - let span = shaka.ui.Utils.createHTMLElement('span'); - span.textContent = this.getLanguageName_(language); - button.appendChild(span); - - if (updateChosen && (language == selectedTrack.language)) { - button.appendChild(this.chosenIcon_()); - span.classList.add('shaka-chosen-item'); - button.setAttribute('aria-selected', 'true'); - currentSelectionElement.textContent = span.textContent; - } - langMenu.appendChild(button); - }); - } - - - /** - * Returns the language's name for itself in its own script (autoglottonym), - * if we have it. - * - * If the locale, including region, can be mapped to a name, we return a very - * specific name including the region. For example, "de-AT" would map to - * "Deutsch (Österreich)" or Austrian German. - * - * If only the language part of the locale is in our map, we append the locale - * itself for specificity. For example, "ar-EG" (Egyptian Arabic) would map - * to "ﺎﻠﻋﺮﺒﻳﺓ (ar-EG)". In this way, multiple versions of Arabic whose - * regions are not in our map would not all look the same in the language - * list, but could be distinguished by their locale. - * - * Finally, if language part of the locale is not in our map, we label it - * "unknown", as translated to the UI locale, and we append the locale itself - * for specificity. For example, "sjn" would map to "Unknown (sjn)". In this - * way, multiple unrecognized languages would not all look the same in the - * language list, but could be distinguished by their locale. - * - * @param {string} locale - * @return {string} The language's name for itself in its own script, or as - * close as we can get with the information we have. - * @private - */ - getLanguageName_(locale) { - if (!locale) { - return ''; - } - - // Shorthand for resolving a localization ID. - const resolve = (id) => this.localization.resolve(id); - - // Handle some special cases first. These are reserved language tags that - // are used to indicate something that isn't one specific language. - switch (locale) { - case 'mul': - return resolve(shaka.ui.Locales.Ids.LABEL_MULTIPLE_LANGUAGES); - case 'zxx': - return resolve(shaka.ui.Locales.Ids.LABEL_NOT_APPLICABLE); - } - - // Extract the base language from the locale as a fallback step. - const language = shaka.util.LanguageUtils.getBase(locale); - - // First try to resolve the full language name. - // If that fails, try the base. - // Finally, report "unknown". - // When there is a loss of specificity (either to a base language or to - // "unknown"), we should append the original language code. - // Otherwise, there may be multiple identical-looking items in the list. - if (locale in mozilla.LanguageMapping) { - return mozilla.LanguageMapping[locale].nativeName; - } else if (language in mozilla.LanguageMapping) { - return mozilla.LanguageMapping[language].nativeName + - ' (' + locale + ')'; - } else { - return resolve(shaka.ui.Locales.Ids.LABEL_UNKNOWN_LANGUAGE) + - ' (' + locale + ')'; - } - } - - - /** - * @param {!shaka.extern.Track} track - * @private - */ - onTrackSelected_(track) { - // Disable abr manager before changing tracks. - let config = {abr: {enabled: false}}; - this.player.configure(config); - - this.player.selectVariantTrack(track, /* clearBuffer */ true); - } - - - /** - * @param {string} language - * @private - */ - onAudioLanguageSelected_(language) { - this.player.selectAudioLanguage(language); - } - - - /** - * @param {string} language - * @return {!Promise} - * @private - */ - async onTextLanguageSelected_(language) { - await this.player.setTextTrackVisibility(true); - this.player.selectTextLanguage(language); - } - - - /** - * @param {HTMLElement} menu - * @private - */ - focusOnTheChosenItem_(menu) { - if (!menu) return; - const chosenItem = shaka.ui.Utils.getDescendantIfExists( - menu, 'shaka-chosen-item'); - if (chosenItem) { - chosenItem.parentElement.focus(); - } - } - - - /** - * @return {!Element} - * @private - */ - chosenIcon_() { - let chosenIcon = shaka.ui.Utils.createHTMLElement('i'); - chosenIcon.classList.add('material-icons'); - chosenIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.CHECKMARK; - // Screen reader should ignore 'done'. - chosenIcon.setAttribute('aria-hidden', 'true'); - return chosenIcon; - } - - - /** @private */ - onCaptionStateChange_() { - if (this.captionIcon_) { - if (this.player.isTextTrackVisible()) { - this.captionIcon_.classList.add('shaka-captions-on'); - this.captionIcon_.classList.remove('shaka-captions-off'); - } else { - this.captionIcon_.classList.add('shaka-captions-off'); - this.captionIcon_.classList.remove('shaka-captions-on'); - } - } - } - - - /** @private */ - async onCastClick_() { - if (this.castProxy_.isCasting()) { - this.castProxy_.suggestDisconnect(); - } else { - this.castButton_.disabled = true; - this.castProxy_.cast().then(function() { - this.castButton_.disabled = false; - // Success! - }.bind(this), function(error) { - this.castButton_.disabled = false; - if (error.code != shaka.util.Error.Code.CAST_CANCELED_BY_USER) { - this.controls.dispatchEvent(new shaka.util.FakeEvent('error', { - errorDetails: error, - })); - } - }.bind(this)); - - // If we're in picture-in-picture state, exit - if (document.pictureInPictureElement && this.pipButton_ != null) { - await this.onPipClick_(); - } - } - } - - - /** - * @return {!Promise} - * @private - */ - async onPipClick_() { - try { - if (!document.pictureInPictureElement) { - await this.video.requestPictureInPicture(); - } else { - await document.exitPictureInPicture(); - } - } catch (error) { - this.controls.dispatchEvent(new shaka.util.FakeEvent('error', { - errorDetails: error, - })); - } - } - - - /** @private */ - onEnterPictureInPicture_() { - const LocIds = shaka.ui.Locales.Ids; - this.pipIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.EXIT_PIP; - this.pipButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, - this.localization.resolve(LocIds.ARIA_LABEL_EXIT_PICTURE_IN_PICTURE)); - this.currentPipState_.textContent = - this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE_ON); - } - - - /** @private */ - onLeavePictureInPicture_() { - const LocIds = shaka.ui.Locales.Ids; - this.pipIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.PIP; - this.pipButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, - this.localization.resolve(LocIds.ARIA_LABEL_ENTER_PICTURE_IN_PICTURE)); - this.currentPipState_.textContent = - this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE_OFF); } @@ -1139,7 +176,7 @@ goog.require('shaka.ui.Utils'); if (this.controls.anySettingsMenusAreOpen()) { this.controls.hideSettingsMenus(); } else { - shaka.ui.Controls.setDisplay(this.overflowMenu_, true); + shaka.ui.Utils.setDisplay(this.overflowMenu_, true); this.controls.overrideCssShowControls(); // If overflow menu has currently visible buttons, focus on the // first one, when the menu opens. @@ -1161,131 +198,10 @@ goog.require('shaka.ui.Utils'); /** * @private */ - setCurrentCastSelection_() { - if (!this.castCurrentSelectionSpan_) { - return; - } - - if (this.castProxy_.isCasting()) { - this.castCurrentSelectionSpan_.textContent = - this.castProxy_.receiverName(); - } else { - this.castCurrentSelectionSpan_.textContent = - this.localization.resolve(shaka.ui.Locales.Ids.LABEL_NOT_CASTING); - } - } - - - /** - * @private - */ - updateLocalizedStrings_() { + updateAriaLabel_() { const LocIds = shaka.ui.Locales.Ids; - - // Localize aria labels - let elements = this.ariaLabels_.keys(); - for (const element of elements) { - if (element == null) { - continue; - } - - const id = this.ariaLabels_.get(element); - element.setAttribute(shaka.ui.Constants.ARIA_LABEL, - this.localization.resolve(id)); - } - - // Localize state-dependant labels - if (this.pipButton_) { - const pipAriaLabel = document.pictureInPictureElement ? - LocIds.ARIA_LABEL_EXIT_PICTURE_IN_PICTURE : - LocIds.ARIA_LABEL_ENTER_PICTURE_IN_PICTURE; - this.pipButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, - this.localization.resolve(pipAriaLabel)); - - const currentPipState = document.pictureInPictureElement ? - LocIds.LABEL_PICTURE_IN_PICTURE_ON : - LocIds.LABEL_PICTURE_IN_PICTURE_OFF; - - this.currentPipState_.textContent = - this.localization.resolve(currentPipState); - } - - // If we're not casting, string "not casting" will be displayed, - // which needs localization. - this.setCurrentCastSelection_(); - - // If we're at "auto" resolution, this string needs localization. - this.updateResolutionSelection_(); - - // If captions/subtitles are off, this string needs localization. - this.updateTextLanguages_(); - - // Localize text - elements = this.textContentToLocalize_.keys(); - for (const element of elements) { - if (element == null) { - continue; - } - - const id = this.textContentToLocalize_.get(element); - element.textContent = this.localization.resolve(id); - } - } - - /** - * @param {Event} e - * @private - */ - onCastStatusChange_(e) { - const canCast = this.castProxy_.canCast() && this.controls.isCastAllowed(); - const isCasting = e['newStatus']; - if (this.castButton_) { - const materialDesignIcons = shaka.ui.Enums.MaterialDesignIcons; - shaka.ui.Controls.setDisplay(this.castButton_, canCast); - this.castIcon_.textContent = isCasting ? - materialDesignIcons.EXIT_CAST : - materialDesignIcons.CAST; - - // Aria-pressed set to true when casting, set to false otherwise. - if (canCast) { - if (isCasting) { - this.castButton_.setAttribute('aria-pressed', 'true'); - } else { - this.castButton_.setAttribute('aria-pressed', 'false'); - } - } - } - - this.setCurrentCastSelection_(); - - const pipIsEnabled = (this.isPipAllowed_() && (this.pipButton_ != null)); - if (isCasting) { - // Picture-in-picture is not applicable if we're casting - if (pipIsEnabled) { - shaka.ui.Controls.setDisplay(this.pipButton_, false); - } - } else { - if (pipIsEnabled) { - shaka.ui.Controls.setDisplay(this.pipButton_, true); - } - } - } - - - /** - * Resolve a special language code to a name/description enum. - * - * @param {string} lang - * @return {string} - */ - resolveSpecialLanguageCode_(lang) { - if (lang == 'mul') { - return shaka.ui.Locales.Ids.LABEL_MULTIPLE_LANGUAGES; - } else if (lang == 'zxx') { - return shaka.ui.Locales.Ids.LABEL_NOT_APPLICABLE; - } else { - return shaka.ui.Locales.Ids.LABEL_UNKNOWN_LANGUAGE; - } + this.overflowMenuButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_MORE_SETTINGS)); } }; @@ -1304,3 +220,7 @@ shaka.ui.OverflowMenu.Factory = class { shaka.ui.Controls.registerElement( 'overflow_menu', new shaka.ui.OverflowMenu.Factory()); + +/** @private {!Map.} */ +shaka.ui.OverflowMenu.elementNamesToFactories_ = new Map(); + diff --git a/ui/pip_button.js b/ui/pip_button.js new file mode 100644 index 000000000..2cf60c4ac --- /dev/null +++ b/ui/pip_button.js @@ -0,0 +1,217 @@ +/** + * @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. + */ + + +goog.provide('shaka.ui.PipButton'); + +goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Locales'); +goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.OverflowMenu'); +goog.require('shaka.ui.Utils'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.PipButton = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + /** @private {!HTMLMediaElement} */ + this.localVideo_ = this.controls.getLocalVideo(); + + const LocIds = shaka.ui.Locales.Ids; + /** @private {!HTMLElement} */ + this.pipButton_ = shaka.ui.Utils.createHTMLElement('button'); + this.pipButton_.classList.add('shaka-pip-button'); + + /** @private {!HTMLElement} */ + this.pipIcon_ = shaka.ui.Utils.createHTMLElement('i'); + this.pipIcon_.classList.add('material-icons'); + this.pipIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.PIP; + this.pipButton_.appendChild(this.pipIcon_); + + const label = shaka.ui.Utils.createHTMLElement('label'); + label.classList.add('shaka-overflow-button-label'); + this.pipNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); + this.pipNameSpan_.textContent = + this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE); + label.appendChild(this.pipNameSpan_); + + /** @private {!HTMLElement} */ + this.currentPipState_ = shaka.ui.Utils.createHTMLElement('span'); + this.currentPipState_.classList.add('shaka-current-selection-span'); + label.appendChild(this.currentPipState_); + + this.pipButton_.appendChild(label); + + this.updateLocalizedStrings_(); + + this.parent.appendChild(this.pipButton_); + + // Don't display the button if PiP is not supported or not allowed + // TODO: Can this ever change? Is it worth creating the button if the below + // condition is true? + if (!this.isPipAllowed_()) { + shaka.ui.Utils.setDisplay(this.pipButton_, false); + } + + 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.pipButton_, 'click', () => { + this.onPipClick_(); + }); + + this.eventManager.listen( + this.localVideo_, 'enterpictureinpicture', () => { + this.onEnterPictureInPicture_(); + }); + + this.eventManager.listen( + this.localVideo_, 'leavepictureinpicture', () => { + this.onLeavePictureInPicture_(); + }); + + this.eventManager.listen(this.controls, 'caststatuschange', (e) => { + this.onCastStatusChange_(e); + }); + } + + + /** + * @return {boolean} + * @private + */ + isPipAllowed_() { + return document.pictureInPictureEnabled && + !this.video.disablePictureInPicture; + } + + + /** + * @return {!Promise} + * @private + */ + async onPipClick_() { + try { + if (!document.pictureInPictureElement) { + await this.video.requestPictureInPicture(); + } else { + await document.exitPictureInPicture(); + } + } catch (error) { + this.controls.dispatchEvent(new shaka.util.FakeEvent('error', { + errorDetails: error, + })); + } + } + + + /** @private */ + onEnterPictureInPicture_() { + const LocIds = shaka.ui.Locales.Ids; + this.pipIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.EXIT_PIP; + this.pipButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_EXIT_PICTURE_IN_PICTURE)); + this.currentPipState_.textContent = + this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE_ON); + } + + + /** @private */ + onLeavePictureInPicture_() { + const LocIds = shaka.ui.Locales.Ids; + this.pipIcon_.textContent = shaka.ui.Enums.MaterialDesignIcons.PIP; + this.pipButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_ENTER_PICTURE_IN_PICTURE)); + this.currentPipState_.textContent = + this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE_OFF); + } + + + /** + * @private + */ + updateLocalizedStrings_() { + const LocIds = shaka.ui.Locales.Ids; + + this.pipNameSpan_.textContent = + this.localization.resolve(LocIds.LABEL_PICTURE_IN_PICTURE); + + const ariaLabel = document.pictureInPictureElement ? + LocIds.ARIA_LABEL_EXIT_PICTURE_IN_PICTURE : + LocIds.ARIA_LABEL_ENTER_PICTURE_IN_PICTURE; + this.pipButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(ariaLabel)); + + const currentPipState = document.pictureInPictureElement ? + LocIds.LABEL_PICTURE_IN_PICTURE_ON : + LocIds.LABEL_PICTURE_IN_PICTURE_OFF; + + this.currentPipState_.textContent = + this.localization.resolve(currentPipState); + } + + /** + * @param {Event} e + * @private + */ + onCastStatusChange_(e) { + const isCasting = e['newStatus']; + + if (isCasting) { + // Picture-in-picture is not applicable if we're casting + if (this.isPipAllowed_()) { + shaka.ui.Utils.setDisplay(this.pipButton_, false); + } + } else { + if (this.isPipAllowed_()) { + shaka.ui.Utils.setDisplay(this.pipButton_, true); + } + } + } +}; + + +/** + * @implements {shaka.extern.IUIElement.Factory} + * @final + */ +shaka.ui.PipButton.Factory = class { + /** @override */ + create(rootElement, controls) { + return new shaka.ui.PipButton(rootElement, controls); + } +}; + +shaka.ui.OverflowMenu.registerElement( + 'picture_in_picture', new shaka.ui.PipButton.Factory()); diff --git a/ui/resolution_selection.js b/ui/resolution_selection.js new file mode 100644 index 000000000..a0874dcaf --- /dev/null +++ b/ui/resolution_selection.js @@ -0,0 +1,299 @@ +/** + * @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. + */ + + +goog.provide('shaka.ui.ResolutionSelection'); + +goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Enums'); +goog.require('shaka.ui.Locales'); +goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.OverflowMenu'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.ResolutionSelection = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + this.addResolutionButton_(); + + this.addResolutionMenu_(); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => { + this.updateLocalizedStrings_(); + // If abr is enabled, the 'Auto' string needs localization. + // TODO: is there a more efficient way of updating just the strings + // we need instead of running the whole resolution update? + this.updateResolutionSelection_(); + }); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => { + this.updateLocalizedStrings_(); + // If abr is enabled, the 'Auto' string needs localization. + // TODO: is there a more efficient way of updating just the strings + // we need instead of running the whole resolution update? + this.updateResolutionSelection_(); + }); + + this.eventManager.listen(this.resolutionButton_, 'click', () => { + this.onResolutionClick_(); + }); + + this.eventManager.listen(this.player, 'variantchanged', () => { + this.updateResolutionSelection_(); + }); + + this.eventManager.listen(this.player, 'trackschanged', () => { + this.updateResolutionSelection_(); + }); + + // Set up all the strings in the user's preferred language. + this.updateLocalizedStrings_(); + } + + + /** + * @private + */ + addResolutionButton_() { + /** @private {!HTMLElement}*/ + this.resolutionButton_ = shaka.ui.Utils.createHTMLElement('button'); + + this.resolutionButton_.classList.add('shaka-resolution-button'); + + const icon = shaka.ui.Utils.createHTMLElement('i'); + icon.classList.add('material-icons'); + icon.textContent = shaka.ui.Enums.MaterialDesignIcons.RESOLUTION; + this.resolutionButton_.appendChild(icon); + + const label = shaka.ui.Utils.createHTMLElement('label'); + label.classList.add('shaka-overflow-button-label'); + + /** @private {!HTMLElement}*/ + this.resolutionNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); + label.appendChild(this.resolutionNameSpan_); + + /** @private {!HTMLElement}*/ + this.currentResolution_ = shaka.ui.Utils.createHTMLElement('span'); + this.currentResolution_.classList.add('shaka-current-selection-span'); + label.appendChild(this.currentResolution_); + this.resolutionButton_.appendChild(label); + + this.parent.appendChild(this.resolutionButton_); + } + + + /** + * @private + */ + addResolutionMenu_() { + /** @private {!HTMLElement}*/ + this.resolutionMenu_ = shaka.ui.Utils.createHTMLElement('div'); + this.resolutionMenu_.classList.add('shaka-resolutions'); + this.resolutionMenu_.classList.add('shaka-no-propagation'); + this.resolutionMenu_.classList.add('shaka-show-controls-on-mouse-over'); + this.resolutionMenu_.classList.add('shaka-settings-menu'); + + /** @private {!HTMLElement}*/ + this.backFromResolutionButton_ = + shaka.ui.Utils.createHTMLElement('button'); + this.backFromResolutionButton_.classList.add( + 'shaka-back-to-overflow-button'); + this.resolutionMenu_.appendChild(this.backFromResolutionButton_); + + const backIcon = shaka.ui.Utils.createHTMLElement('i'); + backIcon.classList.add('material-icons'); + backIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.BACK; + this.backFromResolutionButton_.appendChild(backIcon); + + /** @private {!HTMLElement}*/ + this.backFromResolutionSpan_ = shaka.ui.Utils.createHTMLElement('span'); + this.backFromResolutionButton_.appendChild(this.backFromResolutionSpan_); + + + // Add the abr option + const auto = shaka.ui.Utils.createHTMLElement('button'); + auto.setAttribute('aria-selected', 'true'); + this.resolutionMenu_.appendChild(auto); + + auto.appendChild(shaka.ui.Utils.checkmarkIcon()); + + /** @private {!HTMLElement}*/ + this.abrOnSpan_ = shaka.ui.Utils.createHTMLElement('span'); + this.abrOnSpan_.classList.add('shaka-auto-span'); + auto.appendChild(this.abrOnSpan_); + + const controlsContainer = this.controls.getControlsContainer(); + controlsContainer.appendChild(this.resolutionMenu_); + } + + + /** @private */ + updateResolutionSelection_() { + let tracks = this.player.getVariantTracks(); + // Hide resolution menu and button for audio-only content. + if (tracks.length && !tracks[0].height) { + shaka.ui.Utils.setDisplay(this.resolutionMenu_, false); + shaka.ui.Utils.setDisplay(this.resolutionButton_, false); + return; + } + tracks.sort(function(t1, t2) { + return t1.height - t2.height; + }); + tracks.reverse(); + + // If there is a selected variant track, then we filter out any tracks in + // a different language. Then we use those remaining tracks to display the + // available resolutions. + const selectedTrack = tracks.find((track) => track.active); + if (selectedTrack) { + const language = selectedTrack.language; + // Filter by current audio language. + tracks = tracks.filter(function(track) { + return track.language == language; + }); + } + + // Remove old shaka-resolutions + // 1. Save the back to menu button + const backButton = shaka.ui.Utils.getFirstDescendantWithClassName( + this.resolutionMenu_, 'shaka-back-to-overflow-button'); + + // 2. Remove everything + while (this.resolutionMenu_.firstChild) { + this.resolutionMenu_.removeChild(this.resolutionMenu_.firstChild); + } + + // 3. Add the backTo Menu button back + this.resolutionMenu_.appendChild(backButton); + + const abrEnabled = this.player.getConfiguration().abr.enabled; + + // Add new ones + tracks.forEach((track) => { + let button = shaka.ui.Utils.createHTMLElement('button'); + button.classList.add('explicit-resolution'); + button.addEventListener('click', + this.onTrackSelected_.bind(this, track)); + + let span = shaka.ui.Utils.createHTMLElement('span'); + span.textContent = track.height + 'p'; + button.appendChild(span); + + if (!abrEnabled && track == selectedTrack) { + // If abr is disabled, mark the selected track's resolution. + button.setAttribute('aria-selected', 'true'); + button.appendChild(shaka.ui.Utils.checkmarkIcon()); + span.classList.add('shaka-chosen-item'); + this.currentResolution_.textContent = span.textContent; + } + this.resolutionMenu_.appendChild(button); + }); + + // Add the Auto button + let autoButton = shaka.ui.Utils.createHTMLElement('button'); + autoButton.addEventListener('click', function() { + let config = {abr: {enabled: true}}; + this.player.configure(config); + this.updateResolutionSelection_(); + }.bind(this)); + + let autoSpan = shaka.ui.Utils.createHTMLElement('span'); + autoSpan.textContent = + this.localization.resolve(shaka.ui.Locales.Ids.LABEL_AUTO_QUALITY); + autoButton.appendChild(autoSpan); + + // If abr is enabled reflect it by marking 'Auto' as selected. + if (abrEnabled) { + autoButton.setAttribute('aria-selected', 'true'); + autoButton.appendChild(shaka.ui.Utils.checkmarkIcon()); + + autoSpan.classList.add('shaka-chosen-item'); + + this.currentResolution_.textContent = + this.localization.resolve(shaka.ui.Locales.Ids.LABEL_AUTO_QUALITY); + } + + this.resolutionMenu_.appendChild(autoButton); + shaka.ui.Utils.focusOnTheChosenItem(this.resolutionMenu_); + } + + + /** @private */ + onResolutionClick_() { + this.controls.dispatchEvent(new shaka.util.FakeEvent('submenuopen')); + shaka.ui.Utils.setDisplay(this.resolutionMenu_, true); + shaka.ui.Utils.focusOnTheChosenItem(this.resolutionMenu_); + } + + /** + * @param {!shaka.extern.Track} track + * @private + */ + onTrackSelected_(track) { + // Disable abr manager before changing tracks. + let config = {abr: {enabled: false}}; + this.player.configure(config); + + this.player.selectVariantTrack(track, /* clearBuffer */ true); + } + + + /** + * @private + */ + updateLocalizedStrings_() { + const LocIds = shaka.ui.Locales.Ids; + + this.resolutionButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_RESOLUTION)); + this.backFromResolutionButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_RESOLUTION)); + this.backFromResolutionSpan_.textContent = + this.localization.resolve(LocIds.LABEL_RESOLUTION); + this.resolutionNameSpan_.textContent = + this.localization.resolve(LocIds.LABEL_RESOLUTION); + this.abrOnSpan_.textContent = + this.localization.resolve(LocIds.LABEL_AUTO_QUALITY); + } +}; + + +/** + * @implements {shaka.extern.IUIElement.Factory} + * @final + */ +shaka.ui.ResolutionSelection.Factory = class { + /** @override */ + create(rootElement, controls) { + return new shaka.ui.ResolutionSelection(rootElement, controls); + } +}; + +shaka.ui.OverflowMenu.registerElement( + 'quality', new shaka.ui.ResolutionSelection.Factory()); diff --git a/ui/text_selection.js b/ui/text_selection.js new file mode 100644 index 000000000..00ee9d9a6 --- /dev/null +++ b/ui/text_selection.js @@ -0,0 +1,290 @@ +/** + * @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. + */ + + +goog.provide('shaka.ui.TextSelection'); + +goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Enums'); +goog.require('shaka.ui.LanguageUtils'); +goog.require('shaka.ui.Locales'); +goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.OverflowMenu'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.TextSelection = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + this.addCaptionButton_(); + + this.addTextLangMenu_(); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => { + this.updateLocalizedStrings_(); + // If captions/subtitles are off, this string needs localization. + // TODO: is there a more efficient way of updating just the strings + // we need instead of running the whole language update? + this.updateTextLanguages_(); + }); + + this.eventManager.listen( + this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => { + this.updateLocalizedStrings_(); + // If captions/subtitles are off, this string needs localization. + // TODO: is there a more efficient way of updating just the strings + // we need instead of running the whole language update? + this.updateTextLanguages_(); + }); + + this.eventManager.listen(this.player, 'texttrackvisibility', () => { + this.onCaptionStateChange_(); + }); + + this.eventManager.listen(this.captionButton_, 'click', () => { + this.onCaptionClick_(); + }); + + this.eventManager.listen(this.player, 'textchanged', () => { + this.updateTextLanguages_(); + }); + + this.eventManager.listen(this.player, 'trackschanged', () => { + this.onTracksChange_(); + }); + + // Initialize caption state with a fake event. + this.onCaptionStateChange_(); + + // Set up all the strings in the user's preferred language. + this.updateLocalizedStrings_(); + } + + + /** + * @private + */ + addCaptionButton_() { + /** @private {!HTMLElement} */ + this.captionButton_ = shaka.ui.Utils.createHTMLElement('button'); + this.captionButton_.classList.add('shaka-caption-button'); + + /** @private {!HTMLElement} */ + this.captionIcon_ = shaka.ui.Utils.createHTMLElement('i'); + this.captionIcon_.classList.add('material-icons'); + this.captionIcon_.textContent = + shaka.ui.Enums.MaterialDesignIcons.CLOSED_CAPTIONS; + + if (this.player && this.player.isTextTrackVisible()) { + this.captionButton_.setAttribute('aria-pressed', 'true'); + } else { + this.captionButton_.setAttribute('aria-pressed', 'false'); + } + this.captionButton_.appendChild(this.captionIcon_); + + const label = shaka.ui.Utils.createHTMLElement('label'); + label.classList.add('shaka-overflow-button-label'); + + /** @private {!HTMLElement} */ + this.captionsNameSpan_ = shaka.ui.Utils.createHTMLElement('span'); + + label.appendChild(this.captionsNameSpan_); + + /** @private {!HTMLElement} */ + this.currentCaptions_ = shaka.ui.Utils.createHTMLElement('span'); + this.currentCaptions_.classList.add('shaka-current-selection-span'); + label.appendChild(this.currentCaptions_); + this.captionButton_.appendChild(label); + this.parent.appendChild(this.captionButton_); + } + + + /** + * @private + */ + addTextLangMenu_() { + /** @private {!HTMLElement} */ + this.textLangMenu_ = shaka.ui.Utils.createHTMLElement('div'); + this.textLangMenu_.classList.add('shaka-text-languages'); + this.textLangMenu_.classList.add('shaka-no-propagation'); + this.textLangMenu_.classList.add('shaka-show-controls-on-mouse-over'); + this.textLangMenu_.classList.add('shaka-settings-menu'); + + /** @private {!HTMLElement} */ + this.backFromCaptionsButton_ = shaka.ui.Utils.createHTMLElement('button'); + this.backFromCaptionsButton_.classList.add('shaka-back-to-overflow-button'); + this.textLangMenu_.appendChild(this.backFromCaptionsButton_); + + const backIcon = shaka.ui.Utils.createHTMLElement('i'); + backIcon.classList.add('material-icons'); + backIcon.textContent = shaka.ui.Enums.MaterialDesignIcons.BACK; + this.backFromCaptionsButton_.appendChild(backIcon); + + /** @private {!HTMLElement} */ + this.backFromCaptionsSpan_ = shaka.ui.Utils.createHTMLElement('span'); + this.backFromCaptionsButton_.appendChild(this.backFromCaptionsSpan_); + + // Add the off option + const off = shaka.ui.Utils.createHTMLElement('button'); + off.setAttribute('aria-selected', 'true'); + this.textLangMenu_.appendChild(off); + + off.appendChild(shaka.ui.Utils.checkmarkIcon()); + + /** @private {!HTMLElement} */ + this.captionsOffSpan_ = shaka.ui.Utils.createHTMLElement('span'); + + this.captionsOffSpan_.classList.add('shaka-auto-span'); + off.appendChild(this.captionsOffSpan_); + + const controlsContainer = this.controls.getControlsContainer(); + controlsContainer.appendChild(this.textLangMenu_); + } + + + /** @private */ + onCaptionClick_() { + this.controls.dispatchEvent(new shaka.util.FakeEvent('submenuopen')); + shaka.ui.Utils.setDisplay(this.textLangMenu_, true); + // Focus on the currently selected language button. + shaka.ui.Utils.focusOnTheChosenItem(this.textLangMenu_); + } + + + /** @private */ + onCaptionStateChange_() { + if (this.captionIcon_) { + if (this.player.isTextTrackVisible()) { + this.captionIcon_.classList.add('shaka-captions-on'); + this.captionIcon_.classList.remove('shaka-captions-off'); + } else { + this.captionIcon_.classList.add('shaka-captions-off'); + this.captionIcon_.classList.remove('shaka-captions-on'); + } + } + } + + /** @private */ + updateTextLanguages_() { + const tracks = this.player.getTextTracks(); + + const languagesAndRoles = this.player.getTextLanguagesAndRoles(); + const languages = languagesAndRoles.map((langAndRole) => { + return langAndRole.language; + }); + + shaka.ui.LanguageUtils.updateLanguages(tracks, this.textLangMenu_, + languages, + this.onTextLanguageSelected_.bind(this), + // Don't mark current text language as chosen unless captions are enabled + this.player.isTextTrackVisible(), + this.currentCaptions_, + this.localization); + + // Add the Off button + let offButton = shaka.ui.Utils.createHTMLElement('button'); + offButton.addEventListener('click', () => { + this.player.setTextTrackVisibility(false); + this.updateTextLanguages_(); + }); + + offButton.appendChild(this.captionsOffSpan_); + + this.textLangMenu_.appendChild(offButton); + + if (!this.player.isTextTrackVisible()) { + offButton.setAttribute('aria-selected', 'true'); + offButton.appendChild(shaka.ui.Utils.checkmarkIcon()); + this.captionsOffSpan_.classList.add('shaka-chosen-item'); + this.currentCaptions_.textContent = + this.localization.resolve(shaka.ui.Locales.Ids.LABEL_CAPTIONS_OFF); + } + + shaka.ui.Utils.focusOnTheChosenItem(this.textLangMenu_); + } + + + /** + * @param {string} language + * @return {!Promise} + * @private + */ + async onTextLanguageSelected_(language) { + await this.player.setTextTrackVisibility(true); + this.player.selectTextLanguage(language); + } + + + /** + * @private + */ + updateLocalizedStrings_() { + const LocIds = shaka.ui.Locales.Ids; + + this.captionButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_CAPTIONS)); + this.backFromCaptionsButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(LocIds.ARIA_LABEL_BACK)); + this.captionsNameSpan_.textContent = + this.localization.resolve(LocIds.LABEL_CAPTIONS); + this.backFromCaptionsSpan_.textContent = + this.localization.resolve(LocIds.LABEL_CAPTIONS); + this.captionsOffSpan_.textContent = + this.localization.resolve(LocIds.LABEL_CAPTIONS_OFF); + } + + + /** @private */ + onTracksChange_() { + // TS content might have captions embedded in video stream, we can't know + // until we start transmuxing. So, always show the caption button if we're + // playing TS content. + if (shaka.ui.Utils.isTsContent(this.player)) { + shaka.ui.Utils.setDisplay(this.captionButton_, true); + } else { + const hasText = this.player.getTextTracks().length; + shaka.ui.Utils.setDisplay(this.captionButton_, hasText > 0); + } + + this.updateTextLanguages_(); + } +}; + + +/** + * @implements {shaka.extern.IUIElement.Factory} + * @final + */ +shaka.ui.TextSelection.Factory = class { + /** @override */ + create(rootElement, controls) { + return new shaka.ui.TextSelection(rootElement, controls); + } +}; + +shaka.ui.OverflowMenu.registerElement( + 'captions', new shaka.ui.TextSelection.Factory()); diff --git a/ui/ui.js b/ui/ui.js index 4391bb36f..336f378e9 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -107,6 +107,7 @@ shaka.ui.Overlay.prototype.defaultConfig_ = function() { 'quality', 'language', 'picture_in_picture', + 'cast', ], addSeekBar: true, castReceiverAppId: '', diff --git a/ui/ui_utils.js b/ui/ui_utils.js index 345b028dc..07505e413 100644 --- a/ui/ui_utils.js +++ b/ui/ui_utils.js @@ -81,3 +81,57 @@ shaka.ui.Utils.createHTMLElement = function(tagName) { /** @type {!HTMLElement} */ (document.createElement(tagName)); return element; }; + + +/** + * Finds a descendant of |menu| that has a 'shaka-chosen-item' class + * and focuses on its' parent. + * + * @param {HTMLElement} menu + */ +shaka.ui.Utils.focusOnTheChosenItem = function(menu) { + if (!menu) return; + const chosenItem = shaka.ui.Utils.getDescendantIfExists( + menu, 'shaka-chosen-item'); + if (chosenItem) { + chosenItem.parentElement.focus(); + } +}; + + +/** + * @return {!Element} + */ +shaka.ui.Utils.checkmarkIcon = function() { + let icon = shaka.ui.Utils.createHTMLElement('i'); + icon.classList.add('material-icons'); + icon.classList.add('shaka-chosen-item'); + icon.textContent = shaka.ui.Enums.MaterialDesignIcons.CHECKMARK; + // Screen reader should ignore icon text. + icon.setAttribute('aria-hidden', 'true'); + return icon; +}; + + +/** + * Depending on the value of display, sets/removes the css class of element to + * either display it or hide it. + * + * @param {Element} element + * @param {boolean} display + * @export + */ +shaka.ui.Utils.setDisplay = function(element, display) { + if (!element) return; + if (display) { + element.classList.add('shaka-displayed'); + // Removing a non-existent class doesn't throw, so, even if + // the element is not hidden, this should be fine. Same for displayed + // below. + element.classList.remove('shaka-hidden'); + } else { + element.classList.add('shaka-hidden'); + element.classList.remove('shaka-displayed'); + } +}; +