Skip to content

Commit

Permalink
feat(cast): Add Android receiver support (#4183)
Browse files Browse the repository at this point in the history
Closes #3773 
Closes #3772
  • Loading branch information
Álvaro Velad Galván authored May 5, 2022
1 parent b57279d commit dbba571
Show file tree
Hide file tree
Showing 10 changed files with 108 additions and 20 deletions.
4 changes: 3 additions & 1 deletion docs/design/chromecast.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/tutorials/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div data-shaka-player-container style="max-width:40em"
data-shaka-player-cast-receiver-id="E7271BEC"
data-shaka-player-cast-android-receiver-compatible="true">
<!-- The manifest url in the src attribute will be automatically loaded -->
<video autoplay data-shaka-player id="video" style="width:100%;height:100%"
src="https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd"></video>
</div


#### Providing source(s) for auto load.

It's also possible to provide the `src` attribute on the `<video>` element
Expand Down Expand Up @@ -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,
});
```
Expand Down
11 changes: 9 additions & 2 deletions externs/chromecast.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ chrome.cast.Session = class {


chrome.cast.SessionRequest = class {
/** @param {string} appId */
constructor(appId) {}
/**
* @param {string} appId
* @param {Array.<Object>} capabilities
* @param {?number} timeout
* @param {boolean} androidReceiverCompatible
* @param {Object} credentialsData
*/
constructor(appId, capabilities, timeout, androidReceiverCompatible,
credentialsData) {}
};
21 changes: 16 additions & 5 deletions lib/cast/cast_proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand All @@ -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();

Expand All @@ -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_();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
}
Expand Down
14 changes: 12 additions & 2 deletions lib/cast/cast_sender.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion test/cast/cast_proxy_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('CastProxy', () => {

const originalCastSender = shaka.cast.CastSender;
const fakeAppId = 'fake app ID';
const fakeAndroidReceiverCompatible = false;

let mockPlayer;
let mockSender;
Expand All @@ -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 () => {
Expand Down
27 changes: 21 additions & 6 deletions test/cast/cast_sender_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
});

Expand All @@ -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();
});
});
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions ui/controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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_) {
Expand Down
3 changes: 3 additions & 0 deletions ui/externs/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ shaka.extern.UIVolumeBarColors;
* addBigPlayButton: boolean,
* customContextMenu: boolean,
* castReceiverAppId: string,
* castAndroidReceiverCompatible: boolean,
* clearBufferOnQualityChange: boolean,
* showUnbufferedStart: boolean,
* seekBarColors: shaka.extern.UISeekBarColors,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion ui/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ shaka.ui.Overlay = class {
addBigPlayButton: false,
customContextMenu: false,
castReceiverAppId: '',
castAndroidReceiverCompatible: false,
clearBufferOnQualityChange: true,
showUnbufferedStart: false,
seekBarColors: {
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit dbba571

Please sign in to comment.