diff --git a/src/js/config/defaults.js b/src/js/config/defaults.js
index e301022e4..550be9fe8 100644
--- a/src/js/config/defaults.js
+++ b/src/js/config/defaults.js
@@ -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',
diff --git a/src/js/controls.js b/src/js/controls.js
index 008e76566..c3d1fd0fb 100644
--- a/src/js/controls.js
+++ b/src/js/controls.js
@@ -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, {
diff --git a/src/js/listeners.js b/src/js/listeners.js
index dfe118b5b..0cfcc0046 100644
--- a/src/js/listeners.js
+++ b/src/js/listeners.js
@@ -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,
diff --git a/src/js/plugins/google-cast.js b/src/js/plugins/google-cast.js
new file mode 100644
index 000000000..bbb877cfc
--- /dev/null
+++ b/src/js/plugins/google-cast.js
@@ -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;
diff --git a/src/js/plyr.js b/src/js/plyr.js
index 55471441d..8aed1a4a9 100644
--- a/src/js/plyr.js
+++ b/src/js/plyr.js
@@ -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
diff --git a/src/js/source.js b/src/js/source.js
index a62edbba7..893101fcb 100644
--- a/src/js/source.js
+++ b/src/js/source.js
@@ -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');
diff --git a/src/js/support.js b/src/js/support.js
index 9b5d2aa04..69014b978 100644
--- a/src/js/support.js
+++ b/src/js/support.js
@@ -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'),
diff --git a/src/js/ui.js b/src/js/ui.js
index 31eadf294..f80cb0690 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -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;
diff --git a/src/sass/components/controls.scss b/src/sass/components/controls.scss
index 330c450c5..da50bea58 100644
--- a/src/sass/components/controls.scss
+++ b/src/sass/components/controls.scss
@@ -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;
}
diff --git a/src/sprite/plyr-googlecast-off.svg b/src/sprite/plyr-googlecast-off.svg
new file mode 100644
index 000000000..7758a1fd8
--- /dev/null
+++ b/src/sprite/plyr-googlecast-off.svg
@@ -0,0 +1,40 @@
+
+
diff --git a/src/sprite/plyr-googlecast-on.svg b/src/sprite/plyr-googlecast-on.svg
new file mode 100644
index 000000000..50ea04cf5
--- /dev/null
+++ b/src/sprite/plyr-googlecast-on.svg
@@ -0,0 +1,40 @@
+
+