diff --git a/build/types/cast b/build/types/cast new file mode 100644 index 0000000000..f7bf342399 --- /dev/null +++ b/build/types/cast @@ -0,0 +1,6 @@ +# Cast support. + ++../../lib/cast/cast_proxy.js ++../../lib/cast/cast_receiver.js ++../../lib/cast/cast_sender.js ++../../lib/cast/cast_utils.js diff --git a/build/types/complete b/build/types/complete index c73efa4541..9e3ac577b9 100644 --- a/build/types/complete +++ b/build/types/complete @@ -1,5 +1,6 @@ # The complete library, containing every available plugin. ++@cast +@networking +@manifests +@polyfill diff --git a/demo/asset_section.js b/demo/asset_section.js index fc161cbee9..b1a6f5b8cd 100644 --- a/demo/asset_section.js +++ b/demo/asset_section.js @@ -117,7 +117,7 @@ shakaDemo.preparePlayer_ = function(asset) { var player = shakaDemo.player_; var config = /** @type {shakaExtern.PlayerConfiguration} */( - { abr: {}, drm: {}, manifest: { dash: {} } }); + { abr: {}, manifest: { dash: {} } }); config.manifest.dash.clockSyncUri = '//shaka-player-demo.appspot.com/time.txt'; @@ -137,13 +137,14 @@ shakaDemo.preparePlayer_ = function(asset) { }); } + player.resetConfiguration(); + // Add config from this asset. - if (asset.licenseServers) - config.drm.servers = asset.licenseServers; - if (asset.drmCallback) - config.manifest.dash.customScheme = asset.drmCallback; - if (asset.clearKeys) - config.drm.clearKeys = asset.clearKeys; + ShakaDemoUtils.setupAssetMetadata(asset, player); + shakaDemo.castProxy_.setAppData({ + 'asset': asset, + 'isYtDrm': asset.drmCallback == shakaAssets.YouTubeCallback + }); // Add configuration from the UI. config.preferredAudioLanguage = @@ -153,23 +154,8 @@ shakaDemo.preparePlayer_ = function(asset) { config.abr.enabled = document.getElementById('enableAdaptation').checked; - player.resetConfiguration(); player.configure(config); - // Configure network filters. - var networkingEngine = player.getNetworkingEngine(); - networkingEngine.clearAllRequestFilters(); - networkingEngine.clearAllResponseFilters(); - - if (asset.licenseRequestHeaders) { - var filter = shakaDemo.addLicenseRequestHeaders_.bind( - null, asset.licenseRequestHeaders); - networkingEngine.registerRequestFilter(filter); - } - - if (asset.licenseProcessor) { - networkingEngine.registerResponseFilter(asset.licenseProcessor); - } return asset; }; @@ -184,6 +170,10 @@ shakaDemo.load = function() { // Load the manifest. player.load(asset.manifestUri).then(function() { + // Disallow casting of offline content. + var isOffline = asset.manifestUri.indexOf('offline:') == 0; + shakaDemo.controls_.allowCast(!isOffline); + (asset.extraText || []).forEach(function(extraText) { player.addTextTrack(extraText.uri, extraText.language, extraText.kind, extraText.mime, extraText.codecs); @@ -197,20 +187,3 @@ shakaDemo.load = function() { } }); }; - - -/** - * @param {!Object.} headers - * @param {shaka.net.NetworkingEngine.RequestType} requestType - * @param {shakaExtern.Request} request - * @private - */ -shakaDemo.addLicenseRequestHeaders_ = function(headers, requestType, request) { - if (requestType != shaka.net.NetworkingEngine.RequestType.LICENSE) return; - - // Add these to the existing headers. Do not clobber them! - // For PlayReady, there will already be headers in the request. - for (var k in headers) { - request.headers[k] = headers[k]; - } -}; diff --git a/demo/controls.css b/demo/controls.css index f21d335258..2bc27462fa 100644 --- a/demo/controls.css +++ b/demo/controls.css @@ -76,6 +76,7 @@ #videoContainer:-ms-fullscreen { width: 100%; height: 100%; } #controls button { + color: white; height: 32px; width: 32px; padding: 0; @@ -142,6 +143,17 @@ } +/* Always show controls while casting */ +#controls.casting { + opacity: 1; +} + +/* Hide fullscreen button while casting */ +#controls.casting #fullscreenButton { + display: none; +} + + /* NOTE: pseudo-elements for different browsers can't be combined with commas. * Browsers will ignore styles if any pseudo-element in the list is unknown. */ diff --git a/demo/controls.js b/demo/controls.js index 60fce94070..edb3affaef 100644 --- a/demo/controls.js +++ b/demo/controls.js @@ -23,7 +23,16 @@ * @suppress {missingProvide} */ function ShakaControls() { - /** @private {HTMLVideoElement} */ + /** @private {shaka.cast.CastProxy} */ + this.castProxy_ = null; + + /** @private {boolean} */ + this.castAllowed_ = true; + + /** @private {?function(!shaka.util.Error)} */ + this.onError_ = null; + + /** @private {HTMLMediaElement} */ this.video_ = null; /** @private {shaka.Player} */ @@ -88,15 +97,15 @@ function ShakaControls() { /** * Initializes the player controls. - * @param {HTMLVideoElement} video - * @param {shaka.Player} player + * @param {shaka.cast.CastProxy} castProxy + * @param {function(!shaka.util.Error)} onError + * @param {function(boolean)} notifyCastStatus */ -ShakaControls.prototype.init = function(video, player) { - // TODO: Make these resettable for switching to/from Chromecast - this.video_ = video; - this.player_ = player; - // TODO: Method to tear these down - // TODO: Event manager? +ShakaControls.prototype.init = function(castProxy, onError, notifyCastStatus) { + this.castProxy_ = castProxy; + this.onError_ = onError; + this.notifyCastStatus_ = notifyCastStatus; + this.initMinimal(castProxy.getVideo(), castProxy.getPlayer()); // IE11 doesn't treat the 'input' event correctly. // https://connect.microsoft.com/IE/Feedback/Details/856998 @@ -146,8 +155,6 @@ ShakaControls.prototype.init = function(video, player) { this.fullscreenButton_.addEventListener( 'click', this.onFullscreenClick_.bind(this)); - window.setInterval(this.updateTimeAndSeekRange_.bind(this), 125); - this.currentTime_.addEventListener( 'click', this.onCurrentTimeClick_.bind(this)); @@ -159,9 +166,6 @@ ShakaControls.prototype.init = function(video, player) { this.castButton_.addEventListener( 'click', this.onCastClick_.bind(this)); - this.player_.addEventListener( - 'buffering', this.onBufferingStateChange_.bind(this)); - this.videoContainer_.addEventListener( 'click', this.onPlayPauseClick_.bind(this)); @@ -180,6 +184,35 @@ ShakaControls.prototype.init = function(video, player) { 'mousemove', this.onMouseMove_.bind(this)); this.videoContainer_.addEventListener( 'mouseout', this.onMouseOut_.bind(this)); + + this.castProxy_.addEventListener( + 'caststatuschanged', this.onCastStatusChange_.bind(this)); +}; + + +/** + * Initializes minimal player controls. Used on both sender (indirectly) and + * receiver (directly). + * @param {HTMLMediaElement} video + * @param {shaka.Player} player + */ +ShakaControls.prototype.initMinimal = function(video, player) { + this.video_ = video; + this.player_ = player; + this.player_.addEventListener( + 'buffering', this.onBufferingStateChange_.bind(this)); + window.setInterval(this.updateTimeAndSeekRange_.bind(this), 125); +}; + + +/** + * This allows the application to inhibit casting. + * + * @param {boolean} allow + */ +ShakaControls.prototype.allowCast = function(allow) { + this.castAllowed_ = allow; + this.onCastStatusChange_(null); }; @@ -291,7 +324,7 @@ ShakaControls.prototype.onSeekInput_ = function() { /** @private */ ShakaControls.prototype.onSeekInputTimeout_ = function() { this.seekTimeoutId_ = null; - this.video_.currentTime = this.seekBar_.value; + this.video_.currentTime = parseFloat(this.seekBar_.value); }; @@ -338,7 +371,7 @@ ShakaControls.prototype.onVolumeStateChange_ = function() { /** @private */ ShakaControls.prototype.onVolumeInput_ = function() { - this.video_.volume = this.volumeBar_.value; + this.video_.volume = parseFloat(this.volumeBar_.value); this.video_.muted = false; }; @@ -411,7 +444,33 @@ ShakaControls.prototype.onFastForwardClick_ = function() { /** @private */ ShakaControls.prototype.onCastClick_ = function() { - // TODO: cast, cast_connected + if (this.castProxy_.isCasting()) { + this.castProxy_.disconnect(); + } else { + // TODO: disable other controls while connecting? + this.castProxy_.cast().then(function() { + // Success! + }, function(error) { + if (error.code != shaka.util.Error.Code.CAST_CANCELED_BY_USER) { + this.onError_(error); + } + }.bind(this)); + } +}; + + +/** + * @param {Event} event + * @private + */ +ShakaControls.prototype.onCastStatusChange_ = function(event) { + var canCast = this.castProxy_.canCast() && this.castAllowed_; + var isCasting = this.castProxy_.isCasting(); + + this.notifyCastStatus_(isCasting); + this.castButton_.style.display = canCast ? 'inherit' : 'none'; + this.castButton_.textContent = isCasting ? 'cast_connected' : 'cast'; + this.controls_.classList.toggle('casting', this.castProxy_.isCasting()); }; diff --git a/demo/demo_utils.js b/demo/demo_utils.js new file mode 100644 index 0000000000..08d94612fc --- /dev/null +++ b/demo/demo_utils.js @@ -0,0 +1,72 @@ +/** + * @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. + */ + + +/** @namespace */ +var ShakaDemoUtils = {}; + + +/** + * @param {shakaAssets.AssetInfo} asset + * @param {shaka.Player} player + */ +ShakaDemoUtils.setupAssetMetadata = function(asset, player) { + var config = /** @type {shakaExtern.PlayerConfiguration} */( + { drm: {}, manifest: { dash: {} } }); + + // Add config from this asset. + if (asset.licenseServers) + config.drm.servers = asset.licenseServers; + if (asset.drmCallback) + config.manifest.dash.customScheme = asset.drmCallback; + if (asset.clearKeys) + config.drm.clearKeys = asset.clearKeys; + player.configure(config); + + // Configure network filters. + var networkingEngine = player.getNetworkingEngine(); + networkingEngine.clearAllRequestFilters(); + networkingEngine.clearAllResponseFilters(); + + if (asset.licenseRequestHeaders) { + var filter = ShakaDemoUtils.addLicenseRequestHeaders_.bind( + null, asset.licenseRequestHeaders); + networkingEngine.registerRequestFilter(filter); + } + + if (asset.licenseProcessor) { + networkingEngine.registerResponseFilter(asset.licenseProcessor); + } +}; + + +/** + * @param {!Object.} headers + * @param {shaka.net.NetworkingEngine.RequestType} requestType + * @param {shakaExtern.Request} request + * @private + */ +ShakaDemoUtils.addLicenseRequestHeaders_ = + function(headers, requestType, request) { + if (requestType != shaka.net.NetworkingEngine.RequestType.LICENSE) return; + + // Add these to the existing headers. Do not clobber them! + // For PlayReady, there will already be headers in the request. + for (var k in headers) { + request.headers[k] = headers[k]; + } +}; diff --git a/demo/idle1.jpg b/demo/idle1.jpg new file mode 100644 index 0000000000..c80a4e5683 Binary files /dev/null and b/demo/idle1.jpg differ diff --git a/demo/idle2.jpg b/demo/idle2.jpg new file mode 100644 index 0000000000..eaedd42bd1 Binary files /dev/null and b/demo/idle2.jpg differ diff --git a/demo/idle3.jpg b/demo/idle3.jpg new file mode 100644 index 0000000000..a232f4c7d8 Binary files /dev/null and b/demo/idle3.jpg differ diff --git a/demo/index.html b/demo/index.html index 68a5f03060..d238750425 100644 --- a/demo/index.html +++ b/demo/index.html @@ -37,6 +37,7 @@ + diff --git a/demo/main.js b/demo/main.js index 90fbb30caf..f1c4e6e72d 100644 --- a/demo/main.js +++ b/demo/main.js @@ -20,7 +20,11 @@ var shakaDemo = shakaDemo || {}; -/** @private {HTMLVideoElement} */ +/** @private {shaka.cast.CastProxy} */ +shakaDemo.castProxy_ = null; + + +/** @private {HTMLMediaElement} */ shakaDemo.video_ = null; @@ -36,6 +40,14 @@ shakaDemo.support_; shakaDemo.controls_ = null; +/** + * The registered ID of the v2 Chromecast receiver demo. + * @const {string} + * @private + */ +shakaDemo.CC_APP_ID_ = '4E839F3A'; + + /** * Initialize the application. */ @@ -112,9 +124,14 @@ shakaDemo.init = function() { shaka.Player.probeSupport().then(function(support) { shakaDemo.support_ = support; - shakaDemo.video_ = + var localVideo = /** @type {!HTMLVideoElement} */(document.getElementById('video')); - shakaDemo.player_ = new shaka.Player(shakaDemo.video_); + var localPlayer = new shaka.Player(localVideo); + shakaDemo.castProxy_ = new shaka.cast.CastProxy( + localVideo, localPlayer, shakaDemo.CC_APP_ID_); + + shakaDemo.video_ = shakaDemo.castProxy_.getVideo(); + shakaDemo.player_ = shakaDemo.castProxy_.getPlayer(); shakaDemo.player_.addEventListener('error', shakaDemo.onErrorEvent_); shakaDemo.setupAssets_(); @@ -123,7 +140,8 @@ shakaDemo.init = function() { shakaDemo.setupInfo_(); shakaDemo.controls_ = new ShakaControls(); - shakaDemo.controls_.init(shakaDemo.video_, shakaDemo.player_); + shakaDemo.controls_.init(shakaDemo.castProxy_, shakaDemo.onError_, + shakaDemo.onCastStatusChange_); // If a custom asset was given in the URL, select it now. if ('asset' in params) { diff --git a/demo/offline_section.js b/demo/offline_section.js index b43a10a0b8..8835e8b9f0 100644 --- a/demo/offline_section.js +++ b/demo/offline_section.js @@ -194,3 +194,22 @@ shakaDemo.refreshAssetList_ = function() { shakaDemo.setupOfflineAssets_(); }; + + +/** + * @param {boolean} connected + * @private + */ +shakaDemo.onCastStatusChange_ = function(connected) { + // When we are casting, offline assets become unavailable. + shakaDemo.offlineOptGroup_.disabled = connected; + + if (connected) { + var assetList = document.getElementById('assetList'); + var option = assetList.options[assetList.selectedIndex]; + if (option.storedContent) { + // This is an offline asset. Select something else. + assetList.selectedIndex = 0; + } + } +}; diff --git a/demo/receiver_app.css b/demo/receiver_app.css new file mode 100644 index 0000000000..30243873be --- /dev/null +++ b/demo/receiver_app.css @@ -0,0 +1,146 @@ +/** + * 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. + */ + +/* Experimentation has revealed that unless html, body, and videoContainer are + * width and height 100%, video can force all its parents to grow larger than + * window.innerHeight, causing things to be cut off for some content. + */ +html { + width: 100%; + height: 100%; +} + +body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + font-family: Roboto, sans-serif; + font-weight: 300; + background-color: black; + color: white; +} + +#videoContainer { + width: 100%; + height: 100%; +} + +#video { + width: 100%; + height: 100%; + margin: auto; +} + +#controlsContainer { + padding: 0 3%; + height: auto; +} + +#controls { + height: 100%; + max-width: initial; +} + +#controls * { + font-size: 35px; +} + +#controls button { + width: 50px; + height: 50px; +} + +#seekBar { + height: 12px; + border-radius: 12px; +} + +#seekBar::-webkit-slider-thumb { + width: 30px; + height: 30px; + border-radius: 30px; + opacity: 0.5; +} + +#bufferingSpinner { + transform: scale(2.0); +} + +.overlay-parent { + /* Makes this a positioned ancestor of .overlay */ + position: relative; +} + +.overlay { + /* Allows this to be positioned relative to a containing .overlay-parent */ + position: absolute; +} + +#idle { + top: 0; + left: 0; + bottom: 0; + right: 0; + + padding-top: 60px; + padding-left: 0; + background-color: black; + + /* To make it easier to view in a browser in some non-Chromecast size: */ + background-repeat: no-repeat; + + /* Chromecast receiver guidelines say to change the screen every 30-60s */ + animation: bg-change 90s linear infinite; +} + +#idle h1 { + margin-left: 100px; +} + +#idle h2 { + font-weight: normal; + margin-left: 100px; + width: 550px; +} + +/* Preload the background images for idle mode */ +body:after { + position: absolute; + width: 0; + height: 0; + overflow: hidden; + z-index: -1; + content: url(idle1.jpg) url(idle2.jpg) url(idle3.jpg); +} + +@keyframes bg-change { + 0% { background-image: url('idle1.jpg'); padding-left: 0; } + 32% { background-image: url('idle1.jpg'); padding-left: 0; } + + 34% { background-image: url('idle2.jpg'); padding-left: 0; } + 49% { background-image: url('idle2.jpg'); padding-left: 0; } + 50% { background-image: url('idle2.jpg'); padding-left: 400px; } + 65% { background-image: url('idle2.jpg'); padding-left: 400px; } + + 67% { background-image: url('idle3.jpg'); padding-left: 400px; } + 87% { background-image: url('idle3.jpg'); padding-left: 400px; } + 88% { background-image: url('idle3.jpg'); padding-left: 0; } + 98% { background-image: url('idle3.jpg'); padding-left: 0; } + + 100% { background-image: url('idle1.jpg'); padding-left: 0; } +} diff --git a/demo/receiver_app.html b/demo/receiver_app.html new file mode 100644 index 0000000000..db68ca95a1 --- /dev/null +++ b/demo/receiver_app.html @@ -0,0 +1,57 @@ + + + + + + + Shaka Player Cast Demo + + + + + + + + + + + + + +
+ +
+ + + +
+
+ +
0:00
+ +
+ + +
+

Ready to try Shaka's Chromecast support?

+

Select an asset in your browser and click "Load". + Control the video using the controls in your browser.

+
+
+ + diff --git a/demo/receiver_app.js b/demo/receiver_app.js new file mode 100644 index 0000000000..17e5ae3015 --- /dev/null +++ b/demo/receiver_app.js @@ -0,0 +1,208 @@ +/** + * @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. + */ + + + +/** + * A Chromecast receiver demo app. + * @constructor + * @suppress {missingProvide} + */ +function ShakaReceiver() { + /** @private {HTMLMediaElement} */ + this.video_ = null; + + /** @private {shaka.Player} */ + this.player_ = null; + + /** @private {shaka.cast.CastReceiver} */ + this.receiver_ = null; + + /** @private {Element} */ + this.pauseIcon_ = null; + + /** @private {Element} */ + this.controlsElement_ = null; + + /** @private {ShakaControls} */ + this.controlsUi_ = null; + + /** @private {?number} */ + this.controlsTimerId_ = null; + + /** @private {Element} */ + this.idle_ = null; + + /** @private {?number} */ + this.idleTimerId_ = null; + + /** + * In seconds. + * @const + * @private {number} + */ + this.idleTimeout_ = 300; +} + + +/** + * Initialize the application. + */ +ShakaReceiver.prototype.init = function() { + shaka.polyfill.installAll(); + + this.video_ = + /** @type {!HTMLMediaElement} */(document.getElementById('video')); + this.player_ = new shaka.Player(this.video_); + + this.controlsUi_ = new ShakaControls(); + this.controlsUi_.initMinimal(this.video_, this.player_); + + this.controlsElement_ = document.getElementById('controls'); + this.pauseIcon_ = document.getElementById('pauseIcon'); + this.idle_ = document.getElementById('idle'); + + this.video_.addEventListener( + 'play', this.onPlayStateChange_.bind(this)); + this.video_.addEventListener( + 'pause', this.onPlayStateChange_.bind(this)); + this.video_.addEventListener( + 'seeking', this.onPlayStateChange_.bind(this)); + this.video_.addEventListener( + 'emptied', this.onPlayStateChange_.bind(this)); + + this.video_.addEventListener( + 'canplay', this.checkIdle_.bind(this)); + this.video_.addEventListener( + 'emptied', this.checkIdle_.bind(this)); + this.video_.addEventListener( + 'ended', this.checkIdle_.bind(this)); + + this.receiver_ = new shaka.cast.CastReceiver( + this.video_, this.player_, this.appDataCallback_.bind(this)); + this.receiver_.addEventListener( + 'caststatuschanged', this.checkIdle_.bind(this)); + + this.startIdleTimer_(); +}; + + +/** + * @param {Object} appData + * @private + */ +ShakaReceiver.prototype.appDataCallback_ = function(appData) { + // appData is null if we start the app without any media loaded. + if (!appData) return; + + var asset = /** @type {shakaAssets.AssetInfo} */(appData['asset']); + // Patch in non-transferable callbacks for YT DRM: + if (appData['isYtDrm']) { + asset.drmCallback = shakaAssets.YouTubeCallback; + asset.licenseProcessor = shakaAssets.YouTubePostProcessor; + } + ShakaDemoUtils.setupAssetMetadata(asset, this.player_); +}; + + +/** @private */ +ShakaReceiver.prototype.checkIdle_ = function() { + var connected = this.receiver_.isConnected(); + var loaded = this.video_.readyState > 0; + var ended = this.video_.ended; + + var idle = !loaded || (!connected && ended); + + console.debug('status changed', + 'connected=', connected, + 'loaded=', loaded, + 'ended=', ended, + 'idle=', idle); + + // If something is loaded, but we've just gone idle, unload the content, show + // the idle card, and set a timer to close the app. + if (idle && loaded) { + this.player_.unload(); + this.idle_.style.display = 'block'; + this.startIdleTimer_(); + } + + // If we are no longer idle, hide the idle card, and make sure we cancel any + // timers that would close the app. + if (!idle) { + this.idle_.style.display = 'none'; + this.cancelIdleTimer_(); + } +}; + + +/** @private */ +ShakaReceiver.prototype.startIdleTimer_ = function() { + this.idleTimerId_ = window.setTimeout( + window.close.bind(window), this.idleTimeout_ * 1000.0); +}; + + +/** @private */ +ShakaReceiver.prototype.cancelIdleTimer_ = function() { + if (this.idleTimerId_ != null) { + window.clearTimeout(this.idleTimerId_); + this.idleTimerId_ = null; + } +}; + + +/** @private */ +ShakaReceiver.prototype.onPlayStateChange_ = function() { + if (this.controlsTimerId_ != null) { + window.clearTimeout(this.controlsTimerId_); + } + + if (this.video_.paused) { + this.pauseIcon_.textContent = 'pause'; + } else { + this.pauseIcon_.textContent = 'play_arrow'; + } + + if (this.video_.paused && this.video_.readyState > 0) { + // Show controls. + this.controlsElement_.style.opacity = 1; + } else { + // Show controls for 3 seconds. + this.controlsElement_.style.opacity = 1; + this.controlsTimerId_ = window.setTimeout(function() { + this.controlsElement_.style.opacity = 0; + }.bind(this), 3000); + } +}; + + +/** + * Initialize the receiver app by instantiating ShakaReceiver. + */ +function receiverAppInit() { + window.receiver = new ShakaReceiver(); + window.receiver.init(); +} + + +if (document.readyState == 'loading' || + document.readyState == 'interactive') { + window.addEventListener('load', receiverAppInit); +} else { + receiverAppInit(); +} diff --git a/docs/design/chromecast.md b/docs/design/chromecast.md new file mode 100644 index 0000000000..10a21170ec --- /dev/null +++ b/docs/design/chromecast.md @@ -0,0 +1,149 @@ +# Shaka Player v2 Chromecast Design + +last update: 2016-05-07 + +by: [joeyparrish@google.com](mailto:joeyparrish@google.com) + + +## Overview + +Shaka Player v2 will support Chromecast in the library for both sender and +receiver. This is in contrast to v1, where we demonstrated cast sending and +receiving in the demo app, but provided no direct Chromecast support in the +library. + +Senders will use a `shaka.cast.CastProxy` object which wraps both `shaka.Player` +and `HTMLMediaElement`. The proxy will delegate to either local or remote +objects based on the current cast state. Senders must also load the Cast Sender +API JavaScript library in addition to Shaka. Applications can be quickly +modified to use a `CastProxy` without changing their use of `Player` and +`HTMLMediaElement` APIs. + +Receivers will use a `shaka.cast.CastReceiver` object which wraps both +`shaka.Player` and `HTMLMediaElement` on the Chromecast. The `CastReceiver` +will receive commands from the sender and update the sender with the status of +the `Player` and `HTMLMediaElement` objects. Receiver apps only have to worry +about their UI, while the `CastReceiver` takes care of playback and +communication. + + +#### `CastProxy` API sketch + +```js +new shaka.cast.CastProxy(video, player, receiverAppId) + +// Also destroys the underlying local Player object +shaka.cast.CastProxy.prototype.destroy() => Promise + +// Looks like shaka.Player, proxies to local or remote player based on cast +// state +shaka.cast.CastProxy.prototype.getPlayer() => shaka.Player + +// Looks like HTMLMediaElement, proxies to local or remote video based on cast +// state +shaka.cast.CastProxy.prototype.getVideo() => HTMLMediaElement + +// True if there are cast receivers available +shaka.cast.CastProxy.prototype.canCast() => boolean + +// True if we are currently casting +shaka.cast.CastProxy.prototype.isCasting() => boolean + +// Fired when either canCast or isCasting changes +shaka.cast.CastProxy.CastStatusChangedEvent + +// Resolved when connected to a receiver, rejected if the connection fails +shaka.cast.CastProxy.prototype.cast() => Promise + +// Transmits application-specific data to the receiver (now or on later connect) +shaka.cast.CastProxy.prototype.setAppData(appData) + +// Disconnect from the receiver +shaka.cast.CastProxy.prototype.disconnect() +``` + + +#### `CastReceiver` API sketch + +```js +new shaka.cast.CastReceiver(video, player, opt_appDataCallback) + +// True if there are cast senders connected +shaka.cast.CastReceiver.prototype.isConnected() => boolean + +// Fired when isConnected changes +shaka.cast.CastReceiver.CastStatusChangedEvent +``` + + +#### Implementation + +`CastProxy` in local mode will pass all attribute and method accesses directly +to the local objects. In cast mode, it will have to implement certain +attributes and methods differently from others. + +In cast mode, reads of attributes and calls to synchronous getter methods will +be backed by a cache of attribute values pushed by the `CastReceiver`. They +will synchronously return the most recent value sent by the receiver. Writes to +attributes and calls to methods without a return value will be proxied to the +remote objects and treated as synchronous. Attribute writes will replace the +most recent values pushed by the receiver. Methods which return a `Promise` +will return a Promise which is resolved when the `CastReceiver` pushes a return +value back. + +`CastProxy` and `CastReceiver` must share a list of events, attributes, getter +methods, void methods, and methods which return a `Promise`. If a key is not +explicitly listed, its type is not known and it is not supported by the proxy. + +`CastReceiver` will periodically push values of attributes and getters to the +`CastProxy` on the sender. It will also push events to the `CastProxy`. As of +our first draft, `CastReceiver` pushes attribute updates every 500ms. Based on +experiments, this seems to be a good balance between performance and UI +responsiveness. + +`CastProxy` will have to have a special handler to simulate `TimeRanges`, since +the object cannot be pushed directly by `CastReceiver`. `CastReceiver` will +read out the values of each range and push an anonymous object containing the +information. `CastProxy` will provide an object that looks and acts like +`TimeRanges`, but which is backed by the anonymous object pushed by +`CastReceiver`. + +`CastReceiver` will have to connect `volume` and `muted` attributes to the +system volume, not the video volume. The ChromeCast documentation states: + +> All user-initiated actions on volume should impact the system volume, not the +> stream volume. + +When we first cast, `CastProxy` will send its initial state to `CastReceiver`, +including its configuration, manifest URI, selected tracks, text visibility, +side-loaded text tracks, current timestamp, and any custom app data from the +sender application. `CastReceiver` will invoke a receiver app callback to +process any custom app data before initiating playback. + +Custom app data can be used to pass information that cannot be transferred from +the sender app's config to the receiver app's config. For example, network +filters, custom dash scheme callbacks, and custom ABR callbacks are all +functions defined in the sender app. These cannot be serialized and transferred +to the receiver without security risks, because the receiver would have to use +`eval()` to deserialize them. Instead, the sender app must send whatever data +is necessary for the receiver to construct equivalent callbacks if needed. For +example, if network filters are used to attach auth tokens to license requests, +the app data would include the user's auth token. + +Sender apps must listen for `CastStatusChangedEvent`s because a receiver can be +disconnected without going through the app's UI. + +Receiver apps must listen for `CastStatusChangedEvent`s because senders can +disconnect at any time, and because Chromecast UI guidelines say that receiver +apps should show a different UI while idle. + + +#### Error conditions + +Errors during `cast()`: + - Cast API unavailable + - No cast receivers available + - Already casting + - Cast canceled by user + - Cast connection timed out + - Requested cast receiver app ID is unavailable or does not exist diff --git a/externs/chromecast.js b/externs/chromecast.js index 10f8874b62..dcb8901b3f 100644 --- a/externs/chromecast.js +++ b/externs/chromecast.js @@ -22,7 +22,7 @@ */ -/** @const */ +/** @type {function(boolean)} */ var __onGCastApiAvailable; @@ -38,39 +38,79 @@ cast.receiver = {}; cast.receiver.system = {}; -cast.receiver.system.DisconnectReason = { - REQUESTED_BY_SENDER: 'requested by sender', - ERROR: 'error', - UNKNOWN: 'unknown' -}; + +/** + * @constructor + * @struct + */ +cast.receiver.system.SystemVolumeData = function() {}; + + +/** @type {number} */ +cast.receiver.system.SystemVolumeData.prototype.level; + + +/** @type {boolean} */ +cast.receiver.system.SystemVolumeData.prototype.muted; /** - * @param {string} namespace - * @param {string} ipcChannel - * @param {Array.} senders - * @param {string=} opt_messageType * @constructor + * @struct */ -cast.receiver.CastMessageBus = function( - namespace, ipcChannel, senders, opt_messageType) {}; +cast.receiver.CastMessageBus = function() {}; + + +/** @param {*} message */ +cast.receiver.CastMessageBus.prototype.broadcast = function(message) {}; /** - * @param {*} message + * @param {string} senderId + * @return {!cast.receiver.CastChannel} */ -cast.receiver.CastMessageBus.prototype.broadcast = function(message) {}; +cast.receiver.CastMessageBus.prototype.getCastChannel = function(senderId) {}; +/** @type {Function} */ +cast.receiver.CastMessageBus.prototype.onMessage; + -/** @constructor */ -cast.receiver.CastReceiverManager = function() {}; +/** + * @constructor + * @struct + */ +cast.receiver.CastMessageBus.Event = function() {}; -/** @constructor */ -cast.receiver.CastReceiverManager.Config = function() {}; +/** @type {?} */ +cast.receiver.CastMessageBus.Event.prototype.data; + + +/** @type {string} */ +cast.receiver.CastMessageBus.Event.prototype.senderId; + + + +/** + * @constructor + * @struct + */ +cast.receiver.CastChannel = function() {}; + + +/** @param {*} message */ +cast.receiver.CastChannel.prototype.send = function(message) {}; + + + +/** + * @constructor + * @struct + */ +cast.receiver.CastReceiverManager = function() {}; /** @return {cast.receiver.CastReceiverManager} */ @@ -90,16 +130,50 @@ cast.receiver.CastReceiverManager.prototype.getCastMessageBus = function( cast.receiver.CastReceiverManager.prototype.getSenders = function() {}; -/** - * @param {cast.receiver.CastReceiverManager.Config=} opt_config - */ -cast.receiver.CastReceiverManager.prototype.start = function(opt_config) {}; +cast.receiver.CastReceiverManager.prototype.start = function() {}; + + +cast.receiver.CastReceiverManager.prototype.stop = function() {}; + + +/** @return {?cast.receiver.system.SystemVolumeData} */ +cast.receiver.CastReceiverManager.prototype.getSystemVolume = function() {}; + + +/** @param {number} level */ +cast.receiver.CastReceiverManager.prototype.setSystemVolumeLevel = + function(level) {}; + + +/** @param {number} muted */ +cast.receiver.CastReceiverManager.prototype.setSystemVolumeMuted = + function(muted) {}; + + +/** @return {boolean} */ +cast.receiver.CastReceiverManager.prototype.isSystemReady = function() {}; + + +/** @type {Function} */ +cast.receiver.CastReceiverManager.prototype.onSenderConnected; + + +/** @type {Function} */ +cast.receiver.CastReceiverManager.prototype.onSenderDisconnected; + + +/** @type {Function} */ +cast.receiver.CastReceiverManager.prototype.onSystemVolumeChanged; /** @const */ chrome.cast = {}; +/** @type {boolean} */ +chrome.cast.isAvailable; + + /** * @param {chrome.cast.ApiConfig} apiConfig * @param {Function} successCallback @@ -122,9 +196,10 @@ chrome.cast.requestSession = function( * @param {chrome.cast.SessionRequest} sessionRequest * @param {Function} sessionListener * @param {Function} receiverListener - * @param {Object=} opt_autoJoinPolicy - * @param {Object=} opt_defaultActionPolicy + * @param {string=} opt_autoJoinPolicy + * @param {string=} opt_defaultActionPolicy * @constructor + * @struct */ chrome.cast.ApiConfig = function( sessionRequest, @@ -134,47 +209,45 @@ chrome.cast.ApiConfig = function( opt_defaultActionPolicy) {}; -/** @typedef {string} */ -chrome.cast.Capability; - - /** - * @param {string} url + * @param {string} code + * @param {string=} opt_description + * @param {Object=} opt_details * @constructor + * @struct */ -chrome.cast.Image = function(url) {}; +chrome.cast.Error = function(code, opt_description, opt_details) {}; +/** @type {string} */ +chrome.cast.Error.prototype.code; + + +/** @type {?string} */ +chrome.cast.Error.prototype.description; -/** - * @param {string} label - * @param {string} friendlyName - * @param {Array.=} opt_capabilities - * @param {chrome.cast.Volume=} opt_volume - * @constructor - */ -chrome.cast.Receiver = function( - label, friendlyName, opt_capabilities, opt_volume) {}; + +/** @type {Object} */ +chrome.cast.Error.prototype.details; /** - * @param {string} sessionId - * @param {string} appId - * @param {string} displayName - * @param {chrome.cast.Image} appImages - * @param {chrome.cast.Receiver} receiver * @constructor + * @struct */ -chrome.cast.Session = function( - sessionId, appId, displayName, appImages, receiver) {}; +chrome.cast.Session = function() {}; /** @type {string} */ chrome.cast.Session.prototype.sessionId; +/** @type {string} */ +chrome.cast.Session.prototype.status; + + /** * @param {string} namespace * @param {Function} listener @@ -210,17 +283,7 @@ chrome.cast.Session.prototype.stop = function( /** * @param {string} appId - * @param {Array.=} opt_capabilities - * @param {number=} opt_timeout - * @constructor - */ -chrome.cast.SessionRequest = function(appId, opt_capabilities, opt_timeout) {}; - - - -/** - * @param {number=} opt_level - * @param {boolean=} opt_muted * @constructor + * @struct */ -chrome.cast.Volume = function(opt_level, opt_muted) {}; +chrome.cast.SessionRequest = function(appId) {}; diff --git a/lib/cast/cast_proxy.js b/lib/cast/cast_proxy.js new file mode 100644 index 0000000000..2b453b34e3 --- /dev/null +++ b/lib/cast/cast_proxy.js @@ -0,0 +1,516 @@ +/** + * @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.cast.CastProxy'); + +goog.require('goog.asserts'); +goog.require('shaka.cast.CastSender'); +goog.require('shaka.cast.CastUtils'); +goog.require('shaka.util.EventManager'); +goog.require('shaka.util.FakeEvent'); +goog.require('shaka.util.FakeEventTarget'); +goog.require('shaka.util.IDestroyable'); + + + +/** + * A proxy to switch between local and remote playback for Chromecast in a way + * that is transparent to the app's controls. + * + * @constructor + * @struct + * @param {!HTMLMediaElement} video The local video element associated with the + * local Player instance. + * @param {!shaka.Player} player A local Player instance. + * @param {string} receiverAppId The ID of the cast receiver application. + * @implements {shaka.util.IDestroyable} + * @extends {shaka.util.FakeEventTarget} + * @export + */ +shaka.cast.CastProxy = function(video, player, receiverAppId) { + shaka.util.FakeEventTarget.call(this); + + /** @private {HTMLMediaElement} */ + this.localVideo_ = video; + + /** @private {shaka.Player} */ + this.localPlayer_ = player; + + /** @private {Object} */ + this.videoProxy_ = null; + + /** @private {Object} */ + this.playerProxy_ = null; + + /** @private {shaka.util.FakeEventTarget} */ + this.videoEventTarget_ = null; + + /** @private {shaka.util.FakeEventTarget} */ + this.playerEventTarget_ = null; + + /** @private {shaka.util.EventManager} */ + this.eventManager_ = null; + + /** @private {shaka.cast.CastSender} */ + this.sender_ = new shaka.cast.CastSender( + receiverAppId, + this.onCastStatusChanged_.bind(this), + this.onRemoteEvent_.bind(this), + this.onResumeLocal_.bind(this)); + + this.init_(); +}; +goog.inherits(shaka.cast.CastProxy, shaka.util.FakeEventTarget); + + +/** + * Destroys the proxy and the underlying local Player. + * + * @override + * @export + */ +shaka.cast.CastProxy.prototype.destroy = function() { + var async = [ + this.eventManager_ ? this.eventManager_.destroy() : null, + this.localPlayer_ ? this.localPlayer_.destroy() : null, + this.sender_ ? this.sender_.destroy() : null + ]; + + this.localVideo_ = null; + this.localPlayer_ = null; + this.videoProxy_ = null; + this.playerProxy_ = null; + this.eventManager_ = null; + this.sender_ = null; + + return Promise.all(async); +}; + + +/** + * @event shaka.cast.CastProxy.CastStatusChangedEvent + * @description Fired when cast status changes. The status change will be + * reflected in canCast() and isCasting(). + * @property {string} type + * 'caststatuschanged' + * @exportDoc + */ + + +/** + * Get a proxy for the video element that delegates to local and remote video + * elements as appropriate. + * + * @suppress {invalidCasts} to cast proxy Objects to unrelated types + * @return {HTMLMediaElement} + * @export + */ +shaka.cast.CastProxy.prototype.getVideo = function() { + return /** @type {HTMLMediaElement} */(this.videoProxy_); +}; + + +/** + * Get a proxy for the Player that delegates to local and remote Player objects + * as appropriate. + * + * @suppress {invalidCasts} to cast proxy Objects to unrelated types + * @return {shaka.Player} + * @export + */ +shaka.cast.CastProxy.prototype.getPlayer = function() { + return /** @type {shaka.Player} */(this.playerProxy_); +}; + + +/** + * @return {boolean} True if the cast API is available and there are receivers. + * @export + */ +shaka.cast.CastProxy.prototype.canCast = function() { + return this.sender_ ? + this.sender_.apiReady() && this.sender_.hasReceivers() : + false; +}; + + +/** + * @return {boolean} True if we are currently casting. + * @export + */ +shaka.cast.CastProxy.prototype.isCasting = function() { + return this.sender_ ? this.sender_.isCasting() : false; +}; + + +/** + * @return {!Promise} Resolved when connected to a receiver. Rejected if the + * connection fails or is canceled by the user. + * @suppress {unnecessaryCasts} to cast Player to Object to access with [] + * @export + */ +shaka.cast.CastProxy.prototype.cast = function() { + var initState = { + 'video': {}, + 'player': {}, + 'playerAfterLoad': {}, + 'manifest': this.localPlayer_.getManifestUri(), + 'startTime': null + }; + + // Pause local playback before capturing state. + this.localVideo_.pause(); + + shaka.cast.CastUtils.VideoInitStateAttributes.forEach(function(name) { + initState['video'][name] = this.localVideo_[name]; + }.bind(this)); + + // If the video is still playing, set the startTime. + // Has no effect if nothing is loaded. + if (!this.localVideo_.ended) { + initState['startTime'] = this.localVideo_.currentTime; + } + + shaka.cast.CastUtils.PlayerInitState.forEach(function(pair) { + var getter = pair[0]; + var setter = pair[1]; + var value = /** @type {Object} */(this.localPlayer_)[getter](); + + initState['player'][setter] = value; + }.bind(this)); + + shaka.cast.CastUtils.PlayerInitAfterLoadState.forEach(function(pair) { + var getter = pair[0]; + var setter = pair[1]; + var value = /** @type {Object} */(this.localPlayer_)[getter](); + + initState['playerAfterLoad'][setter] = value; + }.bind(this)); + + // TODO: transfer manually-selected tracks? + // TODO: transfer side-loaded text tracks? + + return this.sender_.cast(initState).then(function() { + // Unload the local manifest when casting succeeds. + return this.localPlayer_.unload(); + }.bind(this)); +}; + + +/** + * Set application-specific data. + * + * @param {Object} appData Application-specific data to relay to the receiver. + * @export + */ +shaka.cast.CastProxy.prototype.setAppData = function(appData) { + this.sender_.setAppData(appData); +}; + + +/** + * Disconnect from the cast connection. + * @export + */ +shaka.cast.CastProxy.prototype.disconnect = function() { + this.sender_.disconnect(); +}; + + +/** + * Initialize the Proxies and the Cast sender. + * @private + * @suppress {unnecessaryCasts} to cast Player to Object to access with [] + */ +shaka.cast.CastProxy.prototype.init_ = function() { + this.sender_.init(); + + this.eventManager_ = new shaka.util.EventManager(); + + shaka.cast.CastUtils.VideoEvents.forEach(function(name) { + this.eventManager_.listen(this.localVideo_, name, + this.videoProxyLocalEvent_.bind(this)); + }.bind(this)); + + shaka.cast.CastUtils.PlayerEvents.forEach(function(name) { + this.eventManager_.listen(this.localPlayer_, name, + this.playerProxyLocalEvent_.bind(this)); + }.bind(this)); + + // We would like to use Proxy here, but it is not supported on IE11 or Safari. + this.videoProxy_ = {}; + for (var k in this.localVideo_) { + Object.defineProperty(this.videoProxy_, k, { + configurable: false, + enumerable: true, + get: this.videoProxyGet_.bind(this, k), + set: this.videoProxySet_.bind(this, k) + }); + } + + this.playerProxy_ = {}; + for (var k in /** @type {Object} */(this.localPlayer_)) { + Object.defineProperty(this.playerProxy_, k, { + configurable: false, + enumerable: true, + get: this.playerProxyGet_.bind(this, k) + }); + } + + this.videoEventTarget_ = new shaka.util.FakeEventTarget(); + this.videoEventTarget_.dispatchTarget = + /** @type {EventTarget} */(this.videoProxy_); + + this.playerEventTarget_ = new shaka.util.FakeEventTarget(); + this.playerEventTarget_.dispatchTarget = + /** @type {EventTarget} */(this.playerProxy_); +}; + + +/** + * Dispatch an event to notify the app that the status has changed. + * @private + */ +shaka.cast.CastProxy.prototype.onCastStatusChanged_ = function() { + var event = new shaka.util.FakeEvent('caststatuschanged'); + this.dispatchEvent(event); +}; + + +/** + * Transfer remote state back and resume local playback. + * @private + * @suppress {unnecessaryCasts} to cast Player to Object to access with [] + */ +shaka.cast.CastProxy.prototype.onResumeLocal_ = function() { + // Transfer back the player state. + shaka.cast.CastUtils.PlayerInitState.forEach(function(pair) { + var getter = pair[0]; + var setter = pair[1]; + var value = this.sender_.get('player', getter)(); + /** @type {Object} */(this.localPlayer_)[setter](value); + }.bind(this)); + + // Get the most recent manifest URI and ended state. + var manifestUri = this.sender_.get('player', 'getManifestUri')(); + var ended = this.sender_.get('video', 'ended'); + + var manifestReady = Promise.resolve(); + var autoplay = this.localVideo_.autoplay; + + var startTime = null; + + // If the video is still playing, set the startTime. + // Has no effect if nothing is loaded. + if (!ended) { + startTime = this.sender_.get('video', 'currentTime'); + } + + // Now load the manifest, if present. + if (manifestUri) { + // Don't autoplay the content until we finish setting up initial state. + this.localVideo_.autoplay = false; + manifestReady = this.localPlayer_.load(manifestUri, startTime); + // Pass any errors through to the app. + manifestReady.catch(function(error) { + goog.asserts.assert(error instanceof shaka.util.Error, + 'Wrong error type!'); + var event = new shaka.util.FakeEvent('error', { 'detail': error }); + this.localPlayer_.dispatchEvent(event); + }.bind(this)); + } + + // Get the video state into a temp variable since we will apply it async. + var videoState = {}; + shaka.cast.CastUtils.VideoInitStateAttributes.forEach(function(name) { + videoState[name] = this.sender_.get('video', name); + }.bind(this)); + + // Finally, take on video state and player's "after load" state. + manifestReady.then(function() { + shaka.cast.CastUtils.VideoInitStateAttributes.forEach(function(name) { + this.localVideo_[name] = videoState[name]; + }.bind(this)); + + shaka.cast.CastUtils.PlayerInitAfterLoadState.forEach(function(pair) { + var getter = pair[0]; + var setter = pair[1]; + var value = this.sender_.get('player', getter)(); + /** @type {Object} */(this.localPlayer_)[setter](value); + }.bind(this)); + + // Restore original autoplay setting. + this.localVideo_.autoplay = autoplay; + if (manifestUri) { + // Resume playback with transferred state. + this.localVideo_.play(); + } + }.bind(this)); +}; + + +/** + * @param {string} name + * @return {?} + * @private + */ +shaka.cast.CastProxy.prototype.videoProxyGet_ = function(name) { + if (name == 'addEventListener') { + return this.videoEventTarget_.addEventListener.bind( + this.videoEventTarget_); + } + if (name == 'removeEventListener') { + return this.videoEventTarget_.removeEventListener.bind( + this.videoEventTarget_); + } + + // If we are casting, but the first update has not come in yet, use local + // values, but not local methods. + if (this.sender_.isCasting() && !this.sender_.hasRemoteProperties()) { + var value = this.localVideo_[name]; + if (typeof value != 'function') { + return value; + } + } + + // Use local values and methods if we are not casting. + if (!this.sender_.isCasting()) { + var value = this.localVideo_[name]; + if (typeof value == 'function') { + value = value.bind(this.localVideo_); + } + return value; + } + + return this.sender_.get('video', name); +}; + + +/** + * @param {string} name + * @param {?} value + * @private + */ +shaka.cast.CastProxy.prototype.videoProxySet_ = function(name, value) { + if (!this.sender_.isCasting()) { + this.localVideo_[name] = value; + return; + } + + this.sender_.set('video', name, value); +}; + + +/** + * @param {!Event} event + * @private + */ +shaka.cast.CastProxy.prototype.videoProxyLocalEvent_ = function(event) { + if (this.sender_.isCasting()) { + // Ignore any unexpected local events while casting. Events can still be + // fired by the local video and Player when we unload() after the Cast + // connection is complete. + return; + } + + // Convert this real Event into a FakeEvent for dispatch from our + // FakeEventListener. + var fakeEvent = new shaka.util.FakeEvent(event.type, event); + this.videoEventTarget_.dispatchEvent(fakeEvent); +}; + + +/** + * @param {string} name + * @return {?} + * @private + * @suppress {unnecessaryCasts} to cast Player to Object to access with [] + */ +shaka.cast.CastProxy.prototype.playerProxyGet_ = function(name) { + if (name == 'addEventListener') { + return this.playerEventTarget_.addEventListener.bind( + this.playerEventTarget_); + } + if (name == 'removeEventListener') { + return this.playerEventTarget_.removeEventListener.bind( + this.playerEventTarget_); + } + + if (name == 'getNetworkingEngine') { + // Always returns a local instance, in case you need to make a request. + // Issues a warning, in case you think you are making a remote request + // or affecting remote filters. + if (this.sender_.isCasting()) { + shaka.log.warning('NOTE: getNetworkingEngine() is always local!'); + } + return this.localPlayer_.getNetworkingEngine.bind(this.localPlayer_); + } + + // If we are casting, but the first update has not come in yet, use local + // getters, but not local methods. + if (this.sender_.isCasting() && !this.sender_.hasRemoteProperties()) { + if (shaka.cast.CastUtils.PlayerGetterMethods.indexOf(name) >= 0) { + var value = /** @type {Object} */(this.localPlayer_)[name]; + goog.asserts.assert(typeof value == 'function', 'only methods on Player'); + return value.bind(this.localPlayer_); + } + } + + // Use local getters and methods if we are not casting. + if (!this.sender_.isCasting()) { + var value = /** @type {Object} */(this.localPlayer_)[name]; + goog.asserts.assert(typeof value == 'function', 'only methods on Player'); + return value.bind(this.localPlayer_); + } + + return this.sender_.get('player', name); +}; + + +/** + * @param {!Event} event + * @private + */ +shaka.cast.CastProxy.prototype.playerProxyLocalEvent_ = function(event) { + if (this.sender_.isCasting()) { + // Ignore any unexpected local events while casting. + return; + } + + this.playerEventTarget_.dispatchEvent(event); +}; + + +/** + * @param {string} targetName + * @param {!shaka.util.FakeEvent} event + * @private + */ +shaka.cast.CastProxy.prototype.onRemoteEvent_ = function(targetName, event) { + goog.asserts.assert(this.sender_.isCasting(), + 'Should only receive remote events while casting'); + if (!this.sender_.isCasting()) { + // Ignore any unexpected remote events. + return; + } + + if (targetName == 'video') { + this.videoEventTarget_.dispatchEvent(event); + } else if (targetName == 'player') { + this.playerEventTarget_.dispatchEvent(event); + } +}; diff --git a/lib/cast/cast_receiver.js b/lib/cast/cast_receiver.js new file mode 100644 index 0000000000..667717266b --- /dev/null +++ b/lib/cast/cast_receiver.js @@ -0,0 +1,418 @@ +/** + * @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.cast.CastReceiver'); + +goog.require('shaka.cast.CastUtils'); +goog.require('shaka.log'); +goog.require('shaka.util.FakeEvent'); +goog.require('shaka.util.FakeEventTarget'); +goog.require('shaka.util.IDestroyable'); + + + +/** + * A receiver to communicate between the Chromecast-hosted player and the + * sender application. + * + * @constructor + * @struct + * @param {!HTMLMediaElement} video The local video element associated with the + * local Player instance. + * @param {!shaka.Player} player A local Player instance. + * @param {function(Object)=} opt_appDataCallback A callback to handle + * application-specific data passed from the sender. + * @implements {shaka.util.IDestroyable} + * @extends {shaka.util.FakeEventTarget} + * @export + */ +shaka.cast.CastReceiver = function(video, player, opt_appDataCallback) { + shaka.util.FakeEventTarget.call(this); + + /** @private {HTMLMediaElement} */ + this.video_ = video; + + /** @private {shaka.Player} */ + this.player_ = player; + + /** @private {Object} */ + this.targets_ = { + 'video': video, + 'player': player + }; + + /** @private {?function(Object)} */ + this.appDataCallback_ = opt_appDataCallback || function() {}; + + /** @private {boolean} */ + this.isConnected_ = false; + + /** @private {cast.receiver.CastMessageBus} */ + this.bus_ = null; + + /** @private {?number} */ + this.pollTimerId_ = null; + + this.init_(); +}; +goog.inherits(shaka.cast.CastReceiver, shaka.util.FakeEventTarget); + + +/** + * @return {boolean} True if the cast API is available and there are receivers. + * @export + */ +shaka.cast.CastReceiver.prototype.isConnected = function() { + return this.isConnected_; +}; + + +/** + * Destroys the underlying Player, then terminates the cast receiver app. + * + * @override + * @export + */ +shaka.cast.CastReceiver.prototype.destroy = function() { + var p = this.player_ ? this.player_.destroy() : Promise.resolve(); + + if (this.pollTimerId_ != null) { + window.clearTimeout(this.pollTimerId_); + } + + this.video_ = null; + this.player_ = null; + this.targets_ = null; + this.appDataCallback_ = null; + this.isConnected_ = false; + this.bus_ = null; + this.pollTimerId_ = null; + + return p.then(function() { + var manager = cast.receiver.CastReceiverManager.getInstance(); + manager.stop(); + }); +}; + + +/** @private */ +shaka.cast.CastReceiver.prototype.init_ = function() { + var manager = cast.receiver.CastReceiverManager.getInstance(); + manager.onSenderConnected = this.onSendersChanged_.bind(this); + manager.onSenderDisconnected = this.onSendersChanged_.bind(this); + manager.onSystemVolumeChanged = this.fakeVolumeChangeEvent_.bind(this); + + this.bus_ = manager.getCastMessageBus(shaka.cast.CastUtils.MESSAGE_NAMESPACE); + this.bus_.onMessage = this.onMessage_.bind(this); + + if (!COMPILED) { + // Sometimes it is useful to load the receiver app in Chrome to work on the + // UI. To avoid log spam caused by the SDK trying to connect to web sockets + // that don't exist, in uncompiled mode we check if the hosting browser is a + // Chromecast before starting the receiver manager. We wouldn't do browser + // detection except for debugging, so only do this in uncompiled mode. + var isChromecast = navigator.userAgent.indexOf('CrKey') >= 0; + if (isChromecast) { + manager.start(); + } + } else { + manager.start(); + } + + shaka.cast.CastUtils.VideoEvents.forEach(function(name) { + this.video_.addEventListener(name, this.proxyEvent_.bind(this, 'video')); + }.bind(this)); + + shaka.cast.CastUtils.PlayerEvents.forEach(function(name) { + this.player_.addEventListener(name, this.proxyEvent_.bind(this, 'player')); + }.bind(this)); + + // Start polling. + this.pollAttributes_(); +}; + + +/** @private */ +shaka.cast.CastReceiver.prototype.onSendersChanged_ = function() { + var manager = cast.receiver.CastReceiverManager.getInstance(); + this.isConnected_ = manager.getSenders().length != 0; + this.onCastStatusChanged_(); +}; + + +/** + * Dispatch an event to notify the receiver app that the status has changed. + * @private + */ +shaka.cast.CastReceiver.prototype.onCastStatusChanged_ = function() { + var event = new shaka.util.FakeEvent('caststatuschanged'); + this.dispatchEvent(event); +}; + + +/** + * Take on initial state from the sender. + * @param {shaka.cast.CastUtils.InitStateType} initState + * @param {Object} appData + * @private + * @suppress {unnecessaryCasts} + */ +shaka.cast.CastReceiver.prototype.initState_ = function(initState, appData) { + // Take on player state first. + for (var k in initState['player']) { + var v = initState['player'][k]; + // All player state vars are setters to be called. + /** @type {Object} */(this.player_)[k](v); + } + + // Now process custom app data, which may add additional player configs: + this.appDataCallback_(appData); + + var manifestReady = Promise.resolve(); + var autoplay = this.video_.autoplay; + + // Now load the manifest, if present. + if (initState['manifest']) { + // Don't autoplay the content until we finish setting up initial state. + this.video_.autoplay = false; + manifestReady = this.player_.load( + initState['manifest'], initState['startTime']); + // Pass any errors through to the app. + manifestReady.catch(function(error) { + goog.asserts.assert(error instanceof shaka.util.Error, + 'Wrong error type!'); + var event = new shaka.util.FakeEvent('error', { 'detail': error }); + this.player_.dispatchEvent(event); + }.bind(this)); + } + + // Finally, take on video state and player's "after load" state. + manifestReady.then(function() { + for (var k in initState['video']) { + var v = initState['video'][k]; + this.video_[k] = v; + } + + for (var k in initState['playerAfterLoad']) { + var v = initState['playerAfterLoad'][k]; + // All player state vars are setters to be called. + /** @type {Object} */(this.player_)[k](v); + } + + // Restore original autoplay setting. + this.video_.autoplay = autoplay; + if (initState['manifest']) { + // Resume playback with transferred state. + this.video_.play(); + } + }.bind(this)); +}; + + +/** + * @param {string} targetName + * @param {!Event} event + * @private + */ +shaka.cast.CastReceiver.prototype.proxyEvent_ = function(targetName, event) { + // Poll and send an update right before we send the event. Some events + // indicate an attribute change, so that change should be visible when the + // event is handled. + this.pollAttributes_(); + + this.sendMessage_({ + 'type': 'event', + 'targetName': targetName, + 'event': event + }); +}; + + +/** + * @private + * @suppress {unnecessaryCasts} + */ +shaka.cast.CastReceiver.prototype.pollAttributes_ = function() { + // The poll timer may have been pre-empted by an event. + // To avoid polling too often, we clear it here. + if (this.pollTimerId_ != null) { + window.clearTimeout(this.pollTimerId_); + } + // Since we know the timer has been cleared, start a new one now. + // This will be preempted by events, including 'timeupdate'. + this.pollTimerId_ = window.setTimeout(this.pollAttributes_.bind(this), 500); + + var update = { + 'video': {}, + 'player': {} + }; + + shaka.cast.CastUtils.VideoAttributes.forEach(function(name) { + update['video'][name] = this.video_[name]; + }.bind(this)); + + shaka.cast.CastUtils.PlayerGetterMethods.forEach(function(name) { + update['player'][name] = /** @type {Object} */(this.player_)[name](); + }.bind(this)); + + // Volume attributes are tied to the system volume. + var manager = cast.receiver.CastReceiverManager.getInstance(); + var systemVolume = manager.getSystemVolume(); + if (systemVolume) { + update['video']['volume'] = systemVolume.level; + update['video']['muted'] = systemVolume.muted; + } + + this.sendMessage_({ + 'type': 'update', + 'update': update + }); +}; + + +/** + * Dispatch a fake 'volumechange' event to mimic the video element, since volume + * changes are routed to the system volume on the receiver. + * @private + */ +shaka.cast.CastReceiver.prototype.fakeVolumeChangeEvent_ = function() { + // Volume attributes are tied to the system volume. + var manager = cast.receiver.CastReceiverManager.getInstance(); + var systemVolume = manager.getSystemVolume(); + goog.asserts.assert(systemVolume, 'System volume should not be null!'); + + if (systemVolume) { + // Send an update message with just the latest volume level and muted state. + this.sendMessage_({ + 'type': 'update', + 'update': { + 'video': { + 'volume': systemVolume.level, + 'muted': systemVolume.muted + } + } + }); + } + + // Send another message with a 'volumechange' event to update the sender's UI. + this.sendMessage_({ + 'type': 'event', + 'targetName': 'video', + 'event': {'type': 'volumechange'} + }); +}; + + +/** + * Since this method is in the compiled library, make sure all messages are + * read with quoted properties. + * @param {cast.receiver.CastMessageBus.Event} event + * @private + */ +shaka.cast.CastReceiver.prototype.onMessage_ = function(event) { + var message = shaka.cast.CastUtils.deserialize(event.data); + shaka.log.debug('CastReceiver: message', message); + + switch (message['type']) { + case 'init': + this.initState_(message['initState'], message['appData']); + // The sender is supposed to reflect the cast system volume after + // connecting. Send a volume change event right away. + this.fakeVolumeChangeEvent_(); + break; + case 'appData': + this.appDataCallback_(message['appData']); + break; + case 'set': + var targetName = message['targetName']; + var property = message['property']; + var value = message['value']; + + if (targetName == 'video') { + // Volume attributes must be rerouted to the system. + var manager = cast.receiver.CastReceiverManager.getInstance(); + if (property == 'volume') { + manager.setSystemVolumeLevel(value); + break; + } else if (property == 'muted') { + manager.setSystemVolumeMuted(value); + break; + } + } + + this.targets_[targetName][property] = value; + break; + case 'call': + var targetName = message['targetName']; + var methodName = message['methodName']; + var args = message['args']; + var target = this.targets_[targetName]; + target[methodName].apply(target, args); + break; + case 'asyncCall': + var targetName = message['targetName']; + var methodName = message['methodName']; + var args = message['args']; + var id = message['id']; + var senderId = event.senderId; + var target = this.targets_[targetName]; + var p = target[methodName].apply(target, args); + // Replies must go back to the specific sender who initiated, so that we + // don't have to deal with conflicting IDs between senders. + p.then(this.sendAsyncComplete_.bind(this, senderId, id, /* error */ null), + this.sendAsyncComplete_.bind(this, senderId, id)); + break; + } +}; + + +/** + * Tell the sender that the async operation is complete. + * @param {string} senderId + * @param {string} id + * @param {shaka.util.Error} error + * @private + */ +shaka.cast.CastReceiver.prototype.sendAsyncComplete_ = + function(senderId, id, error) { + this.sendMessage_({ + 'type': 'asyncComplete', + 'id': id, + 'error': error + }, senderId); +}; + + +/** + * Since this method is in the compiled library, make sure all messages passed + * in here were created with quoted property names. + * @param {!Object} message + * @param {string=} opt_senderId + * @private + */ +shaka.cast.CastReceiver.prototype.sendMessage_ = + function(message, opt_senderId) { + // Cuts log spam when debugging the receiver UI in Chrome. + if (!this.isConnected_) return; + + var serialized = shaka.cast.CastUtils.serialize(message); + if (opt_senderId) { + this.bus_.getCastChannel(opt_senderId).send(serialized); + } else { + this.bus_.broadcast(serialized); + } +}; diff --git a/lib/cast/cast_sender.js b/lib/cast/cast_sender.js new file mode 100644 index 0000000000..903ab12a8a --- /dev/null +++ b/lib/cast/cast_sender.js @@ -0,0 +1,541 @@ +/** + * @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.cast.CastSender'); + +goog.require('goog.asserts'); +goog.require('shaka.log'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.FakeEvent'); +goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.PublicPromise'); + + + +/** + * @constructor + * @struct + * @param {string} receiverAppId The ID of the cast receiver application. + * @param {function()} onStatusChanged A callback invoked when the cast status + * changes. + * @param {function(string, !shaka.util.FakeEvent)} onRemoteEvent A callback + * invoked with target name and event when a remote event is received. + * @param {function()} onResumeLocal A callback invoked when the local player + * should resume playback. Called before cached remote state is wiped. + * @implements {shaka.util.IDestroyable} + */ +shaka.cast.CastSender = + function(receiverAppId, onStatusChanged, onRemoteEvent, onResumeLocal) { + /** @private {string} */ + this.receiverAppId_ = receiverAppId; + + /** @private {?function()} */ + this.onStatusChanged_ = onStatusChanged; + + /** @private {?function(string, !shaka.util.FakeEvent)} */ + this.onRemoteEvent_ = onRemoteEvent; + + /** @private {?function()} */ + this.onResumeLocal_ = onResumeLocal; + + /** @private {boolean} */ + this.apiReady_ = false; + + /** @private {boolean} */ + this.hasReceivers_ = false; + + /** @private {boolean} */ + this.isCasting_ = false; + + /** @private {Object} */ + this.appData_ = null; + + /** @private {chrome.cast.Session} */ + this.session_ = null; + + /** @private {Object} */ + this.cachedProperties_ = { + 'video': {}, + 'player': {} + }; + + /** @private {number} */ + this.nextAsyncCallId_ = 0; + + /** @private {Object.} */ + this.asyncCallPromises_ = {}; + + /** @private {shaka.util.PublicPromise} */ + this.castPromise_ = null; +}; + + +/** @override */ +shaka.cast.CastSender.prototype.destroy = function() { + this.disconnect(); + + this.onStatusChanged_ = null; + this.onRemoteEvent_ = null; + this.onResumeLocal_ = null; + this.apiReady_ = false; + this.hasReceivers_ = false; + this.isCasting_ = false; + this.appData_ = null; + this.session_ = null; + this.cachedProperties_ = null; + this.asyncCallPromises_ = null; + this.castPromise_ = null; + + return Promise.resolve(); +}; + + +/** + * @return {boolean} True if the cast API is available. + */ +shaka.cast.CastSender.prototype.apiReady = function() { + return this.apiReady_; +}; + + +/** + * @return {boolean} True if there are receivers. + */ +shaka.cast.CastSender.prototype.hasReceivers = function() { + return this.hasReceivers_; +}; + + +/** + * @return {boolean} True if we are currently casting. + */ +shaka.cast.CastSender.prototype.isCasting = function() { + return this.isCasting_; +}; + + +/** + * @return {boolean} True if we have a cache of remote properties from the + * receiver. + */ +shaka.cast.CastSender.prototype.hasRemoteProperties = function() { + return Object.keys(this.cachedProperties_['video']).length != 0; +}; + + +/** + * Initialize the Cast API. + */ +shaka.cast.CastSender.prototype.init = function() { + // Check for the cast extension. + if (!window.chrome || !chrome.cast || !chrome.cast.isAvailable) { + // Not available yet, so wait to be notified if/when it is available. + window.__onGCastApiAvailable = (function(loaded) { + if (loaded) { + this.init(); + } + }).bind(this); + return; + } + + // The API is now available. + delete window.__onGCastApiAvailable; + this.apiReady_ = true; + this.onStatusChanged_(); + + var sessionRequest = new chrome.cast.SessionRequest(this.receiverAppId_); + var apiConfig = new chrome.cast.ApiConfig(sessionRequest, + this.onExistingSessionJoined_.bind(this), + this.onReceiverStatusChanged_.bind(this), + 'origin_scoped'); + + // TODO: have never seen this fail. when would it and how should we react? + chrome.cast.initialize(apiConfig, + function() { shaka.log.debug('CastSender: init'); }, + function(error) { shaka.log.error('CastSender: init error', error); }); +}; + + +/** + * Set application-specific data. + * + * @param {Object} appData Application-specific data to relay to the receiver. + */ +shaka.cast.CastSender.prototype.setAppData = function(appData) { + this.appData_ = appData; + if (this.isCasting_) { + this.sendMessage_({ + 'type': 'appData', + 'appData': this.appData_ + }); + } +}; + + +/** + * @param {shaka.cast.CastUtils.InitStateType} initState Video and player state + * to be sent to the receiver. + * @return {!Promise} Resolved when connected to a receiver. Rejected if the + * connection fails or is canceled by the user. + */ +shaka.cast.CastSender.prototype.cast = function(initState) { + if (!this.apiReady_) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Category.CAST, + shaka.util.Error.Code.CAST_API_UNAVAILABLE)); + } + if (!this.hasReceivers_) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Category.CAST, + shaka.util.Error.Code.NO_CAST_RECEIVERS)); + } + if (this.isCasting_) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Category.CAST, + shaka.util.Error.Code.ALREADY_CASTING)); + } + + this.castPromise_ = new shaka.util.PublicPromise(); + chrome.cast.requestSession( + this.onSessionInitiated_.bind(this, initState), + this.onConnectionError_.bind(this)); + return this.castPromise_; +}; + + +/** + * Disconnect from the receiver app. Does nothing if not connected. + */ +shaka.cast.CastSender.prototype.disconnect = function() { + if (!this.isCasting_) { + return; + } + + this.rejectAllPromises_(); + if (this.session_) { + this.session_.stop(function() {}, function() {}); + this.session_ = null; + } +}; + + +/** + * Getter for properties of remote objects. + * @param {string} targetName + * @param {string} property + * @return {?} + */ +shaka.cast.CastSender.prototype.get = function(targetName, property) { + goog.asserts.assert(targetName == 'video' || targetName == 'player', + 'Unexpected target name'); + if (targetName == 'video') { + if (shaka.cast.CastUtils.VideoVoidMethods.indexOf(property) >= 0) { + return this.remoteCall_.bind(this, targetName, property); + } + } else if (targetName == 'player') { + if (shaka.cast.CastUtils.PlayerVoidMethods.indexOf(property) >= 0) { + return this.remoteCall_.bind(this, targetName, property); + } + if (shaka.cast.CastUtils.PlayerPromiseMethods.indexOf(property) >= 0) { + return this.remoteAsyncCall_.bind(this, targetName, property); + } + if (shaka.cast.CastUtils.PlayerGetterMethods.indexOf(property) >= 0) { + return this.propertyGetter_.bind(this, targetName, property); + } + } + + return this.propertyGetter_(targetName, property); +}; + + +/** + * Setter for properties of remote objects. + * @param {string} targetName + * @param {string} property + * @param {?} value + */ +shaka.cast.CastSender.prototype.set = function(targetName, property, value) { + goog.asserts.assert(targetName == 'video' || targetName == 'player', + 'Unexpected target name'); + + this.cachedProperties_[targetName][property] = value; + this.sendMessage_({ + 'type': 'set', + 'targetName': targetName, + 'property': property, + 'value': value + }); +}; + + +/** + * @param {shaka.cast.CastUtils.InitStateType} initState + * @param {chrome.cast.Session} session + * @private + */ +shaka.cast.CastSender.prototype.onSessionInitiated_ = + function(initState, session) { + shaka.log.debug('CastSender: onSessionInitiated'); + this.onSessionCreated_(session); + + this.sendMessage_({ + 'type': 'init', + 'initState': initState, + 'appData': this.appData_ + }); + + this.castPromise_.resolve(); +}; + + +/** + * @param {chrome.cast.Error} error + * @private + */ +shaka.cast.CastSender.prototype.onConnectionError_ = function(error) { + // Default error code: + var code = shaka.util.Error.Code.UNEXPECTED_CAST_ERROR; + + switch (error.code) { + case 'cancel': + code = shaka.util.Error.Code.CAST_CANCELED_BY_USER; + break; + case 'timeout': + code = shaka.util.Error.Code.CAST_CONNECTION_TIMED_OUT; + break; + case 'receiver_unavailable': + code = shaka.util.Error.Code.CAST_RECEIVER_APP_UNAVAILABLE; + break; + } + + this.castPromise_.reject(new shaka.util.Error( + shaka.util.Error.Category.CAST, + code, + error)); +}; + + +/** + * @param {string} targetName + * @param {string} property + * @return {?} + * @private + */ +shaka.cast.CastSender.prototype.propertyGetter_ = + function(targetName, property) { + goog.asserts.assert(targetName == 'video' || targetName == 'player', + 'Unexpected target name'); + return this.cachedProperties_[targetName][property]; +}; + + +/** + * @param {string} targetName + * @param {string} methodName + * @private + */ +shaka.cast.CastSender.prototype.remoteCall_ = + function(targetName, methodName) { + goog.asserts.assert(targetName == 'video' || targetName == 'player', + 'Unexpected target name'); + var args = Array.prototype.slice.call(arguments, 2); + this.sendMessage_({ + 'type': 'call', + 'targetName': targetName, + 'methodName': methodName, + 'args': args + }); +}; + + +/** + * @param {string} targetName + * @param {string} methodName + * @return {!Promise} + * @private + */ +shaka.cast.CastSender.prototype.remoteAsyncCall_ = + function(targetName, methodName) { + goog.asserts.assert(targetName == 'video' || targetName == 'player', + 'Unexpected target name'); + var args = Array.prototype.slice.call(arguments, 2); + + var p = new shaka.util.PublicPromise(); + var id = this.nextAsyncCallId_.toString(); + this.nextAsyncCallId_++; + this.asyncCallPromises_[id] = p; + + this.sendMessage_({ + 'type': 'asyncCall', + 'targetName': targetName, + 'methodName': methodName, + 'args': args, + 'id': id + }); + return p; +}; + + +/** + * @param {chrome.cast.Session} session + * @private + */ +shaka.cast.CastSender.prototype.onExistingSessionJoined_ = function(session) { + // TODO: improve support for connecting from Chrome UI (during local playback) + // TODO: improve support for joining existing non-idle sessions on startup + shaka.log.debug('CastSender: onExistingSessionJoined'); + this.onSessionCreated_(session); +}; + + +/** + * @param {string} availability + * @private + */ +shaka.cast.CastSender.prototype.onReceiverStatusChanged_ = + function(availability) { + // The cast extension is telling us whether there are any cast receiver + // devices available. + shaka.log.debug('CastSender: receiver status', availability); + this.hasReceivers_ = availability == 'available'; + this.onStatusChanged_(); +}; + + +/** + * @param {chrome.cast.Session} session + * @private + */ +shaka.cast.CastSender.prototype.onSessionCreated_ = function(session) { + this.session_ = session; + this.session_.addUpdateListener(this.onConnectionStatusChanged_.bind(this)); + this.session_.addMessageListener( + shaka.cast.CastUtils.MESSAGE_NAMESPACE, + this.onMessageReceived_.bind(this)); + this.onConnectionStatusChanged_(); +}; + + +/** + * @private + */ +shaka.cast.CastSender.prototype.onConnectionStatusChanged_ = function() { + var connected = this.session_ ? this.session_.status == 'connected' : false; + shaka.log.debug('CastSender: connection status', connected); + if (this.isCasting_ && !connected) { + // Tell CastProxy to transfer state back to local player. + this.onResumeLocal_(); + + // Clear whatever we have cached. + for (var targetName in this.cachedProperties_) { + this.cachedProperties_[targetName] = {}; + } + + this.rejectAllPromises_(); + } + + this.isCasting_ = connected; + this.onStatusChanged_(); +}; + + +/** + * Reject any async call promises that are still pending. + * @private + */ +shaka.cast.CastSender.prototype.rejectAllPromises_ = function() { + for (var id in this.asyncCallPromises_) { + var p = this.asyncCallPromises_[id]; + delete this.asyncCallPromises_[id]; + + // Reject pending async operations as if they were interrupted. + // At the moment, load() is the only async operation we are worried + // about. + p.reject(new shaka.util.Error( + shaka.util.Error.Category.PLAYER, + shaka.util.Error.Code.LOAD_INTERRUPTED)); + } +}; + + +/** + * Since this method is in the compiled library, make sure all messages are + * read with quoted properties. + * @param {string} namespace + * @param {string} serialized + * @private + * @suppress {unnecessaryCasts} + */ +shaka.cast.CastSender.prototype.onMessageReceived_ = + function(namespace, serialized) { + var message = shaka.cast.CastUtils.deserialize(serialized); + shaka.log.v2('CastSender: message', message); + + switch (message['type']) { + case 'event': + var targetName = message['targetName']; + var event = message['event']; + var fakeEvent = new shaka.util.FakeEvent(event['type'], event); + this.onRemoteEvent_(targetName, fakeEvent); + break; + case 'update': + var update = message['update']; + for (var targetName in update) { + var target = this.cachedProperties_[targetName] || {}; + for (var property in update[targetName]) { + target[property] = update[targetName][property]; + } + } + break; + case 'asyncComplete': + var id = message['id']; + var error = message['error']; + var p = this.asyncCallPromises_[id]; + delete this.asyncCallPromises_[id]; + + goog.asserts.assert(p, 'Unexpected async id'); + if (!p) break; + + if (error) { + // This is a hacky way to reconstruct the serialized error. + var reconstructedError = new shaka.util.Error( + error.category, error.code); + for (var k in error) { + (/** @type {Object} */(reconstructedError))[k] = error[k]; + } + p.reject(reconstructedError); + } else { + p.resolve(); + } + break; + } +}; + + +/** + * Since this method is in the compiled library, make sure all messages passed + * in here were created with quoted property names. + * @param {!Object} message + * @private + */ +shaka.cast.CastSender.prototype.sendMessage_ = function(message) { + var serialized = shaka.cast.CastUtils.serialize(message); + // TODO: have never seen this fail. when would it and how should we react? + this.session_.sendMessage(shaka.cast.CastUtils.MESSAGE_NAMESPACE, serialized, + function() {}, // success callback + shaka.log.error); // error callback +}; diff --git a/lib/cast/cast_utils.js b/lib/cast/cast_utils.js new file mode 100644 index 0000000000..dd09122244 --- /dev/null +++ b/lib/cast/cast_utils.js @@ -0,0 +1,305 @@ +/** + * @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.cast.CastUtils'); + + +/** + * @namespace shaka.cast.CastUtils + * @summary A set of cast utility functions and variables shared between sender + * and receiver. + */ + + +/** + * HTMLMediaElement events that are proxied while casting. + * @const {!Array.} + */ +shaka.cast.CastUtils.VideoEvents = [ + 'ended', + 'play', + 'playing', + 'pause', + 'pausing', + 'ratechange', + 'seeked', + 'seeking', + 'timeupdate', + 'volumechange' +]; + + +/** + * HTMLMediaElement attributes that are proxied while casting. + * @const {!Array.} + */ +shaka.cast.CastUtils.VideoAttributes = [ + 'buffered', + 'currentTime', + 'duration', + 'ended', + 'loop', + 'muted', + 'paused', + 'playbackRate', + 'seeking', + 'videoHeight', + 'videoWidth', + 'volume' +]; + + +/** + * HTMLMediaElement attributes that are transferred when casting begins. + * @const {!Array.} + */ +shaka.cast.CastUtils.VideoInitStateAttributes = [ + 'loop', + 'playbackRate' +]; + + +/** + * HTMLMediaElement methods with no return value that are proxied while casting. + * @const {!Array.} + */ +shaka.cast.CastUtils.VideoVoidMethods = [ + 'pause', + 'play' +]; + + +/** + * Player events that are proxied while casting. + * @const {!Array.} + */ +shaka.cast.CastUtils.PlayerEvents = [ + 'adaptation', + 'buffering', + 'error', + 'texttrackvisibility', + 'trackschanged' +]; + + +/** + * Player getter methods that are proxied while casting. + * @const {!Array.} + */ +shaka.cast.CastUtils.PlayerGetterMethods = [ + 'getConfiguration', + 'getManifestUri', + 'getPlaybackRate', + 'getTracks', + 'getStats', + 'isBuffering', + 'isLive', + 'isTextTrackVisible', + 'seekRange' +]; + + +/** + * Player getter and setter methods that are used to transfer state when casting + * begins. + * @const {!Array.>} + */ +shaka.cast.CastUtils.PlayerInitState = [ + ['getConfiguration', 'configure'] +]; + + +/** + * Player getter and setter methods that are used to transfer state after + * after load() is resolved. + * @const {!Array.>} + */ +shaka.cast.CastUtils.PlayerInitAfterLoadState = [ + ['isTextTrackVisible', 'setTextTrackVisibility'] +]; + + +/** + * Player methods with no return value that are proxied while casting. + * @const {!Array.} + */ +shaka.cast.CastUtils.PlayerVoidMethods = [ + 'configure', + 'resetConfiguration', + 'trickPlay', + 'cancelTrickPlay', + 'selectTrack', + 'setTextTrackVisibility', + 'addTextTrack' +]; + + +/** + * Player methods returning a Promise that are proxied while casting. + * @const {!Array.} + */ +shaka.cast.CastUtils.PlayerPromiseMethods = [ + // The opt_manifestFactory method is not supported. + 'load', + 'unload' +]; + + +/** + * @typedef {{ + * video: Object, + * player: Object, + * manifest: ?string, + * startTime: ?number + * }} + * @property {Object} video + * Dictionary of video properties to be set. + * @property {Object} player + * Dictionary of player setters to be called. + * @property {?string} manifest + * The currently-selected manifest, if present. + * @property {?number} startTime + * The playback start time, if currently playing. + */ +shaka.cast.CastUtils.InitStateType; + + +/** + * The namespace for Shaka messages on the cast bus. + * @const {string} + */ +shaka.cast.CastUtils.MESSAGE_NAMESPACE = 'urn:x-cast:com.google.shaka.v2'; + + +/** + * Serialize as JSON, but specially encode things JSON will not otherwise + * represent. + * @param {?} thing + * @return {string} + */ +shaka.cast.CastUtils.serialize = function(thing) { + return JSON.stringify(thing, function(key, value) { + if (key == 'manager') { + // ABR manager can't be serialized. + return undefined; + } + if (typeof value == 'function') { + // Functions can't be (safely) serialized. + return undefined; + } + if (value instanceof Event || value instanceof shaka.util.FakeEvent) { + // Events don't serialize to JSON well because of the DOM objects + // and other complex objects they contain. So we strip these out. + // Note that using Object.keys or JSON.stringify directly on the event + // will not capture its properties. We must use a for loop. + var simpleEvent = {}; + for (var eventKey in value) { + var eventValue = value[eventKey]; + if (eventValue && typeof eventValue == 'object') { + // Strip out non-null object types because they are complex and we + // don't need them. + } else if (eventKey in Event) { + // Strip out keys that are found on Event itself because they are + // class-level constants we don't need, like Event.MOUSEMOVE == 16. + } else { + simpleEvent[eventKey] = eventValue; + } + } + return simpleEvent; + } + if (value instanceof TimeRanges) { + // TimeRanges must be unpacked into plain data for serialization. + return shaka.cast.CastUtils.unpackTimeRanges_(value); + } + if (typeof value == 'number') { + // NaN and infinity cannot be represented directly in JSON. + if (isNaN(value)) return 'NaN'; + if (isFinite(value)) return value; + if (value < 0) return '-Infinity'; + return 'Infinity'; + } + return value; + }); +}; + + +/** + * Deserialize JSON using our special encodings. + * @param {string} str + * @return {?} + */ +shaka.cast.CastUtils.deserialize = function(str) { + return JSON.parse(str, function(key, value) { + if (value == 'NaN') { + return NaN; + } else if (value == '-Infinity') { + return -Infinity; + } else if (value == 'Infinity') { + return Infinity; + } else if (value && typeof value == 'object' && + value['__type__'] == 'TimeRanges') { + // TimeRanges objects have been unpacked and sent as plain data. + // Simulate the original TimeRanges object. + return shaka.cast.CastUtils.simulateTimeRanges_(value); + } + return value; + }); +}; + + +/** + * @param {!TimeRanges} ranges + * @return {Object} + * @private + */ +shaka.cast.CastUtils.unpackTimeRanges_ = function(ranges) { + var obj = { + '__type__': 'TimeRanges', // a signal to deserialize + 'length': ranges.length, + 'start': [], + 'end': [] + }; + + for (var i = 0; i < ranges.length; ++i) { + obj['start'].push(ranges.start(i)); + obj['end'].push(ranges.end(i)); + } + + return obj; +}; + + +/** + * Creates a simulated TimeRanges object from data sent by the cast receiver. + * @param {?} obj + * @return {{ + * length: number, + * start: function(number): number, + * end: function(number): number + * }} + * @private + */ +shaka.cast.CastUtils.simulateTimeRanges_ = function(obj) { + return { + length: obj.length, + // NOTE: a more complete simulation would throw when |i| was out of range, + // but for simplicity we will assume a well-behaved application that uses + // length instead of catch to stop iterating. + start: function(i) { return obj.start[i]; }, + end: function(i) { return obj.end[i]; } + }; +}; diff --git a/lib/util/error.js b/lib/util/error.js index 9355e0be71..94899c58c7 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -121,6 +121,9 @@ shaka.util.Error.Category = { /** Miscellaneous errors from the player. */ 'PLAYER': 7, + /** Errors related to cast. */ + 'CAST': 8, + /** Errors in the database storage (offline). */ 'STORAGE': 9 }; @@ -500,6 +503,50 @@ shaka.util.Error.Code = { */ 'LOAD_INTERRUPTED': 7000, + /** + * The Cast API is unavailable. This may be because of one of the following: + * - The browser may not have Cast support + * - The browser may be missing a necessary Cast extension + * - The Cast sender library may not be loaded in your app + */ + 'CAST_API_UNAVAILABLE': 8000, + + /** + * No cast receivers are available at this time. + */ + 'NO_CAST_RECEIVERS': 8001, + + /** + * The library is already casting. + */ + 'ALREADY_CASTING': 8002, + + /** + * A Cast SDK error that we did not explicitly plan for has occurred. + * Check data[0] and refer to the Cast SDK documentation for details. + *
error.data[0] is an error object from the Cast SDK. + */ + 'UNEXPECTED_CAST_ERROR': 8003, + + /** + * The cast operation was canceled by the user. + *
error.data[0] is an error object from the Cast SDK. + */ + 'CAST_CANCELED_BY_USER': 8004, + + /** + * The cast connection timed out. + *
error.data[0] is an error object from the Cast SDK. + */ + 'CAST_CONNECTION_TIMED_OUT': 8005, + + /** + * The requested receiver app ID does not exist or is unavailable. + * Check the requested app ID for typos. + *
error.data[0] is an error object from the Cast SDK. + */ + 'CAST_RECEIVER_APP_UNAVAILABLE': 8006, + /** * IndexedDb is not supported on this browser; it is required for offline * support. diff --git a/lib/util/fake_event_target.js b/lib/util/fake_event_target.js index b385ef969c..fc40b23698 100644 --- a/lib/util/fake_event_target.js +++ b/lib/util/fake_event_target.js @@ -38,6 +38,12 @@ shaka.util.FakeEventTarget = function() { * @private {!shaka.util.MultiMap.} */ this.listeners_ = new shaka.util.MultiMap(); + + /** + * The target of all dispatched events. Defaults to |this|. + * @type {EventTarget} + */ + this.dispatchTarget = this; }; @@ -97,8 +103,8 @@ shaka.util.FakeEventTarget.prototype.dispatchEvent = function(event) { for (var i = 0; i < list.length; ++i) { // Do this every time, since events can be re-dispatched from handlers. - event.target = this; - event.currentTarget = this; + event.target = this.dispatchTarget; + event.currentTarget = this.dispatchTarget; var listener = list[i]; try { diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index 14d04b71e4..18eb0c2c67 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -17,6 +17,8 @@ // Require all exported classes. goog.require('shaka.Player'); +goog.require('shaka.cast.CastProxy'); +goog.require('shaka.cast.CastReceiver'); goog.require('shaka.media.ManifestParser'); goog.require('shaka.media.PresentationTimeline'); goog.require('shaka.media.TextEngine'); diff --git a/test/cast/cast_proxy_unit.js b/test/cast/cast_proxy_unit.js new file mode 100644 index 0000000000..55bc7ae108 --- /dev/null +++ b/test/cast/cast_proxy_unit.js @@ -0,0 +1,709 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('CastProxy', function() { + var CastProxy; + var FakeEvent; + + var originalCastSender; + + var fakeAppId = 'fake app ID'; + var mockVideo; + var mockPlayer; + var mockSender; + var mockCastSenderConstructor; + + /** @type {shaka.cast.CastProxy} */ + var proxy; + + beforeAll(function() { + CastProxy = shaka.cast.CastProxy; + FakeEvent = shaka.util.FakeEvent; + + mockCastSenderConstructor = jasmine.createSpy('CastSender constructor'); + mockCastSenderConstructor.and.callFake(createMockCastSender); + + originalCastSender = shaka.cast.CastSender; + shaka.cast.CastSender = mockCastSenderConstructor; + }); + + afterAll(function() { + shaka.cast.CastSender = originalCastSender; + }); + + beforeEach(function() { + mockVideo = createMockVideo(); + mockPlayer = createMockPlayer(); + mockSender = null; + + proxy = new CastProxy(mockVideo, mockPlayer, fakeAppId); + }); + + afterEach(function(done) { + proxy.destroy().catch(fail).then(done); + }); + + describe('constructor', function() { + it('creates and initializes a CastSender', function() { + expect(mockCastSenderConstructor).toHaveBeenCalled(); + expect(mockSender).toBeTruthy(); + expect(mockSender.init).toHaveBeenCalled(); + }); + + it('listens for video and player events', function() { + expect(Object.keys(mockVideo.listeners).length).toBeGreaterThan(0); + expect(Object.keys(mockPlayer.listeners).length).toBeGreaterThan(0); + }); + + it('creates proxies for video and player', function() { + expect(proxy.getVideo()).toBeTruthy(); + expect(proxy.getVideo()).not.toBe(mockVideo); + expect(proxy.getPlayer()).toBeTruthy(); + expect(proxy.getPlayer()).not.toBe(mockPlayer); + }); + }); + + describe('canCast', function() { + it('is true if the API is ready and we have receivers', function() { + mockSender.apiReady.and.returnValue(false); + mockSender.hasReceivers.and.returnValue(false); + expect(proxy.canCast()).toBe(false); + + mockSender.apiReady.and.returnValue(true); + expect(proxy.canCast()).toBe(false); + + mockSender.hasReceivers.and.returnValue(true); + expect(proxy.canCast()).toBe(true); + }); + }); + + describe('isCasting', function() { + it('delegates directly to the sender', function() { + mockSender.isCasting.and.returnValue(false); + expect(proxy.isCasting()).toBe(false); + mockSender.isCasting.and.returnValue(true); + expect(proxy.isCasting()).toBe(true); + }); + }); + + describe('setAppData', function() { + it('delegates directly to the sender', function() { + var fakeAppData = {key: 'value'}; + expect(mockSender.setAppData).not.toHaveBeenCalled(); + proxy.setAppData(fakeAppData); + expect(mockSender.setAppData).toHaveBeenCalledWith(fakeAppData); + }); + }); + + describe('disconnect', function() { + it('delegates directly to the sender', function() { + expect(mockSender.disconnect).not.toHaveBeenCalled(); + proxy.disconnect(); + expect(mockSender.disconnect).toHaveBeenCalled(); + }); + }); + + describe('cast', function() { + it('pauses the local video', function() { + proxy.cast(); + expect(mockVideo.pause).toHaveBeenCalled(); + }); + + it('passes initial state to sender', function() { + mockVideo.loop = true; + mockVideo.playbackRate = 3; + mockVideo.currentTime = 12; + var fakeConfig = {key: 'value'}; + mockPlayer.getConfiguration.and.returnValue(fakeConfig); + mockPlayer.isTextTrackVisible.and.returnValue(false); + var fakeManifestUri = 'foo://bar'; + mockPlayer.getManifestUri.and.returnValue(fakeManifestUri); + + proxy.cast(); + var calls = mockSender.cast.calls; + expect(calls.count()).toBe(1); + if (calls.count()) { + var state = calls.argsFor(0)[0]; + // Video state goes directly: + expect(state.video.loop).toEqual(mockVideo.loop); + expect(state.video.playbackRate).toEqual(mockVideo.playbackRate); + // Player state uses corresponding setter names: + expect(state.player.configure).toEqual(fakeConfig); + expect(state['playerAfterLoad'].setTextTrackVisibility).toBe(false); + // Manifest URI: + expect(state.manifest).toEqual(fakeManifestUri); + // Start time: + expect(state.startTime).toEqual(mockVideo.currentTime); + } + }); + + it('does not provide a start time if the video has ended', function() { + mockVideo.ended = true; + mockVideo.currentTime = 12; + + proxy.cast(); + var calls = mockSender.cast.calls; + expect(calls.count()).toBe(1); + if (calls.count()) { + var state = calls.argsFor(0)[0]; + expect(state.startTime).toBe(null); + } + }); + + it('unloads the local player after casting is complete', function(done) { + var p = new shaka.util.PublicPromise(); + mockSender.cast.and.returnValue(p); + + proxy.cast(); + shaka.test.Util.delay(0.1).then(function() { + // unload() has not been called yet. + expect(mockPlayer.unload).not.toHaveBeenCalled(); + // Resolve the cast() promise. + p.resolve(); + return shaka.test.Util.delay(0.1); + }).then(function() { + // unload() has now been called. + expect(mockPlayer.unload).toHaveBeenCalled(); + }).catch(fail).then(done); + }); + }); + + describe('video proxy', function() { + describe('get', function() { + it('returns local values when we are playing back locally', function() { + mockVideo.currentTime = 12; + mockVideo.paused = true; + expect(proxy.getVideo().currentTime).toEqual(mockVideo.currentTime); + expect(proxy.getVideo().paused).toEqual(mockVideo.paused); + + expect(mockVideo.play).not.toHaveBeenCalled(); + proxy.getVideo().play(); + expect(mockVideo.play).toHaveBeenCalled(); + // The local method call was properly bound: + expect(mockVideo.play.calls.mostRecent().object).toBe(mockVideo); + }); + + it('returns cached remote values when we are casting', function() { + // Local values that will be ignored: + mockVideo.currentTime = 12; + mockVideo.paused = true; + + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + // Simulate remote values: + var cache = { video: { + currentTime: 24, + paused: false, + play: jasmine.createSpy('play') + }}; + mockSender.get.and.callFake(function(targetName, property) { + expect(targetName).toEqual('video'); + return cache.video[property]; + }); + + expect(proxy.getVideo().currentTime).not.toEqual(mockVideo.currentTime); + expect(proxy.getVideo().currentTime).toEqual(cache.video.currentTime); + expect(proxy.getVideo().paused).not.toEqual(mockVideo.paused); + expect(proxy.getVideo().paused).toEqual(cache.video.paused); + + // Call a method: + expect(mockVideo.play).not.toHaveBeenCalled(); + proxy.getVideo().play(); + // The call was routed to the remote video. + expect(mockVideo.play).not.toHaveBeenCalled(); + expect(cache.video.play).toHaveBeenCalled(); + }); + + it('returns local values when we have no remote values yet', function() { + mockVideo.currentTime = 12; + mockVideo.paused = true; + + // Set up the sender in casting mode, but without any remote values: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(false); + + // Simulate remote method: + var playSpy = jasmine.createSpy('play'); + mockSender.get.and.callFake(function(targetName, property) { + expect(targetName).toEqual('video'); + expect(property).toEqual('play'); + return playSpy; + }); + + // Without remote values, we should still return the local ones. + expect(proxy.getVideo().currentTime).toEqual(mockVideo.currentTime); + expect(proxy.getVideo().paused).toEqual(mockVideo.paused); + + // Call a method: + expect(mockVideo.play).not.toHaveBeenCalled(); + proxy.getVideo().play(); + // The call was still routed to the remote video. + expect(mockVideo.play).not.toHaveBeenCalled(); + expect(playSpy).toHaveBeenCalled(); + }); + }); + + describe('set', function() { + it('writes local values when we are playing back locally', function() { + mockVideo.currentTime = 12; + expect(proxy.getVideo().currentTime).toEqual(12); + + // Writes to the proxy are reflected immediately in both the proxy and + // the local video. + proxy.getVideo().currentTime = 24; + expect(proxy.getVideo().currentTime).toEqual(24); + expect(mockVideo.currentTime).toEqual(24); + }); + + it('writes values remotely when we are casting', function() { + mockVideo.currentTime = 12; + + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + // Set the value of currentTime: + expect(mockSender.set).not.toHaveBeenCalled(); + proxy.getVideo().currentTime = 24; + expect(mockSender.set).toHaveBeenCalledWith('video', 'currentTime', 24); + + // The local value was unaffected. + expect(mockVideo.currentTime).toEqual(12); + }); + }); + + describe('local events', function() { + it('forward to the proxy when we are playing back locally', function() { + var proxyListener = jasmine.createSpy('listener'); + proxy.getVideo().addEventListener('timeupdate', proxyListener); + + expect(proxyListener).not.toHaveBeenCalled(); + var fakeEvent = new FakeEvent('timeupdate', {detail: 8675309}); + mockVideo.listeners['timeupdate'](fakeEvent); + expect(proxyListener).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'timeupdate', + detail: 8675309 + })); + }); + + it('are ignored when we are casting', function() { + var proxyListener = jasmine.createSpy('listener'); + proxy.getVideo().addEventListener('timeupdate', proxyListener); + + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + expect(proxyListener).not.toHaveBeenCalled(); + var fakeEvent = new FakeEvent('timeupdate', {detail: 8675309}); + mockVideo.listeners['timeupdate'](fakeEvent); + expect(proxyListener).not.toHaveBeenCalled(); + }); + }); + + describe('remote events', function() { + it('forward to the proxy when we are casting', function() { + var proxyListener = jasmine.createSpy('listener'); + proxy.getVideo().addEventListener('timeupdate', proxyListener); + + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + expect(proxyListener).not.toHaveBeenCalled(); + var fakeEvent = new FakeEvent('timeupdate', {detail: 8675309}); + mockSender.onRemoteEvent('video', fakeEvent); + expect(proxyListener).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'timeupdate', + detail: 8675309 + })); + }); + }); + }); + + describe('player proxy', function() { + describe('get', function() { + it('returns local values when we are playing back locally', function() { + var fakeConfig = {key: 'value'}; + mockPlayer.getConfiguration.and.returnValue(fakeConfig); + expect(proxy.getPlayer().getConfiguration()).toEqual(fakeConfig); + + expect(mockPlayer.trickPlay).not.toHaveBeenCalled(); + proxy.getPlayer().trickPlay(5); + expect(mockPlayer.trickPlay).toHaveBeenCalledWith(5); + // The local method call was properly bound: + expect(mockPlayer.trickPlay.calls.mostRecent().object).toBe(mockPlayer); + }); + + it('returns cached remote values when we are casting', function() { + // Local values that will be ignored: + var fakeConfig = {key: 'value'}; + mockPlayer.getConfiguration.and.returnValue(fakeConfig); + mockPlayer.isTextTrackVisible.and.returnValue(false); + + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + // Simulate remote values: + var fakeConfig2 = {key2: 'value2'}; + var cache = { player: { + getConfiguration: fakeConfig2, + isTextTrackVisible: true, + trickPlay: jasmine.createSpy('trickPlay') + }}; + mockSender.get.and.callFake(function(targetName, property) { + expect(targetName).toEqual('player'); + var value = cache.player[property]; + // methods: + if (typeof value == 'function') return value; + // getters: + else return function() { return value; }; + }); + + expect(proxy.getPlayer().getConfiguration()).toEqual(fakeConfig2); + expect(proxy.getPlayer().isTextTrackVisible()).toBe(true); + + // Call a method: + expect(mockPlayer.trickPlay).not.toHaveBeenCalled(); + proxy.getPlayer().trickPlay(5); + // The call was routed to the remote player. + expect(mockPlayer.trickPlay).not.toHaveBeenCalled(); + expect(cache.player.trickPlay).toHaveBeenCalledWith(5); + }); + + it('returns local values when we have no remote values yet', function() { + var fakeConfig = {key: 'value'}; + mockPlayer.getConfiguration.and.returnValue(fakeConfig); + mockPlayer.isTextTrackVisible.and.returnValue(true); + + // Set up the sender in casting mode, but without any remote values: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(false); + + // Simulate remote method: + var trickPlaySpy = jasmine.createSpy('trickPlay'); + mockSender.get.and.callFake(function(targetName, property) { + expect(targetName).toEqual('player'); + expect(property).toEqual('trickPlay'); + return trickPlaySpy; + }); + + // Without remote values, we should still return the local ones. + expect(proxy.getPlayer().getConfiguration()).toEqual(fakeConfig); + expect(proxy.getPlayer().isTextTrackVisible()).toBe(true); + + // Call a method: + expect(mockPlayer.trickPlay).not.toHaveBeenCalled(); + proxy.getPlayer().trickPlay(5); + // The call was still routed to the remote player. + expect(mockPlayer.trickPlay).not.toHaveBeenCalled(); + expect(trickPlaySpy).toHaveBeenCalledWith(5); + }); + + it('always returns a local NetworkingEngine', function() { + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + expect(mockPlayer.getNetworkingEngine).not.toHaveBeenCalled(); + proxy.getPlayer().getNetworkingEngine(); + expect(mockPlayer.getNetworkingEngine).toHaveBeenCalled(); + // The local method call was properly bound: + expect(mockPlayer.getNetworkingEngine.calls.mostRecent().object).toBe( + mockPlayer); + }); + }); + + describe('local events', function() { + it('forward to the proxy when we are playing back locally', function() { + var proxyListener = jasmine.createSpy('listener'); + proxy.getPlayer().addEventListener('buffering', proxyListener); + + expect(proxyListener).not.toHaveBeenCalled(); + var fakeEvent = new FakeEvent('buffering', {detail: 8675309}); + mockPlayer.listeners['buffering'](fakeEvent); + expect(proxyListener).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'buffering', + detail: 8675309 + })); + }); + + it('are ignored when we are casting', function() { + var proxyListener = jasmine.createSpy('listener'); + proxy.getPlayer().addEventListener('buffering', proxyListener); + + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + expect(proxyListener).not.toHaveBeenCalled(); + var fakeEvent = new FakeEvent('buffering', {detail: 8675309}); + mockPlayer.listeners['buffering'](fakeEvent); + expect(proxyListener).not.toHaveBeenCalled(); + }); + }); + + describe('remote events', function() { + it('forward to the proxy when we are casting', function() { + var proxyListener = jasmine.createSpy('listener'); + proxy.getPlayer().addEventListener('buffering', proxyListener); + + // Set up the sender in casting mode: + mockSender.isCasting.and.returnValue(true); + mockSender.hasRemoteProperties.and.returnValue(true); + + expect(proxyListener).not.toHaveBeenCalled(); + var fakeEvent = new FakeEvent('buffering', {detail: 8675309}); + mockSender.onRemoteEvent('player', fakeEvent); + expect(proxyListener).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'buffering', + detail: 8675309 + })); + }); + }); + }); + + describe('"caststatuschanged" event', function() { + it('is triggered by the sender', function() { + var listener = jasmine.createSpy('listener'); + proxy.addEventListener('caststatuschanged', listener); + expect(listener).not.toHaveBeenCalled(); + mockSender.onCastStatusChanged(); + expect(listener).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'caststatuschanged' + })); + }); + }); + + describe('resume local playback', function() { + var cache; + + beforeEach(function() { + // Simulate cached remote state: + cache = { + video: { + loop: true, + playbackRate: 5 + }, + player: { + getConfiguration: {key: 'value'}, + isTextTrackVisisble: true + } + }; + mockSender.get.and.callFake(function(targetName, property) { + if (targetName == 'player') { + return function() { return cache[targetName][property]; }; + } else { + return cache[targetName][property]; + } + }); + }); + + it('transfers remote state back to local objects', function(done) { + // Nothing has been set yet: + expect(mockPlayer.configure).not.toHaveBeenCalled(); + expect(mockPlayer.setTextTrackVisibility).not.toHaveBeenCalled(); + expect(mockVideo.loop).toBe(undefined); + expect(mockVideo.playbackRate).toBe(undefined); + + // Resume local playback. + mockSender.onResumeLocal(); + + // Initial Player state first: + expect(mockPlayer.configure).toHaveBeenCalledWith( + cache.player.getConfiguration); + // Nothing else yet: + expect(mockPlayer.setTextTrackVisibility).not.toHaveBeenCalled(); + expect(mockVideo.loop).toBe(undefined); + expect(mockVideo.playbackRate).toBe(undefined); + + // The rest is done async: + shaka.test.Util.delay(0.1).then(function() { + expect(mockPlayer.setTextTrackVisibility).toHaveBeenCalledWith( + cache.player.isTextTrackVisible); + expect(mockVideo.loop).toEqual(cache.video.loop); + expect(mockVideo.playbackRate).toEqual(cache.video.playbackRate); + }).catch(fail).then(done); + }); + + it('loads the manifest', function() { + cache.video.currentTime = 12; + cache.player.getManifestUri = 'foo://bar'; + expect(mockPlayer.load).not.toHaveBeenCalled(); + + mockSender.onResumeLocal(); + + expect(mockPlayer.load).toHaveBeenCalledWith('foo://bar', 12); + }); + + it('does not provide a start time if the video has ended', function() { + cache.video.currentTime = 12; + cache.video.ended = true; + cache.player.getManifestUri = 'foo://bar'; + expect(mockPlayer.load).not.toHaveBeenCalled(); + + mockSender.onResumeLocal(); + + expect(mockPlayer.load).toHaveBeenCalledWith('foo://bar', null); + }); + + it('plays the video after loading', function(done) { + cache.player.getManifestUri = 'foo://bar'; + // Should play even if the video was paused remotely. + cache.video.paused = true; + // Autoplay has not been touched on the video yet. + expect(mockVideo.autoplay).toBe(undefined); + + mockSender.onResumeLocal(); + + // Video autoplay inhibited: + expect(mockVideo.autoplay).toBe(false); + shaka.test.Util.delay(0.1).then(function() { + expect(mockVideo.play).toHaveBeenCalled(); + // Video autoplay restored: + expect(mockVideo.autoplay).toBe(undefined); + }).catch(fail).then(done); + }); + + it('does not load or play without a manifest URI', function(done) { + cache.player.getManifestUri = null; + + mockSender.onResumeLocal(); + + shaka.test.Util.delay(0.1).then(function() { + // Nothing loaded or played: + expect(mockPlayer.load).not.toHaveBeenCalled(); + expect(mockVideo.play).not.toHaveBeenCalled(); + + // State was still transferred, though: + expect(mockPlayer.setTextTrackVisibility).toHaveBeenCalledWith( + cache.player.isTextTrackVisible); + expect(mockVideo.loop).toEqual(cache.video.loop); + expect(mockVideo.playbackRate).toEqual(cache.video.playbackRate); + }).catch(fail).then(done); + }); + + it('triggers an "error" event if load fails', function(done) { + cache.player.getManifestUri = 'foo://bar'; + var fakeError = new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.UNABLE_TO_GUESS_MANIFEST_TYPE); + mockPlayer.load.and.returnValue(Promise.reject(fakeError)); + + mockSender.onResumeLocal(); + + shaka.test.Util.delay(0.1).then(function() { + expect(mockPlayer.load).toHaveBeenCalled(); + expect(mockPlayer.dispatchEvent).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'error', detail: fakeError })); + }).catch(fail).then(done); + }); + }); + + describe('destroy', function() { + it('destroys the local player and the sender', function(done) { + expect(mockPlayer.destroy).not.toHaveBeenCalled(); + expect(mockSender.destroy).not.toHaveBeenCalled(); + + proxy.destroy().catch(fail).then(done); + + expect(mockPlayer.destroy).toHaveBeenCalled(); + expect(mockSender.destroy).toHaveBeenCalled(); + }); + }); + + /** + * @param {string} appId + * @param {Function} onCastStatusChanged + * @param {Function} onRemoteEvent + * @param {Function} onResumeLocal + */ + function createMockCastSender( + appId, onCastStatusChanged, onRemoteEvent, onResumeLocal) { + expect(appId).toEqual(fakeAppId); + + mockSender = { + init: jasmine.createSpy('init'), + destroy: jasmine.createSpy('destroy'), + apiReady: jasmine.createSpy('apiReady'), + hasReceivers: jasmine.createSpy('hasReceivers'), + isCasting: jasmine.createSpy('isCasting'), + hasRemoteProperties: jasmine.createSpy('hasRemoteProperties'), + setAppData: jasmine.createSpy('setAppData'), + disconnect: jasmine.createSpy('disconnect'), + cast: jasmine.createSpy('cast'), + get: jasmine.createSpy('get'), + set: jasmine.createSpy('set'), + // For convenience: + onCastStatusChanged: onCastStatusChanged, + onRemoteEvent: onRemoteEvent, + onResumeLocal: onResumeLocal + }; + mockSender.cast.and.returnValue(Promise.resolve()); + return mockSender; + } + + // TODO: consolidate with simple_fakes.js + function createMockVideo() { + var video = { + currentTime: undefined, + ended: undefined, + paused: undefined, + play: jasmine.createSpy('play'), + pause: jasmine.createSpy('pause'), + addEventListener: function(eventName, listener) { + video.listeners[eventName] = listener; + }, + removeEventListener: function(eventName, listener) { + delete video.listeners[eventName]; + }, + // For convenience: + listeners: {} + }; + return video; + } + + function createMockPlayer() { + var player = { + load: jasmine.createSpy('load'), + unload: jasmine.createSpy('unload'), + getNetworkingEngine: jasmine.createSpy('getNetworkingEngine'), + getManifestUri: jasmine.createSpy('getManifestUri'), + getConfiguration: jasmine.createSpy('getConfiguration'), + configure: jasmine.createSpy('configure'), + isTextTrackVisible: jasmine.createSpy('isTextTrackVisible'), + setTextTrackVisibility: jasmine.createSpy('setTextTrackVisibility'), + trickPlay: jasmine.createSpy('trickPlay'), + destroy: jasmine.createSpy('destroy'), + addEventListener: function(eventName, listener) { + player.listeners[eventName] = listener; + }, + removeEventListener: function(eventName, listener) { + delete player.listeners[eventName]; + }, + dispatchEvent: jasmine.createSpy('dispatchEvent'), + // For convenience: + listeners: {} + }; + player.load.and.returnValue(Promise.resolve()); + player.unload.and.returnValue(Promise.resolve()); + return player; + } +}); diff --git a/test/cast/cast_receiver_unit.js b/test/cast/cast_receiver_unit.js new file mode 100644 index 0000000000..9a7b526cc8 --- /dev/null +++ b/test/cast/cast_receiver_unit.js @@ -0,0 +1,634 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('CastReceiver', function() { + var CastReceiver; + var CastUtils; + + var originalCast; + var originalUserAgent; + + var mockReceiverManager; + var mockVideo; + var mockPlayer; + var mockAppDataCallback; + + var mockReceiverApi; + var mockMessageBus; + + /** @type {shaka.cast.CastReceiver} */ + var receiver; + + var isChrome; + var isChromecast; + + function checkChromeOrChromecast() { + if (!isChromecast && !isChrome) { + pending('Skipping CastReceiver tests for non-Chrome and non-Chromecast'); + } + } + + beforeAll(function() { + // The receiver is only meant to run on the Chromecast, so we have the + // ability to use modern APIs there that may not be available on all of the + // browsers our library supports. Because of this, CastReceiver tests will + // only be run on Chrome and Chromecast. + isChromecast = navigator.userAgent.indexOf('CrKey') >= 0; + var isEdge = navigator.userAgent.indexOf('Edge/') >= 0; + // Edge also has "Chrome/" in its user agent string. + isChrome = navigator.userAgent.indexOf('Chrome/') >= 0 && !isEdge; + + CastReceiver = shaka.cast.CastReceiver; + CastUtils = shaka.cast.CastUtils; + + originalCast = window.cast; + originalUserAgent = navigator.userAgent; + + // In uncompiled mode, there is a UA check for Chromecast in order to make + // manual testing easier. For these automated tests, we want to act as if + // we are running on the Chromecast, even in Chrome. + // Since we can't write to window.navigator or navigator.userAgent, we use + // Object.defineProperty. + Object.defineProperty(window['navigator'], + 'userAgent', {value: 'CrKey'}); + }); + + afterAll(function() { + if (originalUserAgent) { + window.cast = originalCast; + Object.defineProperty(window['navigator'], + 'userAgent', {value: originalUserAgent}); + } + }); + + beforeEach(function() { + mockReceiverApi = createMockReceiverApi(); + window.cast = { receiver: mockReceiverApi }; + + mockReceiverManager = createMockReceiverManager(); + mockMessageBus = createMockMessageBus(); + mockVideo = createMockVideo(); + mockPlayer = createMockPlayer(); + mockAppDataCallback = jasmine.createSpy('appDataCallback'); + + receiver = new CastReceiver(mockVideo, mockPlayer, mockAppDataCallback); + }); + + afterEach(function(done) { + receiver.destroy().catch(fail).then(done); + }); + + describe('constructor', function() { + it('starts the receiver manager', function() { + checkChromeOrChromecast(); + expect(mockReceiverManager.start).toHaveBeenCalled(); + }); + + it('listens for video and player events', function() { + checkChromeOrChromecast(); + expect(Object.keys(mockVideo.listeners).length).toBeGreaterThan(0); + expect(Object.keys(mockPlayer.listeners).length).toBeGreaterThan(0); + }); + + it('starts polling', function(done) { + checkChromeOrChromecast(); + var fakeConfig = {key: 'value'}; + mockPlayer.getConfiguration.and.returnValue(fakeConfig); + + fakeConnectedSenders(1); + + mockPlayer.getConfiguration.calls.reset(); + shaka.test.Util.delay(1).then(function() { + expect(mockPlayer.getConfiguration).toHaveBeenCalled(); + expect(mockMessageBus.messages).toContain(jasmine.objectContaining({ + type: 'update', + update: jasmine.objectContaining({ + player: jasmine.objectContaining({ + getConfiguration: fakeConfig + }) + }) + })); + }).catch(fail).then(done); + }); + }); + + describe('isConnected', function() { + it('is true when there are senders', function() { + checkChromeOrChromecast(); + expect(receiver.isConnected()).toBe(false); + fakeConnectedSenders(1); + expect(receiver.isConnected()).toBe(true); + fakeConnectedSenders(2); + expect(receiver.isConnected()).toBe(true); + fakeConnectedSenders(99); + expect(receiver.isConnected()).toBe(true); + fakeConnectedSenders(0); + expect(receiver.isConnected()).toBe(false); + }); + }); + + describe('"caststatuschanged" event', function() { + it('is triggered when senders connect or disconnect', function() { + checkChromeOrChromecast(); + fakeConnectedSenders(0); + + var listener = jasmine.createSpy('listener'); + receiver.addEventListener('caststatuschanged', listener); + expect(listener).not.toHaveBeenCalled(); + + mockReceiverManager.onSenderConnected(); + expect(listener).toHaveBeenCalled(); + + listener.calls.reset(); + mockReceiverManager.onSenderDisconnected(); + expect(listener).toHaveBeenCalled(); + }); + }); + + describe('local events', function() { + it('trigger "update" and "event" messages', function() { + checkChromeOrChromecast(); + fakeConnectedSenders(1); + + // No messages yet. + expect(mockMessageBus.messages).toEqual([]); + var fakeEvent = {type: 'timeupdate'}; + mockVideo.listeners['timeupdate'](fakeEvent); + + // There are now "update" and "event" messages, in that order. + expect(mockMessageBus.messages).toEqual([ + { + type: 'update', + update: jasmine.any(Object) + }, + { + type: 'event', + targetName: 'video', + event: jasmine.objectContaining(fakeEvent) + } + ]); + }); + }); + + describe('"init" message', function() { + var fakeInitState; + var fakeConfig = {key: 'value'}; + var fakeAppData = {myFakeAppData: 1234}; + + beforeEach(function() { + fakeInitState = { + player: { + configure: fakeConfig + }, + 'playerAfterLoad': { + setTextTrackVisibility: true + }, + video: { + loop: true, + playbackRate: 5 + } + }; + }); + + it('sets initial state', function(done) { + checkChromeOrChromecast(); + expect(mockVideo.loop).toBe(undefined); + expect(mockVideo.playbackRate).toBe(undefined); + expect(mockPlayer.configure).not.toHaveBeenCalled(); + + fakeIncomingMessage({ + type: 'init', + initState: fakeInitState, + appData: fakeAppData + }); + + // Initial Player state first: + expect(mockPlayer.configure).toHaveBeenCalledWith(fakeConfig); + // App data next: + expect(mockAppDataCallback).toHaveBeenCalledWith(fakeAppData); + // Nothing else yet: + expect(mockPlayer.setTextTrackVisibility).not.toHaveBeenCalled(); + expect(mockVideo.loop).toBe(undefined); + expect(mockVideo.playbackRate).toBe(undefined); + + // The rest is done async: + shaka.test.Util.delay(0.1).then(function() { + expect(mockPlayer.setTextTrackVisibility).toHaveBeenCalledWith( + fakeInitState['playerAfterLoad'].setTextTrackVisibility); + expect(mockVideo.loop).toEqual(fakeInitState.video.loop); + expect(mockVideo.playbackRate).toEqual( + fakeInitState.video.playbackRate); + }).catch(fail).then(done); + }); + + it('loads the manifest', function() { + checkChromeOrChromecast(); + fakeInitState.startTime = 12; + fakeInitState.manifest = 'foo://bar'; + expect(mockPlayer.load).not.toHaveBeenCalled(); + + fakeIncomingMessage({ + type: 'init', + initState: fakeInitState, + appData: fakeAppData + }); + + expect(mockPlayer.load).toHaveBeenCalledWith('foo://bar', 12); + }); + + it('plays the video after loading', function(done) { + checkChromeOrChromecast(); + fakeInitState.manifest = 'foo://bar'; + // Autoplay has not been touched on the video yet. + expect(mockVideo.autoplay).toBe(undefined); + + fakeIncomingMessage({ + type: 'init', + initState: fakeInitState, + appData: fakeAppData + }); + + // Video autoplay inhibited: + expect(mockVideo.autoplay).toBe(false); + shaka.test.Util.delay(0.1).then(function() { + expect(mockVideo.play).toHaveBeenCalled(); + // Video autoplay restored: + expect(mockVideo.autoplay).toBe(undefined); + }).catch(fail).then(done); + }); + + it('does not load or play without a manifest URI', function(done) { + checkChromeOrChromecast(); + fakeInitState.manifest = null; + + fakeIncomingMessage({ + type: 'init', + initState: fakeInitState, + appData: fakeAppData + }); + + shaka.test.Util.delay(0.1).then(function() { + // Nothing loaded or played: + expect(mockPlayer.load).not.toHaveBeenCalled(); + expect(mockVideo.play).not.toHaveBeenCalled(); + + // State was still transferred, though: + expect(mockPlayer.setTextTrackVisibility).toHaveBeenCalledWith( + fakeInitState['playerAfterLoad'].setTextTrackVisibility); + expect(mockVideo.loop).toEqual(fakeInitState.video.loop); + expect(mockVideo.playbackRate).toEqual( + fakeInitState.video.playbackRate); + }).catch(fail).then(done); + }); + + it('triggers an "error" event if load fails', function(done) { + checkChromeOrChromecast(); + fakeInitState.manifest = 'foo://bar'; + var fakeError = new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.UNABLE_TO_GUESS_MANIFEST_TYPE); + mockPlayer.load.and.returnValue(Promise.reject(fakeError)); + + var listener = jasmine.createSpy('listener'); + mockPlayer.addEventListener('error', listener); + expect(listener).not.toHaveBeenCalled(); + + fakeIncomingMessage({ + type: 'init', + initState: fakeInitState, + appData: fakeAppData + }); + + shaka.test.Util.delay(0.1).then(function() { + expect(mockPlayer.load).toHaveBeenCalled(); + expect(mockPlayer.dispatchEvent).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'error', detail: fakeError })); + }).catch(fail).then(done); + }); + }); + + describe('"appData" message', function() { + it('triggers the app data callback', function() { + checkChromeOrChromecast(); + expect(mockAppDataCallback).not.toHaveBeenCalled(); + + var fakeAppData = {myFakeAppData: 1234}; + fakeIncomingMessage({ + type: 'appData', + appData: fakeAppData + }); + + expect(mockAppDataCallback).toHaveBeenCalledWith(fakeAppData); + }); + }); + + describe('"set" message', function() { + it('sets local properties', function() { + checkChromeOrChromecast(); + expect(mockVideo.currentTime).toBe(undefined); + fakeIncomingMessage({ + type: 'set', + targetName: 'video', + property: 'currentTime', + value: 12 + }); + expect(mockVideo.currentTime).toEqual(12); + + expect(mockPlayer['arbitraryName']).toBe(undefined); + fakeIncomingMessage({ + type: 'set', + targetName: 'player', + property: 'arbitraryName', + value: 'arbitraryValue' + }); + expect(mockPlayer['arbitraryName']).toEqual('arbitraryValue'); + }); + + it('routes volume properties to the receiver manager', function() { + checkChromeOrChromecast(); + expect(mockVideo.volume).toBe(undefined); + expect(mockVideo.muted).toBe(undefined); + expect(mockReceiverManager.setSystemVolumeLevel).not.toHaveBeenCalled(); + expect(mockReceiverManager.setSystemVolumeMuted).not.toHaveBeenCalled(); + + fakeIncomingMessage({ + type: 'set', + targetName: 'video', + property: 'volume', + value: 0.5 + }); + fakeIncomingMessage({ + type: 'set', + targetName: 'video', + property: 'muted', + value: true + }); + + expect(mockVideo.volume).toBe(undefined); + expect(mockVideo.muted).toBe(undefined); + expect(mockReceiverManager.setSystemVolumeLevel). + toHaveBeenCalledWith(0.5); + expect(mockReceiverManager.setSystemVolumeMuted). + toHaveBeenCalledWith(true); + }); + }); + + describe('"call" message', function() { + it('calls local methods', function() { + checkChromeOrChromecast(); + expect(mockVideo.play).not.toHaveBeenCalled(); + fakeIncomingMessage({ + type: 'call', + targetName: 'video', + methodName: 'play', + args: [1, 2, 3] + }); + expect(mockVideo.play).toHaveBeenCalledWith(1, 2, 3); + + expect(mockPlayer.configure).not.toHaveBeenCalled(); + fakeIncomingMessage({ + type: 'call', + targetName: 'player', + methodName: 'configure', + args: [42] + }); + expect(mockPlayer.configure).toHaveBeenCalledWith(42); + }); + }); + + describe('"asyncCall" message', function() { + var p; + var fakeSenderId = 'senderId'; + var fakeCallId = '5'; + + beforeEach(function() { + fakeConnectedSenders(1); + p = new shaka.util.PublicPromise(); + mockPlayer.load.and.returnValue(p); + + expect(mockPlayer.load).not.toHaveBeenCalled(); + fakeIncomingMessage({ + type: 'asyncCall', + id: fakeCallId, + targetName: 'player', + methodName: 'load', + args: ['foo://bar', 12] + }, fakeSenderId); + }); + + it('calls local async methods', function() { + checkChromeOrChromecast(); + expect(mockPlayer.load).toHaveBeenCalledWith('foo://bar', 12); + p.resolve(); + }); + + it('sends "asyncComplete" replies when resolved', function(done) { + checkChromeOrChromecast(); + // No messages have been sent, either broadcast or privately. + expect(mockMessageBus.broadcast).not.toHaveBeenCalled(); + expect(mockMessageBus.getCastChannel).not.toHaveBeenCalled(); + + p.resolve(); + shaka.test.Util.delay(0.1).then(function() { + // No broadcast messages have been sent, but a private message has + // been sent to the sender who started the async call. + expect(mockMessageBus.broadcast).not.toHaveBeenCalled(); + expect(mockMessageBus.getCastChannel).toHaveBeenCalledWith( + fakeSenderId); + var senderChannel = mockMessageBus.getCastChannel(); + expect(senderChannel.messages).toEqual([{ + type: 'asyncComplete', + id: fakeCallId, + error: null + }]); + }).catch(fail).then(done); + }); + + it('sends "asyncComplete" replies when rejected', function(done) { + checkChromeOrChromecast(); + // No messages have been sent, either broadcast or privately. + expect(mockMessageBus.broadcast).not.toHaveBeenCalled(); + expect(mockMessageBus.getCastChannel).not.toHaveBeenCalled(); + + var fakeError = new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.UNABLE_TO_GUESS_MANIFEST_TYPE); + p.reject(fakeError); + shaka.test.Util.delay(0.1).then(function() { + // No broadcast messages have been sent, but a private message has + // been sent to the sender who started the async call. + expect(mockMessageBus.broadcast).not.toHaveBeenCalled(); + expect(mockMessageBus.getCastChannel).toHaveBeenCalledWith( + fakeSenderId); + var senderChannel = mockMessageBus.getCastChannel(); + expect(senderChannel.messages).toEqual([{ + type: 'asyncComplete', + id: fakeCallId, + error: jasmine.any(Object) + }]); + if (senderChannel.messages.length) { + var error = senderChannel.messages[0].error; + shaka.test.Util.expectToEqualError(fakeError, error); + } + }).catch(fail).then(done); + }); + }); + + describe('destroy', function() { + it('destroys the local player', function(done) { + checkChromeOrChromecast(); + expect(mockPlayer.destroy).not.toHaveBeenCalled(); + receiver.destroy().then(function() { + expect(mockPlayer.destroy).toHaveBeenCalled(); + }).catch(fail).then(done); + }); + + it('stops polling', function(done) { + checkChromeOrChromecast(); + mockPlayer.getConfiguration.calls.reset(); + shaka.test.Util.delay(1).then(function() { + // We have polled at least once, so this getter has been called. + expect(mockPlayer.getConfiguration).toHaveBeenCalled(); + mockPlayer.getConfiguration.calls.reset(); + // Destroy the receiver. + return receiver.destroy(); + }).then(function() { + // Wait another second. + return shaka.test.Util.delay(1); + }).then(function() { + // We have not polled again since destruction. + expect(mockPlayer.getConfiguration).not.toHaveBeenCalled(); + }).catch(fail).then(done); + }); + + it('stops the receiver manager', function(done) { + checkChromeOrChromecast(); + expect(mockReceiverManager.stop).not.toHaveBeenCalled(); + receiver.destroy().then(function() { + expect(mockReceiverManager.stop).toHaveBeenCalled(); + }).catch(fail).then(done); + }); + }); + + function createMockReceiverApi() { + return { + CastReceiverManager: { + getInstance: function() { return mockReceiverManager; } + } + }; + } + + function createMockReceiverManager() { + return { + start: jasmine.createSpy('CastReceiverManager.start'), + stop: jasmine.createSpy('CastReceiverManager.stop'), + setSystemVolumeLevel: + jasmine.createSpy('CastReceiverManager.setSystemVolumeLevel'), + setSystemVolumeMuted: + jasmine.createSpy('CastReceiverManager.setSystemVolumeMuted'), + getSenders: jasmine.createSpy('CastReceiverManager.getSenders'), + getSystemVolume: function() { return { level: 1, muted: false }; }, + getCastMessageBus: function() { return mockMessageBus; } + }; + }; + + function createMockMessageBus() { + var bus = { + messages: [], + broadcast: jasmine.createSpy('CastMessageBus.broadcast'), + getCastChannel: jasmine.createSpy('CastMessageBus.getCastChannel') + }; + // For convenience, deserialize and store sent messages. + bus.broadcast.and.callFake(function(message) { + bus.messages.push(CastUtils.deserialize(message)); + }); + var channel = { + messages: [], + send: function(message) { + channel.messages.push(CastUtils.deserialize(message)); + } + }; + bus.getCastChannel.and.returnValue(channel); + return bus; + } + + function createMockVideo() { + var video = { + play: jasmine.createSpy('play'), + pause: jasmine.createSpy('pause'), + addEventListener: function(eventName, listener) { + video.listeners[eventName] = listener; + }, + // For convenience: + listeners: {} + }; + return video; + } + + function createMockPlayer() { + var player = { + getConfiguration: jasmine.createSpy('getConfiguration'), + getManifestUri: jasmine.createSpy('getManifestUri'), + getPlaybackRate: jasmine.createSpy('getPlaybackRate'), + getTracks: jasmine.createSpy('getTracks'), + getStats: jasmine.createSpy('getStats'), + isBuffering: jasmine.createSpy('isBuffering'), + isLive: jasmine.createSpy('isLive'), + isTextTrackVisible: jasmine.createSpy('isTextTrackVisible'), + seekRange: jasmine.createSpy('seekRange'), + configure: jasmine.createSpy('configure'), + setTextTrackVisibility: jasmine.createSpy('setTextTrackVisibility'), + load: jasmine.createSpy('load'), + destroy: jasmine.createSpy('destroy'), + addEventListener: function(eventName, listener) { + player.listeners[eventName] = listener; + }, + dispatchEvent: jasmine.createSpy('dispatchEvent'), + // For convenience: + listeners: {} + }; + player.destroy.and.returnValue(Promise.resolve()); + player.load.and.returnValue(Promise.resolve()); + return player; + } + + /** + * @param {number} num + */ + function fakeConnectedSenders(num) { + var senderArray = []; + while (num--) { + senderArray.push('senderId'); + } + + mockReceiverManager.getSenders.and.returnValue(senderArray); + mockReceiverManager.onSenderConnected(); + } + + /** + * @param {?} message + * @param {string=} opt_senderId + */ + function fakeIncomingMessage(message, opt_senderId) { + var serialized = CastUtils.serialize(message); + var messageEvent = { + senderId: opt_senderId, + data: serialized + }; + mockMessageBus.onMessage(messageEvent); + } +}); diff --git a/test/cast/cast_sender_unit.js b/test/cast/cast_sender_unit.js new file mode 100644 index 0000000000..7c1c351866 --- /dev/null +++ b/test/cast/cast_sender_unit.js @@ -0,0 +1,703 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('CastSender', function() { + var CastSender; + var CastUtils; + + var originalChrome; + + var fakeAppId = 'asdf'; + var fakeInitState = { + manifest: null, + player: null, + startTime: null, + video: null + }; + + var onStatusChanged; + var onRemoteEvent; + var onResumeLocal; + + var mockCastApi; + var mockSession; + + /** @type {shaka.cast.CastSender} */ + var sender; + + beforeAll(function() { + CastSender = shaka.cast.CastSender; + CastUtils = shaka.cast.CastUtils; + + originalChrome = window.chrome; + }); + + afterAll(function() { + window.chrome = originalChrome; + }); + + beforeEach(function() { + onStatusChanged = jasmine.createSpy('onStatusChanged'); + onRemoteEvent = jasmine.createSpy('onRemoteEvent'); + onResumeLocal = jasmine.createSpy('onResumeLocal'); + + mockCastApi = createMockCastApi(); + window.chrome = { cast: mockCastApi }; + mockSession = null; + + sender = new CastSender(fakeAppId, onStatusChanged, onRemoteEvent, + onResumeLocal); + }); + + afterEach(function(done) { + delete window.__onGCastApiAvailable; + sender.destroy().catch(fail).then(done); + }); + + describe('init', function() { + it('installs a callback if the cast API is not available', function() { + // Remove the mock cast API. + delete window.chrome.cast; + // This shouldn't exist yet. + expect(window.__onGCastApiAvailable).toBe(undefined); + + // Init and expect the callback to be installed. + sender.init(); + expect(window.__onGCastApiAvailable).not.toBe(undefined); + expect(sender.apiReady()).toBe(false); + expect(onStatusChanged).not.toHaveBeenCalled(); + + // Restore the mock cast API. + window.chrome.cast = mockCastApi; + window.__onGCastApiAvailable(true); + // Expect the API to be ready and initialized. + expect(sender.apiReady()).toBe(true); + expect(sender.hasReceivers()).toBe(false); + expect(onStatusChanged).toHaveBeenCalled(); + expect(mockCastApi.SessionRequest).toHaveBeenCalledWith(fakeAppId); + expect(mockCastApi.initialize).toHaveBeenCalled(); + }); + + it('sets up cast API right away if it is available', function() { + sender.init(); + // Expect the API to be ready and initialized. + expect(sender.apiReady()).toBe(true); + expect(sender.hasReceivers()).toBe(false); + expect(onStatusChanged).toHaveBeenCalled(); + expect(mockCastApi.SessionRequest).toHaveBeenCalledWith(fakeAppId); + expect(mockCastApi.initialize).toHaveBeenCalled(); + }); + }); + + describe('hasReceivers', function() { + it('reflects the most recent receiver status', function() { + sender.init(); + expect(sender.hasReceivers()).toBe(false); + + fakeReceiverAvailability(true); + expect(sender.hasReceivers()).toBe(true); + + fakeReceiverAvailability(false); + expect(sender.hasReceivers()).toBe(false); + }); + }); + + describe('cast', function() { + it('fails when the cast API is not ready', function(done) { + mockCastApi.isAvailable = false; + sender.init(); + expect(sender.apiReady()).toBe(false); + sender.cast(fakeInitState).then(fail).catch(function(error) { + expect(error.category).toBe(shaka.util.Error.Category.CAST); + expect(error.code).toBe(shaka.util.Error.Code.CAST_API_UNAVAILABLE); + }).then(done); + }); + + it('fails when there are no receivers', function(done) { + sender.init(); + expect(sender.apiReady()).toBe(true); + expect(sender.hasReceivers()).toBe(false); + sender.cast(fakeInitState).then(fail).catch(function(error) { + expect(error.category).toBe(shaka.util.Error.Category.CAST); + expect(error.code).toBe(shaka.util.Error.Code.NO_CAST_RECEIVERS); + }).then(done); + }); + + it('creates a session and sends an "init" message', function(done) { + sender.init(); + expect(sender.apiReady()).toBe(true); + fakeReceiverAvailability(true); + expect(sender.hasReceivers()).toBe(true); + + var p = sender.cast(fakeInitState); + fakeSessionConnection(); + + p.then(function() { + expect(onStatusChanged).toHaveBeenCalled(); + expect(sender.isCasting()).toBe(true); + expect(mockSession.messages).toContain(jasmine.objectContaining({ + type: 'init', + initState: fakeInitState + })); + }).catch(fail).then(done); + }); + + // The library is not loaded yet during describe(), so we can't refer to + // Shaka error codes by name here. Instead, we use the numeric value and + // put the name in a comment. + var connectionFailures = [ + { + condition: 'canceled by the user', + castErrorCode: 'cancel', + shakaErrorCode: 8004 // Code.CAST_CANCELED_BY_USER + }, + { + condition: 'the connection times out', + castErrorCode: 'timeout', + shakaErrorCode: 8005 // Code.CAST_CONNECTION_TIMED_OUT + }, + { + condition: 'the receiver is unavailable', + castErrorCode: 'receiver_unavailable', + shakaErrorCode: 8006 // Code.CAST_RECEIVER_APP_UNAVAILABLE + }, + { + condition: 'an unexpected error occurs', + castErrorCode: 'anything else', + shakaErrorCode: 8003 // Code.UNEXPECTED_CAST_ERROR + } + ]; + + connectionFailures.forEach(function(metadata) { + it('fails when ' + metadata.condition, function(done) { + sender.init(); + fakeReceiverAvailability(true); + + var p = sender.cast(fakeInitState); + fakeSessionConnectionFailure(metadata.castErrorCode); + + p.then(fail).catch(function(error) { + expect(error.category).toBe(shaka.util.Error.Category.CAST); + expect(error.code).toBe(metadata.shakaErrorCode); + }).then(done); + }); + }); + + it('fails when we are already casting', function(done) { + sender.init(); + fakeReceiverAvailability(true); + + var p = sender.cast(fakeInitState); + fakeSessionConnection(); + + p.catch(fail).then(function() { + return sender.cast(fakeInitState); + }).then(fail).catch(function(error) { + expect(error.category).toBe(shaka.util.Error.Category.CAST); + expect(error.code).toBe(shaka.util.Error.Code.ALREADY_CASTING); + }).then(done); + }); + }); + + it('joins existing sessions automatically', function(done) { + sender.init(); + fakeReceiverAvailability(true); + fakeJoinExistingSession(); + + shaka.test.Util.delay(0.1).then(function() { + expect(onStatusChanged).toHaveBeenCalled(); + expect(sender.isCasting()).toBe(true); + }).catch(fail).then(done); + }); + + describe('setAppData', function() { + var fakeAppData = { + myKey1: 'myValue1', + myKey2: 'myValue2' + }; + + it('sets "appData" for "init" message if not casting', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.setAppData(fakeAppData); + sender.cast(fakeInitState).then(function() { + expect(mockSession.messages).toContain(jasmine.objectContaining({ + type: 'init', + appData: fakeAppData + })); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + + it('sends a special "appData" message if casting', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + // init message has no appData + expect(mockSession.messages).toContain(jasmine.objectContaining({ + type: 'init', + appData: null + })); + // no appData message yet + expect(mockSession.messages).not.toContain(jasmine.objectContaining({ + type: 'appData' + })); + + sender.setAppData(fakeAppData); + // now there is an appData message + expect(mockSession.messages).toContain(jasmine.objectContaining({ + type: 'appData', + appData: fakeAppData + })); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + }); + + describe('onRemoteEvent', function() { + it('is triggered by an "event" message', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + var fakeEvent = { + type: 'eventName', + detail: {key1: 'value1'} + }; + fakeSessionMessage({ + type: 'event', + targetName: 'video', + event: fakeEvent + }); + + expect(onRemoteEvent).toHaveBeenCalledWith( + 'video', jasmine.objectContaining(fakeEvent)); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + }); + + describe('onResumeLocal', function() { + it('is triggered when casting ends', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + expect(sender.isCasting()).toBe(true); + expect(onResumeLocal).not.toHaveBeenCalled(); + + fakeRemoteDisconnect(); + expect(sender.isCasting()).toBe(false); + expect(onResumeLocal).toHaveBeenCalled(); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + }); + + describe('disconnect', function() { + it('stops the session if we are casting', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + expect(sender.isCasting()).toBe(true); + expect(mockSession.stop).not.toHaveBeenCalled(); + + sender.disconnect(); + expect(mockSession.stop).toHaveBeenCalled(); + fakeRemoteDisconnect(); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + }); + + describe('get', function() { + it('returns most recent properties from "update" messages', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + var update = { + video: { + currentTime: 12, + paused: false + }, + player: { + isBuffering: true, + seekRange: {start: 5, end: 17} + } + }; + fakeSessionMessage({ + type: 'update', + update: update + }); + + // These are properties: + expect(sender.get('video', 'currentTime')).toBe( + update.video.currentTime); + expect(sender.get('video', 'paused')).toBe( + update.video.paused); + + // These are getter methods: + expect(sender.get('player', 'isBuffering')()).toBe( + update.player.isBuffering); + expect(sender.get('player', 'seekRange')()).toEqual( + update.player.seekRange); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + + it('returns functions for video and player methods', function() { + sender.init(); + expect(sender.get('video', 'play')).toEqual(jasmine.any(Function)); + expect(sender.get('player', 'isLive')).toEqual(jasmine.any(Function)); + expect(sender.get('player', 'configure')).toEqual(jasmine.any(Function)); + expect(sender.get('player', 'load')).toEqual(jasmine.any(Function)); + }); + + it('simple methods trigger "call" messages', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + var method = sender.get('video', 'play'); + var retval = method(123, 'abc'); + expect(retval).toBe(undefined); + + expect(mockSession.messages).toContain(jasmine.objectContaining({ + type: 'call', + targetName: 'video', + methodName: 'play', + args: [123, 'abc'] + })); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + + describe('async player methods', function() { + var method; + + beforeEach(function(done) { + method = null; + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + method = sender.get('player', 'load'); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + + it('return Promises', function() { + var p = method(); + expect(p).toEqual(jasmine.any(Promise)); + p.catch(function() {}); // silence logs about uncaught rejections + }); + + it('trigger "asyncCall" messages', function() { + var p = method(123, 'abc'); + p.catch(function() {}); // silence logs about uncaught rejections + + expect(mockSession.messages).toContain(jasmine.objectContaining({ + type: 'asyncCall', + targetName: 'player', + methodName: 'load', + args: [123, 'abc'], + id: jasmine.any(String) + })); + }); + + it('resolve when "asyncComplete" messages are received', function(done) { + var p = method(123, 'abc'); + shaka.test.Util.capturePromiseStatus(p); + + // Wait a tick for the Promise status to be set. + shaka.test.Util.delay(0.1).then(function() { + expect(p.status).toBe('pending'); + var id = mockSession.messages[mockSession.messages.length - 1].id; + fakeSessionMessage({ + type: 'asyncComplete', + id: id, + error: null + }); + + // Wait a tick for the Promise status to change. + return shaka.test.Util.delay(0.1); + }).then(function() { + expect(p.status).toBe('resolved'); + }).catch(fail).then(done); + }); + + it('reject when "asyncComplete" messages have an error', function(done) { + var originalError = new shaka.util.Error( + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.UNABLE_TO_GUESS_MANIFEST_TYPE, + 'foo://bar'); + var p = method(123, 'abc'); + shaka.test.Util.capturePromiseStatus(p); + + // Wait a tick for the Promise status to be set. + shaka.test.Util.delay(0.1).then(function() { + expect(p.status).toBe('pending'); + var id = mockSession.messages[mockSession.messages.length - 1].id; + fakeSessionMessage({ + type: 'asyncComplete', + id: id, + error: originalError + }); + + // Wait a tick for the Promise status to change. + return shaka.test.Util.delay(0.1); + }).then(function() { + expect(p.status).toBe('rejected'); + return p.catch(function(error) { + shaka.test.Util.expectToEqualError(error, originalError); + }); + }).catch(fail).then(done); + }); + + it('reject when disconnected by the user', function(done) { + var p = method(123, 'abc'); + shaka.test.Util.capturePromiseStatus(p); + + // Wait a tick for the Promise status to be set. + shaka.test.Util.delay(0.1).then(function() { + expect(p.status).toBe('pending'); + sender.disconnect(); + + // Wait a tick for the Promise status to change. + return shaka.test.Util.delay(0.1); + }).then(function() { + expect(p.status).toBe('rejected'); + return p.catch(function(error) { + shaka.test.Util.expectToEqualError(error, new shaka.util.Error( + shaka.util.Error.Category.PLAYER, + shaka.util.Error.Code.LOAD_INTERRUPTED)); + }); + }).catch(fail).then(done); + }); + + it('reject when disconnected remotely', function(done) { + var p = method(123, 'abc'); + shaka.test.Util.capturePromiseStatus(p); + + // Wait a tick for the Promise status to be set. + shaka.test.Util.delay(0.1).then(function() { + expect(p.status).toBe('pending'); + fakeRemoteDisconnect(); + + // Wait a tick for the Promise status to change. + return shaka.test.Util.delay(0.1); + }).then(function() { + expect(p.status).toBe('rejected'); + return p.catch(function(error) { + shaka.test.Util.expectToEqualError(error, new shaka.util.Error( + shaka.util.Error.Category.PLAYER, + shaka.util.Error.Code.LOAD_INTERRUPTED)); + }); + }).catch(fail).then(done); + }); + }); + }); + + describe('set', function() { + it('overrides any cached properties', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + var update = { + video: {muted: false} + }; + fakeSessionMessage({ + type: 'update', + update: update + }); + expect(sender.get('video', 'muted')).toBe(false); + + sender.set('video', 'muted', true); + expect(sender.get('video', 'muted')).toBe(true); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + + it('causes a "set" message to be sent', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + sender.set('video', 'muted', true); + expect(mockSession.messages).toContain(jasmine.objectContaining({ + type: 'set', + targetName: 'video', + property: 'muted', + value: true + })); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + + it('can be used before we have an "update" message', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + expect(sender.get('video', 'muted')).toBe(undefined); + sender.set('video', 'muted', true); + expect(sender.get('video', 'muted')).toBe(true); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + }); + + describe('hasRemoteProperties', function() { + it('is true only after we have an "update" message', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + expect(sender.hasRemoteProperties()).toBe(false); + + fakeSessionMessage({ + type: 'update', + update: {video: {currentTime: 12}, player: {isLive: false}} + }); + expect(sender.hasRemoteProperties()).toBe(true); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + }); + + describe('destroy', function() { + it('disconnects and cancels all async operations', function(done) { + sender.init(); + fakeReceiverAvailability(true); + sender.cast(fakeInitState).then(function() { + expect(sender.isCasting()).toBe(true); + expect(mockSession.stop).not.toHaveBeenCalled(); + + var method = sender.get('player', 'load'); + var p = method(); + shaka.test.Util.capturePromiseStatus(p); + + // Wait a tick for the Promise status to be set. + return shaka.test.Util.delay(0.1).then(function() { + expect(p.status).toBe('pending'); + sender.destroy().catch(fail); + expect(mockSession.stop).toHaveBeenCalled(); + + // Wait a tick for the Promise status to change. + return shaka.test.Util.delay(0.1); + }).then(function() { + expect(p.status).toBe('rejected'); + return p.catch(function(error) { + shaka.test.Util.expectToEqualError(error, new shaka.util.Error( + shaka.util.Error.Category.PLAYER, + shaka.util.Error.Code.LOAD_INTERRUPTED)); + }); + }); + }).catch(fail).then(done); + fakeSessionConnection(); + }); + }); + + function createMockCastApi() { + return { + isAvailable: true, + SessionRequest: jasmine.createSpy('chrome.cast.SessionRequest'), + ApiConfig: jasmine.createSpy('chrome.cast.ApiConfig'), + initialize: jasmine.createSpy('chrome.cast.initialize'), + requestSession: jasmine.createSpy('chrome.cast.requestSession') + }; + } + + function createMockCastSession() { + var session = { + messages: [], + status: 'connected', + addUpdateListener: jasmine.createSpy('Session.addUpdateListener'), + addMessageListener: jasmine.createSpy('Session.addMessageListener'), + sendMessage: jasmine.createSpy('Session.sendMessage'), + stop: jasmine.createSpy('Session.stop') + }; + + // For convenience, deserialize and store sent messages. + session.sendMessage.and.callFake( + function(namespace, message, successCallback, errorCallback) { + session.messages.push(CastUtils.deserialize(message)); + }); + return session; + } + + /** + * @param {boolean} yes If true, simulate receivers being available. + */ + function fakeReceiverAvailability(yes) { + var calls = mockCastApi.ApiConfig.calls; + expect(calls.count()).toEqual(1); + if (calls.count()) { + var onReceiverStatusChanged = calls.argsFor(0)[2]; + onReceiverStatusChanged(yes ? 'available' : 'unavailable'); + } + } + + function fakeSessionConnection() { + var calls = mockCastApi.requestSession.calls; + expect(calls.count()).toEqual(1); + if (calls.count()) { + var onSessionInitiated = calls.argsFor(0)[0]; + mockSession = createMockCastSession(); + onSessionInitiated(mockSession); + } + } + + /** + * @param {string} code + */ + function fakeSessionConnectionFailure(code) { + var calls = mockCastApi.requestSession.calls; + expect(calls.count()).toEqual(1); + if (calls.count()) { + var onSessionError = calls.argsFor(0)[1]; + onSessionError({code: code}); + } + } + + /** + * @param {?} message + */ + function fakeSessionMessage(message) { + var calls = mockSession.addMessageListener.calls; + expect(calls.count()).toEqual(1); + if (calls.count()) { + var namespace = calls.argsFor(0)[0]; + var listener = calls.argsFor(0)[1]; + var serialized = CastUtils.serialize(message); + listener(namespace, serialized); + } + } + + function fakeRemoteDisconnect() { + mockSession.status = 'disconnected'; + var calls = mockSession.addUpdateListener.calls; + expect(calls.count()).toEqual(1); + if (calls.count()) { + var onConnectionStatus = calls.argsFor(0)[0]; + onConnectionStatus(); + } + } + + function fakeJoinExistingSession() { + var calls = mockCastApi.ApiConfig.calls; + expect(calls.count()).toEqual(1); + if (calls.count()) { + var onJoinExistingSession = calls.argsFor(0)[1]; + mockSession = createMockCastSession(); + onJoinExistingSession(mockSession); + } + } +}); diff --git a/test/cast/cast_utils_unit.js b/test/cast/cast_utils_unit.js new file mode 100644 index 0000000000..977d7707db --- /dev/null +++ b/test/cast/cast_utils_unit.js @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('CastUtils', function() { + var CastUtils; + var FakeEvent; + + beforeAll(function() { + CastUtils = shaka.cast.CastUtils; + FakeEvent = shaka.util.FakeEvent; + }); + + describe('serialize/deserialize', function() { + it('transfers infinite values and NaN', function() { + var orig = { + 'nan': NaN, + 'positive_infinity': Infinity, + 'negative_infinity': -Infinity, + 'null': null, + 'true': true, + 'false': false, + 'one': 1, + 'string': 'a string' + }; + + var serialized = CastUtils.serialize(orig); + // The object is turned into a string. + expect(typeof serialized).toBe('string'); + + // The deserialized object matches the original. + var deserialized = CastUtils.deserialize(serialized); + for (var k in orig) { + expect(deserialized[k]).toEqual(orig[k]); + } + }); + + it('transfers real Events', function() { + // new Event() is not usable on IE11: + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('myEventType', false, false, null); + + // Properties that can definitely be transferred. + var nativeProperties = [ + 'bubbles', + 'type', + 'cancelable', + 'defaultPrevented' + ]; + var extraProperties = { + 'key': 'value', + 'true': true, + 'one': 1 + }; + + for (var k in extraProperties) { + event[k] = extraProperties[k]; + } + + // The event is turned into a string. + var serialized = CastUtils.serialize(event); + expect(typeof serialized).toBe('string'); + + // The string is turned back into an object. + var deserialized = CastUtils.deserialize(serialized); + expect(typeof deserialized).toBe('object'); + + // The object can be used to construct a FakeEvent. + var fakeEvent = new FakeEvent(deserialized['type'], deserialized); + + // The fake event has the same type and properties as the original. + nativeProperties.forEach(function(k) { + expect(fakeEvent[k]).toEqual(event[k]); + }); + for (var k in extraProperties) { + expect(fakeEvent[k]).toEqual(event[k]); + } + }); + + it('transfers dispatched FakeEvents', function(done) { + var event = new FakeEvent('custom'); + + // Properties that can definitely be transferred. + var nativeProperties = [ + 'bubbles', + 'type', + 'cancelable', + 'defaultPrevented' + ]; + var extraProperties = { + 'key': 'value', + 'true': true, + 'one': 1 + }; + + for (var k in extraProperties) { + event[k] = extraProperties[k]; + } + + var target = new shaka.util.FakeEventTarget(); + target.addEventListener(event.type, function() { + try { + // The event is turned into a string. + var serialized = CastUtils.serialize(event); + expect(typeof serialized).toBe('string'); + + // The string is turned back into an object. + var deserialized = CastUtils.deserialize(serialized); + expect(typeof deserialized).toBe('object'); + + // The deserialized event has the same type and properties as the + // original. + nativeProperties.forEach(function(k) { + expect(deserialized[k]).toEqual(event[k]); + }); + for (var k in extraProperties) { + expect(deserialized[k]).toEqual(event[k]); + } + } catch (exception) { + fail(exception); + } + done(); + }); + target.dispatchEvent(event); + }); + + describe('TimeRanges', function() { + var video; + var eventManager; + var mediaSourceEngine; + + beforeAll(function(done) { + // The TimeRanges constructor cannot be used directly, so we load a clip + // to get ranges to use. + video = /** @type {HTMLMediaElement} */( + document.createElement('video')); + document.body.appendChild(video); + + var mediaSource = new MediaSource(); + var mimeType = 'video/mp4; codecs="avc1.42c01e"'; + var initSegmentUrl = 'test/test/assets/sintel-video-init.mp4'; + var videoSegmentUrl = 'test/test/assets/sintel-video-segment.mp4'; + + // Wait for the media source to be open. + eventManager = new shaka.util.EventManager(); + video.src = window.URL.createObjectURL(mediaSource); + eventManager.listen(video, 'error', onError); + eventManager.listen(mediaSource, 'sourceopen', onSourceOpen); + + function onError() { + fail('Error code ' + (video.error ? video.error.code : 0)); + } + + function onSourceOpen() { + mediaSourceEngine = new shaka.media.MediaSourceEngine( + video, mediaSource, /* TextTrack */ null); + mediaSourceEngine.init({'video': mimeType}); + shaka.test.Util.fetch(initSegmentUrl).then(function(data) { + return mediaSourceEngine.appendBuffer('video', data, null, null); + }).then(function() { + return shaka.test.Util.fetch(videoSegmentUrl); + }).then(function(data) { + return mediaSourceEngine.appendBuffer('video', data, null, null); + }).catch(fail).then(done); + } + }); + + afterAll(function() { + eventManager.destroy(); + if (mediaSourceEngine) mediaSourceEngine.destroy(); + }); + + it('deserialize into equivalent objects', function() { + var buffered = video.buffered; + + // The test is less interesting if the ranges are empty. + expect(buffered.length).toBeGreaterThan(0); + + // The TimeRanges object is turned into a string. + var serialized = CastUtils.serialize(buffered); + expect(typeof serialized).toBe('string'); + + // Expect the deserialized version to look like the original. + var deserialized = CastUtils.deserialize(serialized); + expect(deserialized.length).toEqual(buffered.length); + expect(deserialized.start).toEqual(jasmine.any(Function)); + expect(deserialized.end).toEqual(jasmine.any(Function)); + + for (var i = 0; i < deserialized.length; ++i) { + // Not exact because of the possibility of rounding errors. + expect(deserialized.start(i)).toBeCloseTo(buffered.start(i)); + expect(deserialized.end(i)).toBeCloseTo(buffered.end(i)); + } + }); + }); + }); +}); diff --git a/test/test/util/stream_generator.js b/test/test/util/stream_generator.js index f4f3e0f8fb..cc6b1559df 100644 --- a/test/test/util/stream_generator.js +++ b/test/test/util/stream_generator.js @@ -144,8 +144,8 @@ shaka.test.DashVodStreamGenerator = function( /** @override */ shaka.test.DashVodStreamGenerator.prototype.init = function() { var async = [ - shaka.test.fetch_(this.initSegmentUri_), - shaka.test.fetch_(this.segmentTemplateUri_) + shaka.test.Util.fetch(this.initSegmentUri_), + shaka.test.Util.fetch(this.segmentTemplateUri_) ]; return Promise.all(async).then( @@ -290,8 +290,8 @@ shaka.test.DashLiveStreamGenerator = function( /** @override */ shaka.test.DashLiveStreamGenerator.prototype.init = function() { var async = [ - shaka.test.fetch_(this.initSegmentUri_), - shaka.test.fetch_(this.segmentTemplateUri_) + shaka.test.Util.fetch(this.initSegmentUri_), + shaka.test.Util.fetch(this.segmentTemplateUri_) ]; return Promise.all(async).then( @@ -368,38 +368,6 @@ shaka.test.DashLiveStreamGenerator.prototype.getSegment = function( }; -/** - * Fetches the resource at the given URI. - * - * @param {string} uri - * @return {!Promise.} - * @private - */ -shaka.test.fetch_ = function(uri) { - return new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', uri, true /* asynchronous */); - xhr.responseType = 'arraybuffer'; - - xhr.onload = function(event) { - if (xhr.status >= 200 && - xhr.status <= 299 && - !!xhr.response) { - resolve(/** @type {!ArrayBuffer} */(xhr.response)); - } else { - reject(xhr.status); - } - }; - - xhr.onerror = function(event) { - reject('error'); - }; - - xhr.send(null /* body */); - }); -}; - - /** * Gets the given initialization segment's movie header box's (mvhd box) * timescale parameter. diff --git a/test/test/util/util.js b/test/test/util/util.js index e45ff28eba..b020ec7847 100644 --- a/test/test/util/util.js +++ b/test/test/util/util.js @@ -223,6 +223,37 @@ shaka.test.Util.compareReferences = function(first, second) { }; +/** + * Fetches the resource at the given URI. + * + * @param {string} uri + * @return {!Promise.} + */ +shaka.test.Util.fetch = function(uri) { + return new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', uri, true /* asynchronous */); + xhr.responseType = 'arraybuffer'; + + xhr.onload = function(event) { + if (xhr.status >= 200 && + xhr.status <= 299 && + !!xhr.response) { + resolve(/** @type {!ArrayBuffer} */(xhr.response)); + } else { + reject(xhr.status); + } + }; + + xhr.onerror = function(event) { + reject('error'); + }; + + xhr.send(null /* body */); + }); +}; + + /** * Replace goog.asserts and console.assert with a version which hooks into * jasmine. This converts all failed assertions into failed tests.