diff --git a/build/types/ui b/build/types/ui index 3658312575..c8bca18a49 100644 --- a/build/types/ui +++ b/build/types/ui @@ -25,6 +25,7 @@ +../../ui/playback_rate_selection.js +../../ui/presentation_time.js +../../ui/range_element.js ++../../ui/remote_button.js +../../ui/resolution_selection.js +../../ui/rewind_button.js +../../ui/seek_bar.js diff --git a/docs/tutorials/ui-customization.md b/docs/tutorials/ui-customization.md index 9e9e1a6149..86447319f7 100644 --- a/docs/tutorials/ui-customization.md +++ b/docs/tutorials/ui-customization.md @@ -68,6 +68,8 @@ The following elements can be added to the UI bar using this configuration value supports AirPlay. * cast: adds a button that opens a Chromecast dialog. The button is visible only if there is at least one Chromecast device on the same network available for casting. +* remote: adds a button that opens a Remote Playback dialog. The button is visible only if the + browser supports Remote Playback API. * quality: adds a button that controls enabling/disabling of abr and video resolution selection. * language: adds a button that controls audio language selection. * playback_rate: adds a button that controls the playback rate selection. @@ -91,6 +93,8 @@ The following buttons can be added to the overflow menu: * playback_rate: adds a button that controls the playback rate selection. * airplay: adds a button that opens a AirPlay dialog. The button is visible only if the browser supports AirPlay. +* remote: adds a button that opens a Remote Playback dialog. The button is visible only if the + browser supports Remote Playback API. * Statistics: adds a button that displays statistics of the video. diff --git a/externs/media_remote.js b/externs/media_remote.js new file mode 100644 index 0000000000..d116a48641 --- /dev/null +++ b/externs/media_remote.js @@ -0,0 +1,68 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Externs for HTMLMediaElement which were missing in the + * Closure compiler. + * + * @externs + */ + + +/** + * @constructor + * @implements {EventTarget} + */ +function RemotePlayback() {} + + +/** + * Represents the RemotePlayback connection's state. + * @type {string} + */ +RemotePlayback.prototype.state; + + +/** + * The watchAvailability() method of the RemotePlayback interface watches + * the list of available remote playback devices and returns a Promise that + * resolves with the callbackId of a remote playback device. + * + * @param {!function(boolean)} callback + * @return {!Promise} + */ +RemotePlayback.prototype.watchAvailability = function(callback) {}; + + +/** + * The cancelWatchAvailability() method of the RemotePlayback interface + * cancels the request to watch for one or all available devices. + * + * @param {number} id + * @return {!Promise} + */ +RemotePlayback.prototype.cancelWatchAvailability = function(id) {}; + + +/** + * The prompt() method of the RemotePlayback interface prompts the user + * to select an available remote playback device and give permission + * for the current media to be played using that device. + * + * If the user gives permission, the state will be set to connecting and + * the user agent will connect to the device to initiate playback. + * + * If the user chooses to instead disconnect from the device, the state will + * be set to disconnected and user agent will disconnect from this device. + * + * @return {!Promise} + */ +RemotePlayback.prototype.prompt = function() {}; + + +/** @type {RemotePlayback} */ +HTMLMediaElement.prototype.remote; + diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index 2695460b6c..656ddcfd63 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -99,6 +99,7 @@ goog.require('shaka.ui.Overlay'); goog.require('shaka.ui.PipButton'); goog.require('shaka.ui.PlaybackRateSelection'); goog.require('shaka.ui.PresentationTimeTracker'); +goog.require('shaka.ui.RemoteButton'); goog.require('shaka.ui.ResolutionSelection'); goog.require('shaka.ui.RewindButton'); goog.require('shaka.ui.SkipAdButton'); diff --git a/ui/remote_button.js b/ui/remote_button.js new file mode 100644 index 0000000000..b1a4f05bfd --- /dev/null +++ b/ui/remote_button.js @@ -0,0 +1,201 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + + +goog.provide('shaka.ui.RemoteButton'); + +goog.require('shaka.Player'); +goog.require('shaka.ui.Controls'); +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'); +goog.require('shaka.ui.Utils'); +goog.require('shaka.util.Dom'); +goog.require('shaka.util.Platform'); +goog.requireType('shaka.ui.Controls'); + + +/** + * @extends {shaka.ui.Element} + * @final + * @export + */ +shaka.ui.RemoteButton = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls); + + /** @private {!HTMLButtonElement} */ + this.remoteButton_ = shaka.util.Dom.createButton(); + this.remoteButton_.classList.add('shaka-remote-button'); + this.remoteButton_.classList.add('shaka-tooltip'); + this.remoteButton_.ariaPressed = 'false'; + + /** @private {!HTMLElement} */ + this.remoteIcon_ = shaka.util.Dom.createHTMLElement('i'); + this.remoteIcon_.classList.add('material-icons-round'); + let icon = shaka.ui.Enums.MaterialDesignIcons.CAST; + const safariVersion = shaka.util.Platform.safariVersion(); + if (safariVersion && safariVersion >= 13) { + icon = shaka.ui.Enums.MaterialDesignIcons.AIRPLAY; + } + this.remoteIcon_.textContent = icon; + this.remoteButton_.appendChild(this.remoteIcon_); + + const label = shaka.util.Dom.createHTMLElement('label'); + label.classList.add('shaka-overflow-button-label'); + label.classList.add('shaka-overflow-menu-only'); + this.remoteNameSpan_ = shaka.util.Dom.createHTMLElement('span'); + label.appendChild(this.remoteNameSpan_); + + this.remoteCurrentSelectionSpan_ = + shaka.util.Dom.createHTMLElement('span'); + this.remoteCurrentSelectionSpan_.classList.add( + 'shaka-current-selection-span'); + label.appendChild(this.remoteCurrentSelectionSpan_); + this.remoteButton_.appendChild(label); + this.parent.appendChild(this.remoteButton_); + + /** @private {number} */ + this.callbackId_ = -1; + + // Setup strings in the correct language + this.updateLocalizedStrings_(); + + shaka.ui.Utils.setDisplay(this.remoteButton_, false); + + if (!this.video.remote || this.video.disableRemotePlayback) { + this.remoteButton_.classList.add('shaka-hidden'); + } else { + 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.controls, 'caststatuschanged', () => { + this.updateRemoteState_(); + }); + + this.eventManager.listen(this.remoteButton_, 'click', () => { + this.video.remote.prompt(); + }); + + this.eventManager.listen(this.video.remote, 'connect', () => { + this.updateRemoteState_(); + }); + + this.eventManager.listen(this.video.remote, 'connecting', () => { + this.updateRemoteState_(); + }); + + this.eventManager.listen(this.video.remote, 'disconnect', () => { + this.updateRemoteState_(); + }); + + this.eventManager.listen(this.player, 'loaded', () => { + this.updateRemoteState_(); + }); + + this.updateRemoteState_(); + } + } + + /** @override */ + release() { + if (this.video.remote && this.callbackId_ != -1) { + this.video.remote.cancelWatchAvailability(this.callbackId_); + } + + super.release(); + } + + /** + * @private + */ + async updateRemoteState_() { + if (this.controls.getCastProxy().canCast() && + this.controls.isCastAllowed()) { + shaka.ui.Utils.setDisplay(this.remoteButton_, false); + if (this.callbackId_ != -1) { + this.video.remote.cancelWatchAvailability(this.callbackId_); + this.callbackId_ = -1; + } + } else if (this.video.remote.state == 'disconnected') { + const handleAvailabilityChange = (availability) => { + if (this.player) { + const loadMode = this.player.getLoadMode(); + const srcMode = loadMode == shaka.Player.LoadMode.SRC_EQUALS; + shaka.ui.Utils.setDisplay( + this.remoteButton_, srcMode && availability); + } else { + shaka.ui.Utils.setDisplay(this.remoteButton_, false); + } + }; + try { + if (this.callbackId_ != -1) { + await this.video.remote.cancelWatchAvailability(this.callbackId_); + this.callbackId_ = -1; + } + } catch (e) { + // Ignore this error. + } + try { + const id = await this.video.remote.watchAvailability( + handleAvailabilityChange); + this.callbackId_ = id; + } catch (e) { + handleAvailabilityChange(/* availability= */ true); + } + } else if (this.callbackId_ != -1) { + // If remote device is connecting or connected, we should stop + // watching remote device availability to save power. + await this.video.remote.cancelWatchAvailability(this.callbackId_); + this.callbackId_ = -1; + } + } + + /** + * @private + */ + updateLocalizedStrings_() { + const LocIds = shaka.ui.Locales.Ids; + let text = this.localization.resolve(LocIds.CAST); + const safariVersion = shaka.util.Platform.safariVersion(); + if (safariVersion && safariVersion >= 13) { + text = this.localization.resolve(LocIds.AIRPLAY); + } + this.remoteButton_.ariaLabel = text; + this.remoteNameSpan_.textContent = text; + } +}; + + +/** + * @implements {shaka.extern.IUIElement.Factory} + * @final + */ +shaka.ui.RemoteButton.Factory = class { + /** @override */ + create(rootElement, controls) { + return new shaka.ui.RemoteButton(rootElement, controls); + } +}; + +shaka.ui.OverflowMenu.registerElement( + 'remote', new shaka.ui.RemoteButton.Factory()); + +shaka.ui.Controls.registerElement( + 'remote', new shaka.ui.RemoteButton.Factory()); diff --git a/ui/ui.js b/ui/ui.js index a8c9821c35..f6599e3e2b 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -247,8 +247,10 @@ shaka.ui.Overlay = class { fullScreenElement: this.videoContainer_, }; - // Check AirPlay support - if (window.WebKitPlaybackTargetAvailabilityEvent) { + // eslint-disable-next-line no-restricted-syntax + if ('remote' in HTMLMediaElement.prototype) { + config.overflowMenuButtons.push('remote'); + } else if (window.WebKitPlaybackTargetAvailabilityEvent) { config.overflowMenuButtons.push('airplay'); }