Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cast): Add Android receiver support #4183

Merged
merged 1 commit into from
May 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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