/* global gnca_settings */
/* global gn_analytics */
import InView from '../../utils/classes/InView';
import InBounds from './InBounds';
import Messenger from '../Messenger';
import customEvent from '../../utils/customEvent';
import updateIconPath from '../../utils/updateIconPath';
/**
* Sticky Video functionality.
* Handles multiple potential sticky videos on the same page.
* Only one video may be sticky at a time.
* This is a class because each sticky video needs to be initialized independently.
*
* @class StickyVideo
* @prop {string} stickyAttribute - CSS selector for videos that should be made sticky
* @prop {string} wrapperTemplate - Sticky video markup to wrap around player embeds
* @prop {object} stickySelectors - list of CSS classes which apply to sticky video elements
* @prop {string} pageContainerSelector - selector for the sticky video parent container
* @prop {object} pageContainerListener - In View listener of pageContainer element
* @prop {boolean} activeIFramePaused - T/F if the active sticky player is paused while unstuck
* @prop {string} activeIFrameId - ID of the active sticky player if one exists
* @prop {object} videoObserver - InView instance for observing video object
* @prop {object} containerObserver - InBounds instance for observing container
* @prop {float} intersectionRatio - Proportion of player visible below which we switch to sticky
*/
class StickyVideo {
static stickyAttribute = 'data-displayinline-player-id';
static wrapperTemplate = `
<div class="c-video__stickyGrid c-stickyVideo__grid">
<div class="c-video__stickySpacer c-stickyVideo__spacer"></div>
<div class="c-stickyVideo__inner">
<div class="c-stickyVideo__innerContainer">
<div class="c-stickyVideo__info">
<span class="c-stickyVideo__title">
<div class="c-stickyVideo__text"> </div>
</span>
<div class="c-stickyVideo__buttons">
<button class="c-stickyVideo__close">
<span class="sr-only">close video</span>
<svg class="c-stickyVideo__icon c-icon">
<use xlink:href="#close" />
</svg>
</button>
<button class="c-stickyVideo__mute" data-video-action="mute">
<span class="sr-only">mute video</span>
<svg class="c-stickyVideo__icon c-icon c-icon--black">
<use xlink:href="#volume-mute" />
</svg>
</button>
<button class="c-stickyVideo__unmute" data-video-action="unmute">
<span class="sr-only">unmute video</span>
<svg class="c-stickyVideo__icon c-icon c-icon--black">
<use xlink:href="#volume-unmute" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div class="c-stickyVideo__loader c-loader"></div>
</div>
`;
static stickySelectors = {
element: 'c-stickyVideo',
container: 'c-video__sticky',
inner: 'c-stickyVideo__inner',
innerContainer: 'c-stickyVideo__innerContainer',
title: 'c-stickyVideo__text',
close: 'c-stickyVideo__close',
mute: 'c-stickyVideo__mute',
unmute: 'c-stickyVideo__unmute',
embed: 'c-stickyVideo__embed',
icon: 'c-stickyVideo__icon use',
states: {
loading: 'c-stickyVideo--loading',
stuck: 'c-stickyVideo--stuck',
detatched: 'c-stickyVideo--detached',
anchored: 'c-stickyVideo--anchored',
container: 'c-video--stuck',
muted: 'c-stickyVideo--muted',
cascadedContainer: 'is-static',
},
}
static pageContainerSelector = '.l-main';
static pageContainerListener = null;
static videoListener = null;
static activeIFramePaused = false;
static activeIFrameId = null;
static videoObserver = null;
static containerObserver = null;
static intersectionRatio = 0.3;
static messageType = 'gnca_video';
static messengerRegistered = false;
static disabled = false;
/**
* ID of video iframe
*
* @type {string}
* @member iframeId
*/
iframeId = null;
/**
* Video player element
*
* @type {Element}
* @member $video
*/
$video = null;
videoInstance = null;
/**
* @constructs
*/
constructor( $video, videoInstance ) {
this.$video = $video;
this.iframeId = $video.getAttribute( 'id' );
this.videoInstance = videoInstance;
}
/**
* Initializes sticky video, adding sticky markup and event listeners.
* Closes any open sticky videos in the process.
*
* @method init
* @returns {Element} Video player with sticky video markup added
*/
init() {
// check if sticky video is enabled and ID is set
if ( ! StickyVideo.stickyEnabled() || ! this.iframeId ) {
return this.$video;
}
// remove any current sticky videos
StickyVideo.detachCurrentVideo();
// add sticky markup
const $wrapper = this.addStickyMarkup();
// Register receiver
if ( ! StickyVideo.messengerRegistered ) {
Messenger.registerReceiver( StickyVideo.messageType, StickyVideo );
StickyVideo.messengerRegistered = true;
}
return $wrapper;
}
/**
* Wrap $video tag in sticky video markup.
*
* @method addStickyMarkup
* @returns {Element} Video player with sticky video markup added
*/
addStickyMarkup() {
const $wrapper = document.createElement( 'div' );
$wrapper.innerHTML = StickyVideo.wrapperTemplate;
// update svg sprite path for close button
const $icons = $wrapper.querySelectorAll( `.${StickyVideo.stickySelectors.icon}` );
[].forEach.call( $icons, ( $icon ) => {
updateIconPath( $icon );
});
// add video element
$wrapper.setAttribute( 'id', `${this.iframeId}__sticky` );
$wrapper.classList.add( StickyVideo.stickySelectors.element );
$wrapper.classList.add( StickyVideo.stickySelectors.container );
$wrapper.classList.add( StickyVideo.stickySelectors.states.loading );
$wrapper.querySelector( `.${StickyVideo.stickySelectors.innerContainer}` ).appendChild( this.$video );
$wrapper.querySelector( `.${StickyVideo.stickySelectors.close}` ).addEventListener( 'click', StickyVideo.close );
$wrapper.querySelector( `.${StickyVideo.stickySelectors.mute}` ).addEventListener( 'click', StickyVideo.sendVideoAction );
$wrapper.querySelector( `.${StickyVideo.stickySelectors.unmute}` ).addEventListener( 'click', StickyVideo.sendVideoAction );
this.$video.classList.add( StickyVideo.stickySelectors.embed );
return $wrapper;
}
/**
* Updates video status and potentially title based on info passed in event.
*
* @method receiveMessage
* @param {Object} event - Event object received from child iframe post message
*/
static receiveMessage( data ) {
StickyVideo.updateStatus( data );
if ( data.title ) {
StickyVideo.updateTitle( data.title,
data.show,
data.isAd,
data.iframeId );
}
}
/**
* Updates sticky behavior based on new video status.
*
* @method updateStatus
* @param {string} status - new video state
* @param {string} iframeId - ID of video iframe in question
*/
static updateStatus( data ) {
const { status } = data;
const { iframeId } = data;
switch ( status ) {
case 'playing':
StickyVideo.activateVideo( iframeId );
StickyVideo.updateVolume( data );
break;
case 'paused':
StickyVideo.pauseVideo( iframeId );
break;
case 'loaded':
StickyVideo.removeLoadIndicator( iframeId );
break;
case 'mute':
StickyVideo.updateVolume( data );
break;
case 'upNext':
if ( data.item ) {
StickyVideo.updateTitle( data.item.title, 'Up Next', false, iframeId );
}
break;
case 'pipEnter':
// Player entered picture in picture mode, disable sticky video.
StickyVideo.disable();
break;
case 'pipLeave':
// Player left picture in picture mode,
// bring video in view and re-enable sticky video.
StickyVideo.focusVideo();
StickyVideo.enable();
break;
default:
break;
}
}
/**
* Updates sticky video title, showing default message if ad is playing.
* Include show name if this is an episode or clip from a program.
*
* @method updateTitle
* @param {string} iframeId - ID of video iframe in question
*/
static updateTitle( title, show, isAd, iframeId ) {
if ( isAd ) {
StickyVideo.setTitle( 'Video will begin after these messages...', iframeId );
} else if ( ! show || 0 === title.indexOf( show ) ) {
// If show name is part of episode name, do not display show name
StickyVideo.setTitle( title, iframeId );
} else {
StickyVideo.setTitle( `${show}: ${title}`, iframeId );
}
}
/**
* Setup sticky player and listeners if this player is not yet active.
* Reset player paused parameter.
*
* @method activateVideo
* @param {string} iframeId - ID of video iframe in question
*/
static activateVideo( iframeId ) {
if ( StickyVideo.activeIFrameId !== iframeId ) {
StickyVideo.detachCurrentVideo();
StickyVideo.activeIFrameId = iframeId;
StickyVideo.removeLoadIndicator( iframeId );
StickyVideo.addVideoObserver( iframeId );
if ( StickyVideo.anchorEnabled() ) {
const $player = document.querySelector( `[data-displayinline-player-id="${iframeId}"]` );
if ( $player && 'false' !== $player.dataset.displayinlineAnchor ) {
StickyVideo.addContainerObserver( iframeId );
}
}
}
StickyVideo.activeIFramePaused = false;
}
/**
* Pause sticky player if video is currently not stuck.
* If user pauses the player while unsticky we don't want it to stick.
* If users pauses the player while sticky we don't want it to unstick.
*
* @method pauseVideo
* @param {string} iframeId - ID of video iframe in question
*/
static pauseVideo( iframeId ) {
if ( ! StickyVideo.isStuck( iframeId ) ) {
StickyVideo.activeIFramePaused = true;
}
}
/**
* Turn off all sticky video functionality for the current player.
*
* @method detatchCurrentVideo
*/
static detachCurrentVideo() {
if ( StickyVideo.activeIFrameId && null !== document.querySelector( `#${StickyVideo.activeIFrameId}` ) ) {
Messenger.sendMessage( StickyVideo.activeIFrameId, StickyVideo.messageType, 'detached' );
// remove sticky observer
const $video = StickyVideo.getActiveVideo();
StickyVideo.unstick( $video );
StickyVideo.removeVideoObserver( StickyVideo.activeIFrameId );
customEvent.fire( $video, customEvent.STICKY_DETACHED );
customEvent.fire( window, customEvent.STICKY_OFF, {
target: $video,
selector: StickyVideo.stickySelectors.element,
});
// remove container obserer and all anchoring styles
if ( StickyVideo.anchorEnabled() ) {
StickyVideo.removeContainerObserver();
$video.classList.remove( StickyVideo.stickySelectors.states.anchored );
$video.style.top = ''; // eslint-disable-line no-param-reassign
}
StickyVideo.activeIFrameId = '';
}
}
/**
* Add intersection observer to detect when player enters and exits view.
*
* @method addVideoObserver
* @param {string} iframeId - ID of video iframe in question
*/
static addVideoObserver( iframeId ) {
if ( null === StickyVideo.videoObserver ) {
const $video = StickyVideo.getVideo( iframeId );
const $wrapper = StickyVideo.getWrapper( iframeId );
$wrapper.dataset.alwaysObserve = 'true';
StickyVideo.videoObserver = new InView({
threshold: StickyVideo.intersectionRatio,
});
StickyVideo.videoObserver.init();
StickyVideo.videoObserver.startWatching( $wrapper );
StickyVideo.videoListener = StickyVideo.handleVideoIntersection.bind( this, $video );
$wrapper.addEventListener( customEvent.IN_VIEW, StickyVideo.videoListener );
}
}
/**
* Turn off intersection observer listener for specified player.
*
* @method removeVideoObserver
* @param {string} iframeId - ID of video iframe in question
*/
static removeVideoObserver( iframeId ) {
const $wrapper = StickyVideo.getWrapper( iframeId );
StickyVideo.videoObserver.stopWatching( $wrapper );
StickyVideo.videoObserver = null;
$wrapper.removeEventListener( customEvent.IN_VIEW, StickyVideo.videoListener );
}
/**
* Unsticks or sticks video depending on whether player is in our out of view.
*
* @method handleVideoIntersection
* @param {Element} $video - Video player element
* @param {Object} event - Custom event fired by InView intersection observer module
*/
static handleVideoIntersection( $video, event ) {
// don't toggle stickiness if video is inactive or paused
if ( null === StickyVideo.activeIFrameId || true === StickyVideo.activeIFramePaused ) {
return;
}
if ( event && event.detail ) {
if ( event.detail.ratioInView > StickyVideo.intersectionRatio ) {
StickyVideo.unstick( $video );
} else {
StickyVideo.stick( $video );
}
}
}
/**
* Initialize observer to check if container edges have entered the viewport
* If so, we need to anchor the video to the viewport so it doesn't go beyond the container.
*
* @method addContainerObserver
*/
static addContainerObserver() {
if ( null === StickyVideo.containerObserver ) {
const $pageContainer = document.querySelector( StickyVideo.pageContainerSelector );
$pageContainer.dataset.alwaysObserve = 'true';
StickyVideo.pageContainerListener = StickyVideo.handleContainerIntersection
.bind( this, $pageContainer );
$pageContainer.addEventListener( customEvent.IN_BOUNDS, StickyVideo.pageContainerListener );
StickyVideo.containerObserver = new InBounds({
selector: StickyVideo.pageContainerSelector,
});
StickyVideo.containerObserver.init();
}
}
/**
* Remove container observer when video is deactivated
*
* @method removeContainerObserver
*/
static removeContainerObserver() {
const $pageContainer = document.querySelector( StickyVideo.pageContainerSelector );
if ( StickyVideo.containerObserver ) {
StickyVideo.containerObserver.stopWatching( $pageContainer );
StickyVideo.containerObserver = null;
}
$pageContainer.removeEventListener( customEvent.IN_BOUNDS, StickyVideo.pageContainerListener );
}
/**
* Anchor the video to the edge of the container if the container bounds have been reached.
* Remove the anchor if it is no longer needed and resume fixed positioning.
*
* @method handleContainerIntersection
* @param {Element} $pageContainer - Page container element
* @param {object} event - InBounds event object
*/
static handleContainerIntersection( $pageContainer, event ) {
// nothing to handle if video is inactive or paused
if ( null === StickyVideo.activeIFrameId || true === StickyVideo.activeIFramePaused ) {
return;
}
// nothing to handle if sticky video is not stuck
// if video is a mini sticky video, only anchor for lower bound.
if ( event && event.detail ) {
const $video = StickyVideo.getActiveVideo();
if ( event.detail.isInBounds ) {
StickyVideo.removeAnchor( $video );
} else {
StickyVideo.addAnchor( $video );
}
}
}
/**
* Sticks video player to right rail.
*
* @method stick
* @param {Element} $video - Video player element
*/
static stick( $video ) {
// Bail if sticky feature is disabled.
if ( StickyVideo.disabled ) {
return;
}
if ( ! $video.classList.contains( StickyVideo.stickySelectors.states.stuck ) ) {
Messenger.sendMessage( StickyVideo.activeIFrameId, StickyVideo.messageType, 'stuck' );
$video.classList.remove( StickyVideo.stickySelectors.states.detatched );
$video.classList.add( StickyVideo.stickySelectors.states.stuck );
$video.parentNode.classList.add( StickyVideo.stickySelectors.states.container );
customEvent.fire( window, customEvent.STICKY_ON, {
target: $video,
selector: StickyVideo.stickySelectors.element,
});
}
}
/**
* Unsticks video from right rail
*
* @method unstick
* @param {Element} $video - Video player element
*/
static unstick( $video ) {
// Bail if sticky feature is disabled.
if ( StickyVideo.disabled ) {
return;
}
if ( $video.classList.contains( StickyVideo.stickySelectors.states.stuck ) ) {
Messenger.sendMessage( StickyVideo.activeIFrameId, StickyVideo.messageType, 'restored' );
$video.classList.remove( StickyVideo.stickySelectors.states.stuck );
$video.classList.add( StickyVideo.stickySelectors.states.detatched );
$video.parentNode.classList.remove( StickyVideo.stickySelectors.states.container );
customEvent.fire( window, customEvent.STICKY_OFF, {
target: $video,
selector: StickyVideo.stickySelectors.element,
});
}
}
/**
* Add anchor to position sticky video absolutely at current position.
* Prevents it from going over header / footer.
*
* @method addAnchor
* @param {Element} $video - Video player element
*/
static addAnchor( $video ) {
// Bail if sticky feature is disabled.
if ( StickyVideo.disabled ) {
return;
}
if ( ! $video.classList.contains( StickyVideo.stickySelectors.states.anchored ) ) {
const bounds = $video.getBoundingClientRect();
const $pageContainer = document.querySelector( StickyVideo.pageContainerSelector );
const containerBounds = $pageContainer.getBoundingClientRect();
let containerMarginTop = getComputedStyle( $pageContainer ).getPropertyValue( 'margin-top' );
containerMarginTop = parseInt( containerMarginTop.replace( 'px', '' ), 10 );
const containerOffsetMin = containerBounds.top - containerMarginTop + window.pageYOffset;
const containerOffsetMax = containerBounds.bottom + window.pageYOffset - bounds.height;
let offset = 0;
if ( ! $video.classList.contains( StickyVideo.stickySelectors.states.anchored ) ) {
offset = bounds.top + window.pageYOffset;
// min offset to prevent sticky video from overlapping header ad or footer
offset = Math.max( offset, containerOffsetMin );
offset = Math.min( offset, containerOffsetMax );
$video.style.top = `${offset}px`; // eslint-disable-line no-param-reassign
$video.classList.add( StickyVideo.stickySelectors.states.anchored );
// set position: static for cascaded container nodes.
// TO BE REMOVED WHEN STICKY PLAYER ANCHORS TO THE BOTTOM.
const playerId = $video.getAttribute( 'id' ).replace( '__sticky', '' );
const $containers = document.querySelectorAll( `[data-container-of-video="${playerId}"]` );
[].forEach.call( $containers, ( $container ) => {
$container.classList.add( StickyVideo.stickySelectors.states.cascadedContainer );
});
customEvent.fire( window, customEvent.STICKY_OFF, {
target: $video,
selector: StickyVideo.stickySelectors.element,
});
}
}
}
/**
* Remove anchor and resume fixed positioning of sticky video player
*
* @method removeAnchor
* @param {Element} $video - Video player element
*/
static removeAnchor( $video ) {
// Bail if sticky feature is disabled.
if ( StickyVideo.disabled ) {
return;
}
if ( $video.classList.contains( StickyVideo.stickySelectors.states.anchored ) ) {
$video.style.top = ''; // eslint-disable-line no-param-reassign
$video.classList.remove( StickyVideo.stickySelectors.states.anchored );
// set position: static for cascaded container nodes.
// TO BE REMOVED WHEN STICKY PLAYER ANCHORS TO THE BOTTOM.
const playerId = $video.getAttribute( 'id' ).replace( '__sticky', '' );
const $containers = document.querySelectorAll( `[data-container-of-video="${playerId}"]` );
[].forEach.call( $containers, ( $container ) => {
$container.classList.add( StickyVideo.stickySelectors.states.cascadedContainer );
});
customEvent.fire( window, customEvent.STICKY_ON, {
target: $video,
selector: StickyVideo.stickySelectors.element,
});
}
}
/**
* Stops the video and unsticks the player if user clicks the close button
*
* @method close
*/
static close( evt ) {
gn_analytics.Analytics.track(['adobe', 'ga'], {
action: 'close sticky video',
data: {
'video.stickyclosed': '1',
},
target: evt.currentTarget,
});
StickyVideo.detachCurrentVideo();
}
/**
* Sends a message to the video player on click.
*
* @method sendVideoAction
*/
static sendVideoAction( evt ) {
if ( evt.currentTarget.dataset.videoAction ) {
Messenger.sendMessage( StickyVideo.activeIFrameId,
StickyVideo.messageType,
evt.currentTarget.dataset.videoAction );
}
}
/**
* Removes loading spinner once video (or ad) has loaded.
*
* @method removeLoadIndicator
* @param {string} iframeId - ID of video iframe in question
*/
static removeLoadIndicator( iframeId ) {
const $video = StickyVideo.getVideo( iframeId );
if ( $video ) {
$video.classList.remove( StickyVideo.stickySelectors.states.loading );
}
}
/**
* Updates the text of the title bar above the sticky player.
*
* @method setTitle
* @param {string} title - text to place in the title bar
* @param {string} iframeId - ID of video iframe in question
*/
static setTitle( title, iframeId ) {
const $video = StickyVideo.getVideo( iframeId );
if ( $video ) {
const $titleNode = $video.querySelector( `.${StickyVideo.stickySelectors.title}` );
$titleNode.textContent = title;
}
}
/**
* Show / hide mute unmute buttons.
*
* @method updateVolume
* @param {object} data
*/
static updateVolume( data ) {
const $video = StickyVideo.getVideo( data.iframeId );
if ( $video ) {
if ( data.muted ) {
$video.classList.add( StickyVideo.stickySelectors.states.muted );
} else {
$video.classList.remove( StickyVideo.stickySelectors.states.muted );
}
}
}
/**
* Bring the video in view.
*/
static focusVideo() {
const $video = StickyVideo.getActiveVideo();
if ( $video && $video.parentNode ) {
const videoRect = $video.parentNode.getBoundingClientRect();
const centeredPosY = ( window.innerHeight - videoRect.height ) * 0.5;
window.scrollTo( window.scrollX, window.scrollY + videoRect.top - centeredPosY );
}
}
/**
* Disable sticky video
*/
static disable() {
StickyVideo.disabled = true;
}
/**
* Enable sticky video
*/
static enable() {
StickyVideo.disabled = false;
}
/**
* Gets currently active video element.
*
* @method getActiveVideo
* @returns {Element} Video player element
*/
static getActiveVideo() {
return StickyVideo.getVideo( StickyVideo.activeIFrameId );
}
/**
* Gets specified video player element.
*
* @method getVideo
* @param {string} iframeId - ID of video iframe in question
* @returns {Element} Video player element
*/
static getVideo( iframeId ) {
if ( iframeId ) {
return document.querySelector( `#${iframeId}__sticky` );
}
return false;
}
/**
* Get's the CSS selector for the specified video player's parent element.
* Useful because we need to add our Intersection Observer to the player's parent.
*
* @method getWrapperSelector
* @param {string} iframeId - ID of video iframe in question
* @param {string} CSS selector to identify the player's parent element.
*/
static getWrapperSelector( iframeId ) {
if ( iframeId ) {
return `[${StickyVideo.stickyAttribute}="${iframeId}"]`;
}
return false;
}
/**
* Get's the CSS selector for the specified video player's parent element.
* Useful because we need to add our Intersection Observer to the player's parent.
*
* @method getWrapper
* @param {string} iframeId - ID of video iframe in question
* @param {string} CSS selector to identify the player's parent element.
*/
static getWrapper( iframeId ) {
const wrapperSelector = StickyVideo.getWrapperSelector( iframeId );
let $wrapper = document.querySelector( wrapperSelector );
// if data-displayinline-container is set, watch the container instead of video iframe
if ( $wrapper.dataset.displayinlineContainer ) {
$wrapper = document.querySelector( `[data-container-id="${$wrapper.dataset.displayinlineContainer}"]` );
}
return $wrapper;
}
/**
* Get the SVG sprite path from gnca_settings object
*
* @method getSVGSpritePath
* @return {string} file path the SVG sprite.
*/
static getSVGSpritePath() {
/* eslint-disable camelcase */
if ( 'undefined' !== typeof ( gnca_settings )
&& gnca_settings.svg_sprite_uri
&& gnca_settings.svg_sprite_uri ) {
return gnca_settings.svg_sprite_uri;
}
return '';
/* eslint-enable camelcase */
}
/**
* Determines whether sticky video should be enabled on this page based on global settings.
*
* @method stickyEnabled
* @return {boolean} T/F depending on whether or not sticky video is enabled.
*/
static stickyEnabled() {
/* eslint-disable camelcase */
if ( 'undefined' !== typeof ( gnca_settings )
&& gnca_settings.video_settings
&& gnca_settings.video_settings.sticky_enabled ) {
return Boolean( gnca_settings.video_settings.sticky_enabled );
}
/* eslint-enable camelcase */
return false;
}
/**
* Determines whether sticky video anchoring is enabled on this page based on global settings.
*
* @method anchorEnabled
* @return {boolean} T/F depending on whether or not anchor video is enabled.
*/
static anchorEnabled() {
/* eslint-disable camelcase */
if ( 'undefined' !== typeof ( gnca_settings )
&& gnca_settings.video_settings
&& gnca_settings.video_settings.anchor_enabled ) {
return Boolean( gnca_settings.video_settings.anchor_enabled );
}
/* eslint-enable camelcase */
return false;
}
/**
* Determines if player in question is currently stickied.
*
* @method isStuck
* @param {string} iframeId - ID of video iframe in question
* @param {boolean} T/F depending on whether or not specified player is stickied.
*/
static isStuck( iframeId ) {
return StickyVideo.getVideo( iframeId ).classList
.contains( StickyVideo.stickySelectors.states.stuck );
}
}
export default StickyVideo;