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 @@ + + + + + + image/svg+xml + + + + + + + + + + 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 @@ + + + + + + image/svg+xml + + + + + + + + + +