diff --git a/docs/design/chromecast.md b/docs/design/chromecast.md index 8a49411520..24b38acb31 100644 --- a/docs/design/chromecast.md +++ b/docs/design/chromecast.md @@ -26,11 +26,13 @@ the `Player` and `HTMLMediaElement` objects. Receiver apps only have to worry about their UI, while the `CastReceiver` takes care of playback and communication. +To enable an Android (TV) receiver apps set `androidReceiverCompatible` to true. + #### `CastProxy` API sketch ```js -new shaka.cast.CastProxy(video, player, receiverAppId) +new shaka.cast.CastProxy(video, player, receiverAppId, androidReceiverCompatible) // Also destroys the underlying local Player object shaka.cast.CastProxy.prototype.destroy() => Promise diff --git a/docs/tutorials/ui.md b/docs/tutorials/ui.md index 0e7fb4b9c0..aa44570961 100644 --- a/docs/tutorials/ui.md +++ b/docs/tutorials/ui.md @@ -130,6 +130,23 @@ Next, let's add a listener to the 'caststatuschanged' event in myapp.js: } ``` + +#### Enabling Android Receiver Apps + +If you'd like to take advantage of Android Receiver App support, +you will need to provide a boolean flag to enable support for +casting to an Android receiver app. + +```html +
+ + +
` element @@ -200,6 +217,15 @@ const controls = ui.getControls(); // your API calls will be routed to the remote playback session. const player = controls.getPlayer(); const video = controls.getVideo(); + +// Programatically configure the Chromecast Receiver App Id and Android +// Receiver Compatability. +ui.configure({ + // Set the castReceiverAppId + 'castReceiverAppId': 'BBED8D28', + // Enable casting to native Android Apps (e.g. Android TV Apps) + 'castAndroidReceiverCompatible': true, +}); ``` diff --git a/externs/chromecast.js b/externs/chromecast.js index ed2ef74c28..382165769a 100644 --- a/externs/chromecast.js +++ b/externs/chromecast.js @@ -270,6 +270,13 @@ chrome.cast.Session = class { chrome.cast.SessionRequest = class { - /** @param {string} appId */ - constructor(appId) {} + /** + * @param {string} appId + * @param {Array.} capabilities + * @param {?number} timeout + * @param {boolean} androidReceiverCompatible + * @param {Object} credentialsData + */ + constructor(appId, capabilities, timeout, androidReceiverCompatible, + credentialsData) {} }; diff --git a/lib/cast/cast_proxy.js b/lib/cast/cast_proxy.js index 26c203ca4d..e7f1a6afd2 100644 --- a/lib/cast/cast_proxy.js +++ b/lib/cast/cast_proxy.js @@ -43,8 +43,11 @@ shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget { * @param {string} receiverAppId The ID of the cast receiver application. * If blank, casting will not be available, but the proxy will still * function otherwise. + * @param {boolean} androidReceiverCompatible Indicates if the app is + * compatible with an Android Receiver. */ - constructor(video, player, receiverAppId) { + constructor(video, player, receiverAppId, + androidReceiverCompatible = false) { super(); /** @private {HTMLMediaElement} */ @@ -71,6 +74,9 @@ shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget { /** @private {string} */ this.receiverAppId_ = receiverAppId; + /** @private {boolean} */ + this.androidReceiverCompatible_ = androidReceiverCompatible; + /** @private {!Map} */ this.compiledToExternNames_ = new Map(); @@ -81,7 +87,8 @@ shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget { () => this.onFirstCastStateUpdate_(), (targetName, event) => this.onRemoteEvent_(targetName, event), () => this.onResumeLocal_(), - () => this.getInitState_()); + () => this.getInitState_(), + androidReceiverCompatible); this.init_(); @@ -225,15 +232,18 @@ shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget { /** * @param {string} newAppId + * @param {boolean=} newCastAndroidReceiver * @export */ - async changeReceiverId(newAppId) { - if (newAppId == this.receiverAppId_) { + async changeReceiverId(newAppId, newCastAndroidReceiver = false) { + if (newAppId == this.receiverAppId_ && + newCastAndroidReceiver == this.androidReceiverCompatible_) { // Nothing to change return; } this.receiverAppId_ = newAppId; + this.androidReceiverCompatible_ = newCastAndroidReceiver; // Destroy the old sender this.sender_.forceDisconnect(); @@ -248,7 +258,8 @@ shaka.cast.CastProxy = class extends shaka.util.FakeEventTarget { () => this.onFirstCastStateUpdate_(), (targetName, event) => this.onRemoteEvent_(targetName, event), () => this.onResumeLocal_(), - () => this.getInitState_()); + () => this.getInitState_(), + newCastAndroidReceiver); this.sender_.init(); } diff --git a/lib/cast/cast_sender.js b/lib/cast/cast_sender.js index 9e3bf90835..95e9aec6c5 100644 --- a/lib/cast/cast_sender.js +++ b/lib/cast/cast_sender.js @@ -32,12 +32,18 @@ shaka.cast.CastSender = class { * should resume playback. Called before the cached remote state is wiped. * @param {function()} onInitStateRequired A callback to get local player's. * state. Invoked when casting is initiated from Chrome's cast button. + * @param {boolean} androidReceiverCompatible Indicates if the app is + * compatible with an Android Receiver. */ constructor(receiverAppId, onStatusChanged, onFirstCastStateUpdate, - onRemoteEvent, onResumeLocal, onInitStateRequired) { + onRemoteEvent, onResumeLocal, onInitStateRequired, + androidReceiverCompatible) { /** @private {string} */ this.receiverAppId_ = receiverAppId; + /** @private {boolean} */ + this.androidReceiverCompatible_ = androidReceiverCompatible; + /** @private {shaka.util.Timer} */ this.statusChangeTimer_ = new shaka.util.Timer(onStatusChanged); @@ -211,7 +217,11 @@ shaka.cast.CastSender = class { // Use static versions of the API callbacks, since the ChromeCast API is // static. If we used local versions, we might end up retaining references // to destroyed players here. - const sessionRequest = new chrome.cast.SessionRequest(this.receiverAppId_); + const sessionRequest = new chrome.cast.SessionRequest(this.receiverAppId_, + /* capabilities= */ [], + /* timeout= */ null, + this.androidReceiverCompatible_, + /* credentialsData= */null); const apiConfig = new chrome.cast.ApiConfig(sessionRequest, (session) => CastSender.onExistingSessionJoined_(session), (availability) => CastSender.onReceiverStatusChanged_(availability), diff --git a/test/cast/cast_proxy_unit.js b/test/cast/cast_proxy_unit.js index 4055d362b2..3aaad92909 100644 --- a/test/cast/cast_proxy_unit.js +++ b/test/cast/cast_proxy_unit.js @@ -11,6 +11,7 @@ describe('CastProxy', () => { const originalCastSender = shaka.cast.CastSender; const fakeAppId = 'fake app ID'; + const fakeAndroidReceiverCompatible = false; let mockPlayer; let mockSender; @@ -31,7 +32,8 @@ describe('CastProxy', () => { mockPlayer = createMockPlayer(); mockSender = null; - proxy = new CastProxy(mockVideo, mockPlayer, fakeAppId); + proxy = new CastProxy(mockVideo, mockPlayer, fakeAppId, + fakeAndroidReceiverCompatible); }); afterEach(async () => { diff --git a/test/cast/cast_sender_unit.js b/test/cast/cast_sender_unit.js index 2602fc54d4..4caa776539 100644 --- a/test/cast/cast_sender_unit.js +++ b/test/cast/cast_sender_unit.js @@ -13,6 +13,7 @@ describe('CastSender', () => { const originalStatusDelay = shaka.cast.CastSender.STATUS_DELAY; const fakeAppId = 'asdf'; + const fakeAndroidReceiverCompatible = false; const fakeInitState = { manifest: null, player: null, @@ -52,7 +53,8 @@ describe('CastSender', () => { sender = new CastSender( fakeAppId, Util.spyFunc(onStatusChanged), Util.spyFunc(onFirstCastStateUpdate), Util.spyFunc(onRemoteEvent), - Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired)); + Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired), + fakeAndroidReceiverCompatible); }); afterEach(async () => { @@ -87,7 +89,12 @@ describe('CastSender', () => { expect(sender.apiReady()).toBe(true); expect(sender.hasReceivers()).toBe(false); expect(onStatusChanged).toHaveBeenCalled(); - expect(mockCastApi.SessionRequest).toHaveBeenCalledWith(fakeAppId); + expect(mockCastApi.SessionRequest).toHaveBeenCalledWith( + fakeAppId, + /* capabilities= */ [], + /* timeout= */ null, + fakeAndroidReceiverCompatible, + /* credentialsData= */ null); expect(mockCastApi.initialize).toHaveBeenCalled(); }); @@ -97,7 +104,12 @@ describe('CastSender', () => { expect(sender.apiReady()).toBe(true); expect(sender.hasReceivers()).toBe(false); expect(onStatusChanged).toHaveBeenCalled(); - expect(mockCastApi.SessionRequest).toHaveBeenCalledWith(fakeAppId); + expect(mockCastApi.SessionRequest).toHaveBeenCalledWith( + fakeAppId, + /* capabilities= */ [], + /* timeout= */ null, + fakeAndroidReceiverCompatible, + /* credentialsData= */ null); expect(mockCastApi.initialize).toHaveBeenCalled(); }); }); @@ -122,7 +134,8 @@ describe('CastSender', () => { sender = new CastSender( fakeAppId, Util.spyFunc(onStatusChanged), Util.spyFunc(onFirstCastStateUpdate), Util.spyFunc(onRemoteEvent), - Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired)); + Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired), + /* androidReceiverCompatible= */ false); sender.init(); // You get an initial call to onStatusChanged when it initializes. expect(onStatusChanged).toHaveBeenCalledTimes(3); @@ -256,7 +269,8 @@ describe('CastSender', () => { sender = new CastSender( fakeAppId, Util.spyFunc(onStatusChanged), Util.spyFunc(onFirstCastStateUpdate), Util.spyFunc(onRemoteEvent), - Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired)); + Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired), + /* androidReceiverCompatible= */ false); sender.init(); // The sender should automatically rejoin the session, without needing @@ -288,7 +302,8 @@ describe('CastSender', () => { sender = new CastSender( fakeAppId, Util.spyFunc(onStatusChanged), Util.spyFunc(onFirstCastStateUpdate), Util.spyFunc(onRemoteEvent), - Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired)); + Util.spyFunc(onResumeLocal), Util.spyFunc(onInitStateRequired), + /* androidReceiverCompatible= */ false); sender.init(); expect(sender.isCasting()).toBe(false); diff --git a/ui/controls.js b/ui/controls.js index 2a2def7a6e..9008a0398d 100644 --- a/ui/controls.js +++ b/ui/controls.js @@ -53,7 +53,8 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { /** @private {shaka.cast.CastProxy} */ this.castProxy_ = new shaka.cast.CastProxy( - video, player, this.config_.castReceiverAppId); + video, player, this.config_.castReceiverAppId, + this.config_.castAndroidReceiverCompatible); /** @private {boolean} */ this.castAllowed_ = true; @@ -315,7 +316,8 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { configure(config) { this.config_ = config; - this.castProxy_.changeReceiverId(config.castReceiverAppId); + this.castProxy_.changeReceiverId(config.castReceiverAppId, + config.castAndroidReceiverCompatible); // Deconstruct the old layout if applicable if (this.seekBar_) { diff --git a/ui/externs/ui.js b/ui/externs/ui.js index 5e8dd8e65d..a78473d576 100644 --- a/ui/externs/ui.js +++ b/ui/externs/ui.js @@ -72,6 +72,7 @@ shaka.extern.UIVolumeBarColors; * addBigPlayButton: boolean, * customContextMenu: boolean, * castReceiverAppId: string, + * castAndroidReceiverCompatible: boolean, * clearBufferOnQualityChange: boolean, * showUnbufferedStart: boolean, * seekBarColors: shaka.extern.UISeekBarColors, @@ -109,6 +110,8 @@ shaka.extern.UIVolumeBarColors; * Whether or not a custom context menu replaces the default. * @property {string} castReceiverAppId * Receiver app id to use for the Chromecast support. + * @property {boolean} castAndroidReceiverCompatible + * Indicates if the app is compatible with an Android Cast Receiver. * @property {boolean} clearBufferOnQualityChange * Only applicable if the resolution selection is part of the UI. * Whether buffer should be cleared when changing resolution diff --git a/ui/ui.js b/ui/ui.js index 76b3b0419a..42224e5d3d 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -218,6 +218,7 @@ shaka.ui.Overlay = class { addBigPlayButton: false, customContextMenu: false, castReceiverAppId: '', + castAndroidReceiverCompatible: false, clearBufferOnQualityChange: true, showUnbufferedStart: false, seekBarColors: { @@ -388,19 +389,28 @@ shaka.ui.Overlay = class { // Get and configure cast app id. let castAppId = ''; + // Get and configure cast Android Receiver Compatibility + let castAndroidReceiverCompatible = false; + // Cast receiver id can be specified on either container or video. // It should not be provided on both. If it was, we will use the last // one we saw. if (container['dataset'] && container['dataset']['shakaPlayerCastReceiverId']) { castAppId = container['dataset']['shakaPlayerCastReceiverId']; + castAndroidReceiverCompatible = + container['dataset']['shakaPlayerCastAndroidReceiverCompatible'] === + 'true'; } else if (video['dataset'] && video['dataset']['shakaPlayerCastReceiverId']) { castAppId = video['dataset']['shakaPlayerCastReceiverId']; + castAndroidReceiverCompatible = + video['dataset']['shakaPlayerCastAndroidReceiverCompatible'] === 'true'; } if (castAppId.length) { - ui.configure({castReceiverAppId: castAppId}); + ui.configure({castReceiverAppId: castAppId, + castAndroidReceiverCompatible: castAndroidReceiverCompatible}); } if (shaka.util.Dom.asHTMLMediaElement(video).controls) {