Skip to content

Add Google Cast support (take 2) #2575

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

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open
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
13 changes: 13 additions & 0 deletions src/js/config/defaults.js
Original file line number Diff line number Diff line change
@@ -143,6 +143,7 @@ const defaults = {
'pip',
'airplay',
// 'download',
'googlecast',
'fullscreen',
],
settings: ['captions', 'quality', 'speed'],
@@ -168,6 +169,8 @@ const defaults = {
download: 'Download',
enterFullscreen: 'Enter fullscreen',
exitFullscreen: 'Exit fullscreen',
enableGoogleCast: 'Google Cast',
disableGoogleCast: 'Disable Cast',
frameTitle: 'Player for {title}',
captions: 'Captions',
settings: 'Settings',
@@ -209,6 +212,9 @@ const defaults = {
googleIMA: {
sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js',
},
googlecast: {
api: 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1',
},
},

// Custom control listeners
@@ -226,6 +232,7 @@ const defaults = {
fullscreen: null,
pip: null,
airplay: null,
googlecast: null,
speed: null,
quality: null,
loop: null,
@@ -308,6 +315,7 @@ const defaults = {
fullscreen: '[data-plyr="fullscreen"]',
pip: '[data-plyr="pip"]',
airplay: '[data-plyr="airplay"]',
googlecast: '[data-plyr="googlecast"]',
settings: '[data-plyr="settings"]',
loop: '[data-plyr="loop"]',
},
@@ -328,6 +336,7 @@ const defaults = {
progress: '.plyr__progress',
captions: '.plyr__captions',
caption: '.plyr__caption',
googlecast: '.plyr__googlecast',
},

// Class hooks added to the player in different states
@@ -382,6 +391,10 @@ const defaults = {
active: 'plyr--airplay-active',
},
tabFocus: 'plyr__tab-focus',
googlecast: {
enabled: 'plyr--googlecast-enabled',
active: 'plyr--googlecast-active',
},
previewThumbnails: {
// Tooltip thumbs
thumbContainer: 'plyr__preview-thumb',
12 changes: 11 additions & 1 deletion src/js/controls.js
Original file line number Diff line number Diff line change
@@ -243,7 +243,13 @@ const controls = {
props.label = 'play';
props.icon = 'play';
break;

case 'googlecast':
props.toggle = true;
props.label = 'enableGoogleCast';
props.labelPressed = 'disableGoogleCast';
props.icon = 'googlecast-off';
props.iconPressed = 'googlecast-on';
break;
default:
if (is.empty(props.label)) {
props.label = type;
@@ -1581,6 +1587,10 @@ const controls = {
container.appendChild(createButton.call(this, 'airplay', defaultAttributes));
}

// Google cast button
if (control === 'googlecast' && support.googlecast) {
container.appendChild(controls.createButton.call(this, 'googlecast'));
}
// Download button
if (control === 'download') {
const attributes = extend({}, defaultAttributes, {
3 changes: 3 additions & 0 deletions src/js/listeners.js
Original file line number Diff line number Diff line change
@@ -616,6 +616,9 @@ class Listeners {
'download',
);

// Google cast
this.bind(elements.buttons.googlecast, 'click', player.googlecast, 'googlecast');

// Fullscreen toggle
this.bind(
elements.buttons.fullscreen,
394 changes: 394 additions & 0 deletions src/js/plugins/google-cast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,394 @@
import Console from '../console';
import { createElement, getAttributesFromSelector, insertAfter, toggleClass } from '../utils/elements';
import { triggerEvent } from '../utils/events';
import is from '../utils/is';
import loadScript from '../utils/load-script';
import { extend } from '../utils/objects';

const googlecast = {
setup(config) {
if (!window.chrome) {
// TODO: Figure out if this is the right check
// We're not on Chrome. Bail since google-cast does not work
// on other browsers
return;
}
googlecast.defaults = {};
googlecast.config = {};

googlecast.events = {
ready: googlecast.onReady,
play: googlecast.onPlay,
pause: googlecast.onPause,
seeked: googlecast.onSeek,
volumechange: googlecast.onVolumeChange,
qualityrequested: googlecast.onQualityChange,
loadedmetadata: googlecast.onLoadedMetadata,
};

googlecast.debug = new Console(true);
// TODO: Get cast logs under a separate namespace?

// Inject the container
if (!is.element(this.elements.googlecast)) {
this.elements.googlecast = createElement('div', getAttributesFromSelector(this.config.selectors.googlecast));
insertAfter(this.elements.googlecast, this.elements.wrapper);
}
// Set the class hook
toggleClass(this.elements.container, this.config.classNames.googlecast.enabled, true);

if (!window.chrome.cast) {
window['__onGCastApiAvailable'] = function (isAvailable) {
if (!isAvailable) return;

googlecast.defaults = {
options: {
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
// receiverApplicationId: 'C248C800',
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
},
};
const opts = extend({}, googlecast.defaults, config);
googlecast.initializeCastApi(opts);
};

loadScript(this.config.urls.googlecast.api);
}
},

initializeCastApi(config) {
const { framework } = window.cast;
const { CastContext } = framework;
CastContext.getInstance().setOptions(config.options);

// Set up event handlers
CastContext.getInstance().addEventListener(
framework.CastContextEventType.CAST_STATE_CHANGED,
googlecast.castStateListener,
);
CastContext.getInstance().addEventListener(
framework.CastContextEventType.SESSION_STATE_CHANGED,
googlecast.sessionStateListener,
);
googlecast.debug.log('Initialized google cast');
},

getCurrentSession() {
return window.cast.framework.CastContext.getInstance().getCurrentSession();
},

getCurrentPlyr() {
return googlecast.currentPlyr;
},

onPlay() {
const plyr = googlecast.getCurrentPlyr();
googlecast.debug.log('Asking remote player to play');
// Seek before playing?
// googlecast.onSeek();
plyr.remotePlayerController.playOrPause();
},
onPause() {
const plyr = googlecast.getCurrentPlyr();
googlecast.debug.log('Asking remote player to pause');
plyr.remotePlayerController.playOrPause();
// Seek after pause
googlecast.onSeek();
},
onSeek() {
const plyr = googlecast.getCurrentPlyr();
const timestamp = plyr.currentTime;
plyr.remotePlayer.currentTime = timestamp;
plyr.remotePlayerController.seek();
googlecast.debug.log(`Asking remote player to seek to ${timestamp}`);
},
onLoadedMetadata() {
googlecast.debug.log('Running googlecast.onReady()');
const plyr = googlecast.getCurrentPlyr();
const oldLoadRequest = plyr.googlecastLoadRequest;
const newLoadRequest = googlecast.buildLoadRequest(plyr);
if (oldLoadRequest.media.contentId === newLoadRequest.media.contentId) {
return;
}
googlecast.loadMedia(plyr, newLoadRequest);
},
onReady() {
googlecast.debug.log('Running googlecast.onReady()');
const plyr = googlecast.getCurrentPlyr();
googlecast.loadMedia(plyr);
},
onVolumeChange() {
const plyr = googlecast.getCurrentPlyr();
// We need to specially handle the case where plyr is muted
let { volume } = plyr;
if (plyr.muted) {
volume = 0;
}
plyr.remotePlayer.volumeLevel = volume;
plyr.remotePlayerController.setVolumeLevel();
},
onQualityChange() {
const plyr = googlecast.getCurrentPlyr();
googlecast.loadMedia(plyr);
},
loadMedia(plyr, loadRequest) {
googlecast.debug.log('load media called');
const session = googlecast.getCurrentSession();
if (!session) {
return;
}
if (!loadRequest) {
loadRequest = googlecast.buildLoadRequest(plyr);
}
session
.loadMedia(loadRequest)
.then(() => {
googlecast.debug.log('Successfully handled loadMedia');
googlecast.getCurrentPlyr().googlecastLoadRequest = loadRequest;
googlecast.bindPlyr(plyr);
})
.catch((err) => {
googlecast.debug.log(`Error during loadMedia: ${err}`);
});
},
buildLoadRequest(plyr) {
// TODO: We need to be able to override the defaults
const defaults = {
mediaInfo: {
source: plyr.source,
contentType: 'video/mp4',
},
metadata: {
metadataType: window.chrome.cast.media.MetadataType.GENERIC,
title: plyr.config.title || plyr.source,
images: [
{
url: plyr.poster,
},
],
},
loadRequest: {
autoplay: plyr.playing,
currentTime: plyr.currentTime,
customData: {
type: plyr.type,
provider: plyr.provider,
},
},
};

if (plyr.hls) {
// Plyr has been hijacked by HLS
const { customData } = defaults.loadRequest;
customData.subType = 'hls';
customData.source = plyr.hls.manifestURL;
}

const options = extend({}, defaults);
const mediaInfo = new window.chrome.cast.media.MediaInfo(options.mediaInfo.source, options.mediaInfo.contentType);
mediaInfo.streamType = defaults.mediaInfo.streamType;

mediaInfo.metadata = new window.chrome.cast.media.GenericMediaMetadata();
Object.assign(mediaInfo.metadata, options.metadata);

const loadRequest = new window.chrome.cast.media.LoadRequest(mediaInfo);
loadRequest.customData = options.loadRequest.customData;
loadRequest.autoplay = options.loadRequest.autoplay;
loadRequest.currentTime = options.loadRequest.currentTime;
return loadRequest;
},
setCurrentPlyr(plyr) {
googlecast.currentPlyr = plyr;
},
bindEvents(plyr) {
// Iterate over events and add all listeners
Object.keys(googlecast.events).forEach((evt) => {
const fn = googlecast.events[evt];
plyr.on(evt, fn);
});
},
bindPlyr(plyr, options) {
if (googlecast.currentPlyr !== plyr) {
googlecast.debug.warn('Warning! Current plyr !== plyr in bindPlyr()');
googlecast.currentPlyr = plyr;
}
googlecast.currentPlyrOptions = options;

// TODO: Figure out if we should do plyr.remotePlayer = plyr.remotePlayer || new window.cast.framework.RemotePlayer()
plyr.remotePlayer = new window.cast.framework.RemotePlayer();
// TODO: Figure out if we should do plyr.remotePlayerController = plyr.remotePlayerController || new window.cast.framework.RemotePlayerController(plyr.remotePlayer);
plyr.remotePlayerController = new window.cast.framework.RemotePlayerController(plyr.remotePlayer);

googlecast.bindEvents(plyr);
plyr.googlecastEnabled = true; // FIXME: This should probably use state from controls
googlecast.debug.log('Plyr bound');
},

unbindPlyr(plyr) {
const { currentPlyr } = googlecast;
if (currentPlyr === plyr) {
Object.keys(googlecast.events).forEach((evt) => {
const fn = googlecast.events[evt];
plyr.off(evt, fn);
});
}
delete currentPlyr.googlecastEnabled; // FIXME: This should probably use state from controls
googlecast.currentPlyr = undefined;
googlecast.currentPlyrOptions = undefined;
},

getErrorMessage(error) {
const { chrome } = window;
switch (error.code) {
case chrome.cast.ErrorCode.API_NOT_INITIALIZED:
return `The API is not initialized.${error.description ? ` :${error.description}` : ''}`;
case chrome.cast.ErrorCode.CANCEL:
return `The operation was canceled by the user${error.description ? ` :${error.description}` : ''}`;
case chrome.cast.ErrorCode.CHANNEL_ERROR:
return `A channel to the receiver is not available.${error.description ? ` :${error.description}` : ''}`;
case chrome.cast.ErrorCode.EXTENSION_MISSING:
return `The Cast extension is not available.${error.description ? ` :${error.description}` : ''}`;
case chrome.cast.ErrorCode.INVALID_PARAMETER:
return `The parameters to the operation were not valid.${error.description ? ` :${error.description}` : ''}`;
case chrome.cast.ErrorCode.RECEIVER_UNAVAILABLE:
return `No receiver was compatible with the session request.${
error.description ? ` :${error.description}` : ''
}`;
case chrome.cast.ErrorCode.SESSION_ERROR:
return `A session could not be created, or a session was invalid.${
error.description ? ` :${error.description}` : ''
}`;
case chrome.cast.ErrorCode.TIMEOUT:
return `The operation timed out.${error.description ? ` :${error.description}` : ''}`;
default:
return `Unknown error: ${JSON.stringify(error)}`;
}
},

castStateListener(data) {
googlecast.debug.log(`Cast State Changed: ${JSON.stringify(data)}`);
const plyr = googlecast.getCurrentPlyr();
const cs = window.cast.framework.CastState;
let castEvent;
switch (data.castState) {
case cs.NO_DEVICES_AVAILABLE:
case cs.NOT_CONNECTED:
googlecast.debug.log('NOT CONNECTED');
castEvent = 'castdisabled';
break;
case cs.CONNECTING:
break;
case cs.CONNECTED:
castEvent = 'castenabled';
break;
default:
// googlecast.debug.log(`Unknown cast state=${JSON.stringify(data.castState)}`);
break;
}
if (plyr && castEvent) {
const castActive = castEvent === 'castenabled';
// Add class hook
toggleClass(plyr.elements.container, plyr.config.classNames.googlecast.active, castActive);
triggerEvent.call(plyr, plyr.elements.container, castEvent, true);
}
},

sessionStateListener(data) {
const plyr = googlecast.getCurrentPlyr();
if (!plyr) {
return;
}
// console.log("Session State Changed: " + JSON.stringify(data));
const ss = window.cast.framework.SessionState;

switch (data.sessionState) {
case ss.NO_SESSION:
break;
case ss.SESSION_STARTING:
break;
case ss.SESSION_STARTED:
case ss.SESSION_RESUMED:
// run on ready
googlecast.onReady();
break;
case ss.SESSION_START_FAILED:
case ss.SESSION_ENDED:
break;
case ss.SESSION_ENDING:
break;
default:
// plyr.log(`Unknown session state=${JSON.stringify(data.sessionState)}`);
break;
}
googlecast.debug.log(`sessionStateListener: state=${data.sessionState}`);
},

requestSession(plyr) {
// Check if a session already exists, if it does, just use it
const session = googlecast.getCurrentSession();
let wasPlyrAlreadyBound = true;
const existingPlyr = googlecast.getCurrentPlyr();
if (existingPlyr !== undefined && existingPlyr !== plyr) {
googlecast.unbindPlyr(existingPlyr);
}
if (existingPlyr !== plyr) {
googlecast.setCurrentPlyr(plyr);
wasPlyrAlreadyBound = false;
}

function onRequestSuccess(e) {
// This only triggers when a new session is created.
// It does not trigger on successfully showing the drop down and
// requesting stop session.
}

function onError(e) {
googlecast.unbindPlyr(googlecast.getCurrentPlyr());
}

// We need to show the cast drop down if:
// 1) There was no session
// 2) There was a session and the current plyr was already bound
//
// (2) is needed since we need a way to disable cast via the current
// plyr instance
if (session === null || wasPlyrAlreadyBound) {
const promise = window.cast.framework.CastContext.getInstance().requestSession();
promise.then(onRequestSuccess, onError);
} else {
// We have a session and we're just looking to bind plyr which we've
// done already. Just load media and change icon based on session state.
const cs = window.cast.framework.CastContext.getInstance().getCastState();
const castStateEventData = new window.cast.framework.CastStateEventData(cs);
googlecast.castStateListener(castStateEventData);

const ss = window.cast.framework.CastContext.getInstance().getSessionState();
const sessionStateEventData = new window.cast.framework.SessionStateEventData(session, ss, 0);
googlecast.sessionStateListener(sessionStateEventData);
}
},

// Display cast container and button (for initialization)
show() {
// If there's no cast toggle, bail
if (!this.elements.buttons.googlecast) {
return;
}

// Try to load the value from storage
let active = this.storage.googlecast;

// Otherwise fall back to the default config
if (!is.boolean(active)) {
({ active } = this.googlecast);
} else {
this.googlecast.active = active;
}

if (active) {
toggleClass(this.elements.container, this.config.classNames.googlecast.active, true);
toggleState(this.elements.buttons.googlecast, true);
}
},
};
export default googlecast;
11 changes: 11 additions & 0 deletions src/js/plyr.js
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import html5 from './html5';
import Listeners from './listeners';
import media from './media';
import Ads from './plugins/ads';
import googlecast from './plugins/google-cast';
import PreviewThumbnails from './plugins/preview-thumbnails';
import source from './source';
import Storage from './storage';
@@ -1060,6 +1061,16 @@ class Plyr {
}
};

/**
* Trigger google cast dialog
*/
googlecast() {
if (!support.googlecast) {
return;
}
googlecast.requestSession(this);
}

/**
* Toggle the player controls
* @param {Boolean} [toggle] - Whether to show the controls
8 changes: 8 additions & 0 deletions src/js/source.js
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ import ui from './ui';
import { createElement, insertElement, removeElement } from './utils/elements';
import is from './utils/is';
import { getDeep } from './utils/objects';
import googlecast from './plugins/google-cast';

const source = {
// Add elements to HTML5 media (source, tracks, etc)
@@ -37,6 +38,10 @@ const source = {
// Cancel current network requests
html5.cancelRequests.call(this);

if (this.hls) {
this.hls.destroy();
}

// Destroy instance and re-setup
this.destroy.call(
this,
@@ -48,6 +53,9 @@ const source = {
removeElement(this.media);
this.media = null;

// Remove hls property if set
delete plyr.hls;

// Reset class name
if (is.element(this.elements.container)) {
this.elements.container.removeAttribute('class');
2 changes: 2 additions & 0 deletions src/js/support.js
Original file line number Diff line number Diff line change
@@ -61,6 +61,8 @@ const support = {
// Safari only currently
airplay: is.function(window.WebKitPlaybackTargetAvailabilityEvent),

googlecast: !is.nullOrUndefined(window.chrome),

// Inline playback support
// https://webkit.org/blog/6784/new-video-policies-for-ios/
playsinline: 'playsInline' in document.createElement('video'),
4 changes: 4 additions & 0 deletions src/js/ui.js
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@

import captions from './captions';
import controls from './controls';
import googlecast from './plugins/google-cast';
import support from './support';
import browser from './utils/browser';
import { getElement, toggleClass } from './utils/elements';
@@ -61,6 +62,9 @@ const ui = {
captions.setup.call(this);
}

// Google cast
googlecast.setup.call(this);

// Reset volume
this.volume = null;

15 changes: 13 additions & 2 deletions src/sass/components/controls.scss
Original file line number Diff line number Diff line change
@@ -49,17 +49,28 @@
}
}

.plyr--cast-active .plyr__controls .icon--cast-on {
display: block;
color: #2243f9;

& + svg {
display: none;
}
}

// Some options are hidden by default
.plyr [data-plyr='captions'],
.plyr [data-plyr='pip'],
.plyr [data-plyr='airplay'],
.plyr [data-plyr='fullscreen'] {
.plyr [data-plyr='fullscreen'],
.plyr [data-plyr='cast'] {
display: none;
}

.plyr--captions-enabled [data-plyr='captions'],
.plyr--pip-supported [data-plyr='pip'],
.plyr--airplay-supported [data-plyr='airplay'],
.plyr--fullscreen-enabled [data-plyr='fullscreen'] {
.plyr--fullscreen-enabled [data-plyr='fullscreen'],
.plyr--cast-enabled [data-plyr='cast'] {
display: inline-block;
}
40 changes: 40 additions & 0 deletions src/sprite/plyr-googlecast-off.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions src/sprite/plyr-googlecast-on.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.