diff --git a/app/.env b/app/.env
index 747a905..193110b 100644
--- a/app/.env
+++ b/app/.env
@@ -26,6 +26,7 @@ CLAY_SCHEDULING_ENABLED=true
GOOGLE_CONSUMER_KEY=646733424261-m084l8oaodaubngvpte7f2hufc62l0d6.apps.googleusercontent.com
GOOGLE_CONSUMER_SECRET=KyWza3ugQbwEvn4J1nVA3RiA
GOOGLE_PROFILE_URL=https://www.googleapis.com/oauth2/v3/userinfo
+YOUTUBE_API_KEY=AIzaSyCtD1a3SWW3QFzyfkLi0NpwvHL9InosQi8
CLAY_SITE_NAME="Clay Demo"
CLAY_SITE_HOST="localhost"
diff --git a/app/components/article/model.js b/app/components/article/model.js
index 640df04..6335249 100644
--- a/app/components/article/model.js
+++ b/app/components/article/model.js
@@ -22,7 +22,7 @@ function stripHeadlineTags(oldHeadline) {
/**
* sanitize headline
- * @param {object} data
+ * @param {Object} data
*/
function sanitizeInputs(data) {
if (has(data.headline)) {
@@ -33,8 +33,8 @@ function sanitizeInputs(data) {
/**
* set the publish date from the locals (even if it's already set),
* and format it correctly
- * @param {object} data
- * @param {object} locals
+ * @param {Object} data
+ * @param {Object} locals
*/
function formatDate(data, locals) {
if (_get(locals, 'date')) {
@@ -51,8 +51,8 @@ function formatDate(data, locals) {
/**
* set the canonical url from the locals (even if it's already set)
- * @param {object} data
- * @param {object} locals
+ * @param {Object} data
+ * @param {Object} locals
*/
function setCanonicalUrl(data, locals) {
if (_get(locals, 'publishUrl')) {
@@ -62,7 +62,7 @@ function setCanonicalUrl(data, locals) {
/**
* Set the feed image to the lede url if it isn't already set
- * @param {object} data
+ * @param {Object} data
*/
function generateFeedImage(data) {
if (data.ledeUrl) {
diff --git a/app/components/article/schema.yaml b/app/components/article/schema.yaml
index 4051f85..5fe9fdd 100644
--- a/app/components/article/schema.yaml
+++ b/app/components/article/schema.yaml
@@ -61,6 +61,7 @@ content:
- image
- code-sample
- list
+ - youtube
ledeUrl:
_label: Lede Image URL
diff --git a/app/components/youtube-player-head/bootstrap.yml b/app/components/youtube-player-head/bootstrap.yml
new file mode 100644
index 0000000..c181c03
--- /dev/null
+++ b/app/components/youtube-player-head/bootstrap.yml
@@ -0,0 +1,3 @@
+_components:
+ youtube-player-head:
+ allowed: true
diff --git a/app/components/youtube-player-head/schema.yml b/app/components/youtube-player-head/schema.yml
new file mode 100644
index 0000000..1deb251
--- /dev/null
+++ b/app/components/youtube-player-head/schema.yml
@@ -0,0 +1,2 @@
+_description: |
+ Note: Required for `video-player` component. YouTube API scripts, add component to head for best YouTube player performance.
diff --git a/app/components/youtube-player-head/template.hbs b/app/components/youtube-player-head/template.hbs
new file mode 100644
index 0000000..b98004c
--- /dev/null
+++ b/app/components/youtube-player-head/template.hbs
@@ -0,0 +1,19 @@
+
+{{#unless @root.locals.edit}}
+ {{! youtube flags for `video-player` component instances
+ player instances will first look for `youtubeApiReady` to be `true`
+ if `false`, player instances will listen for the event `youtube-event:youtube-api-ready` }}
+
+
+ {{! add the YouTube API script to the head for fastest player creation }}
+
+{{/unless}}
diff --git a/app/components/youtube/bootstrap.yml b/app/components/youtube/bootstrap.yml
new file mode 100644
index 0000000..753cebd
--- /dev/null
+++ b/app/components/youtube/bootstrap.yml
@@ -0,0 +1,14 @@
+_components:
+ youtube:
+ videoId: ''
+ videoType: 'editorial'
+ videoLocation: 'article'
+ playerCaption: ''
+ autoPlay: true
+ autoPlayNextVideo: true
+ videoPlaylist: ''
+ playerBorderTopCTA: 'Watch'
+ playerBorderTop: false
+ playerBorderBottom: false
+ previousTypeRelated: false
+ customPlay: false
diff --git a/app/components/youtube/client.js b/app/components/youtube/client.js
new file mode 100644
index 0000000..cee3a72
--- /dev/null
+++ b/app/components/youtube/client.js
@@ -0,0 +1,75 @@
+'use strict';
+
+const youtubeVideoPlayer = require('../../services/universal/youtube-video-player'),
+ { Visible, isElementNotHidden } = require('../../services/client/visibility');
+
+module.exports = (el) => {
+ const autoplay = el.getAttribute('data-autoplay-video') === 'true',
+ videoConfig = {
+ videoContainerId: el.getAttribute('data-element-id').trim(),
+ videoId: el.getAttribute('data-video-id').trim(),
+ // player variables and settings
+ playerParams: {
+ loop: 1,
+ listType: 'playlist',
+ list: el.getAttribute('data-playlist').trim(),
+ autoplay: autoplay ? 1 : 0,
+ controls: 1,
+ enablejsapi: 1,
+ modestbranding: 1,
+ rel: 0,
+ showinfo: 0,
+ wmode: 'transparent'
+ },
+ customParams: {
+ autoPlayNextVideo: el.getAttribute('data-autoplay-next-video').trim(),
+ trackVideoType: el.getAttribute('data-track-video-type').trim(),
+ customPlayer: el.getAttribute('data-custom-play').trim(),
+ templateid: el.getAttribute('data-element-id').trim(),
+ muted: autoplay // always mute autplaying videos
+ }
+ },
+ visible = new Visible(el, { preloadThreshold: 800 });
+
+ if (videoConfig.customParams.trackVideoType === 'Sponsored') {
+ videoConfig.playerParams.list = '';
+ }
+
+ // when the video player element enters the viewport, load the video(s)
+ if (visible.preload && isElementNotHidden(el)) {
+ // if the YouTube api is ready the videos(s) can be loaded
+ if (window.youtubeApiReady === true) {
+ youtubeVideoPlayer.init(videoConfig);
+ } else {
+ // wait and listen for the YouTube api to be ready before loading the video(s)
+ document.addEventListener('youtube-event:youtube-api-ready', function () {
+ youtubeVideoPlayer.init(videoConfig);
+ });
+ }
+ } else {
+ visible.on('preload', function () {
+ youtubeVideoPlayer.init(videoConfig);
+ });
+ }
+
+ /**
+ * Player start event
+ * we don't need to send an event here, updating the video id for posterity
+ */
+ document.addEventListener('player-start-' + videoConfig.videoContainerId, function (evt) {
+ const hasChanged = el.getAttribute('data-video-id') !== evt.player.videoId;
+
+ if (hasChanged) {
+ updateElementAttributes(el, evt.player);
+ }
+ });
+};
+
+/**
+ * Updates Element attributes
+ * @param {Object} el - DOM node element
+ * @param {Object} config - Attributes values from player
+ */
+function updateElementAttributes(el, config) {
+ el.setAttribute('data-video-id', config.videoId);
+}
diff --git a/app/components/youtube/model.js b/app/components/youtube/model.js
new file mode 100644
index 0000000..fe18281
--- /dev/null
+++ b/app/components/youtube/model.js
@@ -0,0 +1,69 @@
+'use strict';
+
+const _get = require('lodash/get'),
+ { getVideoDetails } = require('../../services/universal/youtube'),
+ defaultPlayerBorderTopCTA = 'Watch';
+
+/**
+ * Override various settings by type of video
+ * @param {Object} data
+ */
+function updateSettingsByType(data) {
+ switch (data.videoType) {
+ case 'related':
+ // By default, display borders and CTA when `related` type is first selected, afterwards accept user's selection
+ data.playerBorderTopCTA = !data.previousTypeRelated && !data.playerBorderTopCTA ? defaultPlayerBorderTopCTA : data.playerBorderTopCTA;
+ data.playerBorderTop = !data.previousTypeRelated ? true : data.playerBorderTop;
+ data.playerBorderBottom = !data.previousTypeRelated ? true : data.playerBorderBottom;
+ data.previousTypeRelated = true;
+ break;
+ case 'sponsored':
+ data.autoPlay = false;
+ data.autoPlayNextVideo = false;
+ break;
+ default:
+ // Toggle borders off if user previously selected `related` type. `sponsored` and `editorial` types share defaults
+ data.playerBorderTop = data.previousTypeRelated ? false : data.playerBorderTop;
+ data.playerBorderBottom = data.previousTypeRelated ? false : data.playerBorderBottom;
+ data.previousTypeRelated = false;
+ }
+}
+
+function clearVideoId(data) {
+ data.videoId = (data.videoId || '').split('&')[0];
+
+ return data;
+}
+
+function setVideoDetails(data, videoDetails) {
+ if (!videoDetails.title) {
+ data.videoValid = false;
+
+ return data;
+ }
+
+ const maxResThumb = _get(videoDetails, 'thumbnails.maxres.url');
+
+ data.videoValid = true;
+ data.channelName = videoDetails.channelTitle;
+ data.videoTitle = videoDetails.title;
+ data.videoThumbnail = maxResThumb ? maxResThumb : _get(videoDetails, 'thumbnails.high.url'); // get the maxres if available, otherwise get the high res which we know will be there
+ data.videoDuration = videoDetails.duration;
+
+ return data;
+}
+
+module.exports.save = (uri, data) => {
+ clearVideoId(data);
+ updateSettingsByType(data);
+
+ if (data.videoId) {
+ return getVideoDetails(data.videoId)
+ .then(videoDetails => setVideoDetails(data, videoDetails));
+ }
+
+ data.videoValid = true; // technically not an invalid video because no videoId so we don't want to show an error message in edit mode
+
+ return data;
+};
+
diff --git a/app/components/youtube/schema.yml b/app/components/youtube/schema.yml
new file mode 100644
index 0000000..bf2292d
--- /dev/null
+++ b/app/components/youtube/schema.yml
@@ -0,0 +1,143 @@
+_description: |
+ Youtube video embed for Clay. For technical reasons, YouTube embeds don't display a preview in edit mode.
+ Preview the video by previewing the full page (from the Clay toolbar). Embeds can display a specific video ID.
+videoId:
+ _label: YouTube Video ID
+ _publish: videoPlayerId
+ _has:
+ input: text
+
+videoType:
+ _label: Type
+ _has:
+ input: radio
+ options:
+ -
+ name: Editorial (Original)
+ value: editorial
+ -
+ name: Editorial (External)
+ value: embedded
+ -
+ name: Related
+ value: related
+ -
+ name: Sponsored
+ value: sponsored
+ validate:
+ required: true
+ requiredMessage: Please select a video type
+
+videoSize:
+ _label: Size
+ _has:
+ input: radio
+ options:
+ -
+ name: Column Width
+ value: column-width
+ -
+ name: Break-out
+ value: break-out
+ _reveal:
+ field: videoType
+ operator: ===
+ values:
+ - sponsored
+ - editorial
+
+playerHeadline:
+ _label: Video Headline
+ _has:
+ input: text
+ _reveal:
+ field: videoType
+ value: related
+
+playerCaption:
+ _label: Video Caption
+ _has: text
+
+autoPlay:
+ _label: Autoplay with muted audio on Page Load
+ _has: checkbox
+
+autoPlayNextVideo:
+ _label: Automatically start next video
+ _has: checkbox
+ _reveal:
+ field: videoType
+ operator: '!=='
+ value: sponsored
+
+videoPlaylist:
+ _label: Youtube Playlist (defaults to current site)
+ _reveal:
+ field: autoPlayNextVideo
+ operator: truthy
+ _has:
+ input: text
+ validate:
+ required:
+ field: autoPlayNextVideo
+ operator: truthy
+ requiredMessage: Video Player should contain video playlist
+ help: Playlists are used for auto playing additional videos in the YouTube component after the assigned video is finished. Only required for videos that are selected to automatically start next video above
+
+playerBorderTopCTA:
+ _label: Top Border Title
+ _has: text
+
+playerBorderTop:
+ _label: Show Top Border
+ _has: checkbox
+
+playerBorderBottom:
+ _label: Show Bottom Border
+ _has: checkbox
+
+customPlay:
+ _subscribe: customPlay
+
+_groups:
+ settings:
+ fields:
+ - autoPlay (Autoplay)
+ - autoPlayNextVideo (Autoplay)
+ - videoPlaylist (Autoplay)
+ - playerBorderTopCTA (Borders and Border Title)
+ - playerBorderTop (Borders and Border Title)
+ - playerBorderBottom (Borders and Border Title)
+ inline:
+ fields:
+ - videoId
+ - videoType
+ - videoSize
+ - playerHeadline
+ - playerCaption
+ _label: Youtube Video Player
+ _placeholder:
+ text: Youtube Video Player
+ height: 100px
+ ifEmpty: videoId
+
+# set by bootstrap or first-run
+videoLocation:
+ help: For analytics, to tell what kind of page the video is on
+
+# non-user-editable fields, set by model.js
+previousTypeRelated:
+ help: Toggle for displaying convenience-based changes to player borders based on previously selected video type. Set by model.js
+videoValid:
+ help: Used to decide whether or not to display a video in the template
+ _placeholder:
+ text: Invalid video id ${videoId}. This video will not display.
+ permanent: true
+channelName:
+ help: Name of the channel the video comes from. Used to decide whether or not to listen to GTM triggers for this video
+videoTitle:
+ help: Title of the video
+videoThumbnail:
+ help: maxres video thumbnail
+videoDuration:
+ help: video duration in seconds
diff --git a/app/components/youtube/template.hbs b/app/components/youtube/template.hbs
new file mode 100644
index 0000000..e0e8553
--- /dev/null
+++ b/app/components/youtube/template.hbs
@@ -0,0 +1,53 @@
+{{ set 'uniquePlayerID' (randomString 'youtube-player-') }}
+{{#ifAny @root.locals.edit videoValid}}
+
+ {{#if playerBorderTopCTA }}
+
+ {{/if}}
+
+
+ {{#unless @root.locals.edit}}
+
+
+ {{#ifAll playerHeadline (compare videoType 'related')}}
+
{{ playerHeadline }}
+ {{/ifAll}}
+
+ {{#if playerCaption}}
+
+ {{ playerCaption }}
+
+ {{/if}}
+
+{{/ifAny}}
diff --git a/app/layouts/layout-simple/schema.yml b/app/layouts/layout-simple/schema.yml
index 350041c..6c70002 100644
--- a/app/layouts/layout-simple/schema.yml
+++ b/app/layouts/layout-simple/schema.yml
@@ -18,6 +18,7 @@ headLayout:
include:
- meta-site
- meta-icons
+ - youtube-player-head
top:
_componentList:
include:
diff --git a/app/layouts/layout/schema.yml b/app/layouts/layout/schema.yml
index 1b40f9a..dd2376e 100644
--- a/app/layouts/layout/schema.yml
+++ b/app/layouts/layout/schema.yml
@@ -18,6 +18,7 @@ headLayout:
include:
- meta-site
- meta-icons
+ - youtube-player-head
top:
_componentList:
include: []
diff --git a/app/package-lock.json b/app/package-lock.json
index 99a23d1..1f56a6a 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -726,6 +726,15 @@
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz",
"integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw=="
},
+ "@nymag/dom": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@nymag/dom/-/dom-1.3.2.tgz",
+ "integrity": "sha1-fm74OzzETc8DNESZqsSeWqorsTI=",
+ "requires": {
+ "domify": "^1.4.0",
+ "lodash": "^4.8.2"
+ }
+ },
"@nymag/vueify": {
"version": "9.4.5",
"resolved": "https://registry.npmjs.org/@nymag/vueify/-/vueify-9.4.5.tgz",
@@ -4083,6 +4092,11 @@
"domelementtype": "1"
}
},
+ "domify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/domify/-/domify-1.4.0.tgz",
+ "integrity": "sha1-EUg2F/dk+GlZdbS9x5sU8IA7Yps="
+ },
"domutils": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
@@ -5104,6 +5118,11 @@
"parse-filepath": "^1.0.1"
}
},
+ "fingerprintjs2": {
+ "version": "1.8.6",
+ "resolved": "https://registry.npmjs.org/fingerprintjs2/-/fingerprintjs2-1.8.6.tgz",
+ "integrity": "sha512-uarmLgW2QXzu1Ljw3PDcQLJ69w8uT42odab//KOZ+NZsAyAudq5ZvlImEXYd/5sJbtBa5TSQ5OKZX1rkpK/t5w=="
+ },
"flagged-respawn": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
@@ -7550,6 +7569,11 @@
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.5.1.tgz",
"integrity": "sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw=="
},
+ "js-cookie": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.0.tgz",
+ "integrity": "sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s="
+ },
"js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
diff --git a/app/package.json b/app/package.json
index 887de94..f0d0a5b 100644
--- a/app/package.json
+++ b/app/package.json
@@ -16,6 +16,7 @@
"author": "",
"license": "MIT",
"dependencies": {
+ "@nymag/dom": "^1.3.2",
"@nymdev/health-check": "^0.1.3",
"amphora": "7.2.0",
"amphora-event-bus-redis": "0.0.1",
@@ -34,6 +35,7 @@
"date-fns": "^1.29.0",
"express": "^4.16.3",
"express-session": "^1.15.6",
+ "fingerprintjs2": "^1.6.1",
"fold-to-ascii": "^4.0.0",
"glob": "^7.1.3",
"he": "^1.1.1",
@@ -41,6 +43,7 @@
"html-truncate": "^1.2.2",
"html-word-count": "^2.0.0",
"isomorphic-fetch": "^2.2.1",
+ "js-cookie": "^2.2.0",
"jsonp-client": "^1.1.1",
"lodash": "^4.17.5",
"moment": "^2.24.0",
diff --git a/app/services/client/visibility.js b/app/services/client/visibility.js
new file mode 100644
index 0000000..f2e8692
--- /dev/null
+++ b/app/services/client/visibility.js
@@ -0,0 +1,197 @@
+'use strict';
+
+const $window = window,
+ $document = document,
+ _throttle = require('lodash/throttle'),
+ Eventify = require('eventify');
+let list = [],
+ Visible, VisibleEvent;
+
+/**
+ * @param {number} a
+ * @param {number} b
+ * @returns {*}
+ * @see http://jsperf.com/math-min-vs-if-condition-vs/8
+ */
+function min(a, b) {
+ return a < b ? a : b;
+}
+
+/**
+ * @param {number} a
+ * @param {number} b
+ * @returns {*}
+ * @see http://jsperf.com/math-min-vs-if-condition-vs/8
+ */
+function max(a, b) {
+ return a > b ? a : b;
+}
+
+/**
+ * Fast loop through watched elements
+ */
+function updateVisibility() {
+ list.forEach(updateVisibilityForItem);
+}
+
+/**
+ * updates seen property
+ * @param {Visible} item
+ * @param {{}} event
+ * @fires Visible#shown
+ * @fires Visible#hidden
+ */
+function updateSeen(item, event) {
+ var px = event.visiblePx,
+ percent = event.visiblePercent;
+
+ // if some pixels are visible and we're greater/equal to threshold
+ if (px && percent >= item.shownThreshold && !item.seen) {
+ item.seen = true;
+ setTimeout(function () {
+ item.trigger('shown', new VisibleEvent('shown', event));
+ }, 15);
+
+ // if no pixels or percent is less than threshold
+ } else if ((!px || percent < item.hiddenThreshold) && item.seen) {
+ item.seen = false;
+ setTimeout(function () {
+ item.trigger('hidden', new VisibleEvent('hidden', event));
+ }, 15);
+ }
+}
+
+/**
+ * sets preload property
+ * @param {Visible} item
+ * @param {Object} event
+ * @param {number} innerHeight
+ * @fires Visible#preload
+ */
+function updatePreload(item, event, innerHeight) {
+ if (!item.preload && item.preloadThreshold && shouldBePreloaded(event.target, event.rect, item.preloadThreshold, innerHeight)) {
+ item.preload = true;
+ setTimeout(function () {
+ item.trigger('preload', new VisibleEvent('preload', event));
+ }, 15);
+ }
+}
+
+/**
+ * Trigger events
+ * @param {Visible} item
+ */
+function updateVisibilityForItem(item) {
+ var rect = item.el.getBoundingClientRect(),
+ innerHeight = $window.innerHeight || $document.documentElement.clientHeight,
+ px = getVerticallyVisiblePixels(rect, innerHeight),
+ percent = px / (rect.height || innerHeight),
+ event = {
+ target: item.el,
+ rect: rect,
+ visiblePx: px,
+ visiblePercent: percent
+ };
+
+ updateSeen(item, event);
+ updatePreload(item, event, innerHeight);
+}
+
+/**
+ * make sure an element isn't hidden by styles or etc
+ * @param {Element} el
+ * @return {boolean}
+ */
+function isElementNotHidden(el) {
+ return el && el.offsetParent !== null && !el.getAttribute('hidden') && getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden';
+}
+
+/**
+ * @param {Element} el
+ * @param {ClientRect} rect
+ * @param {number} preloadThreshold
+ * @param {number} innerHeight
+ * @return {boolean}
+ */
+function shouldBePreloaded(el, rect, preloadThreshold, innerHeight) {
+ return rect.bottom > preloadThreshold * -1 && rect.top <= innerHeight + preloadThreshold && isElementNotHidden(el);
+}
+
+/**
+ * @param {ClientRect} rect
+ * @param {number} innerHeight
+ * @returns {number}
+ */
+function getVerticallyVisiblePixels(rect, innerHeight) {
+ return min(innerHeight, max(rect.bottom, 0)) - min(max(rect.top, 0), innerHeight);
+}
+
+/**
+ * Create a new Visible class to observe when elements enter and leave the viewport
+ *
+ * Call destroy function to stop listening (this is until we have better support for watching for Node Removal)
+ * @param {Element} el
+ * @param {Object} [options]
+ * @param {number} [options.preloadThreshold]
+ * @param {number} [options.shownThreshold] Percentage of element that must be visible to trigger a "shown" event
+ * @param {number} [options.hiddenThreshold] Percentage of element that must be visible to trigger a "hidden" event
+ * @class
+ * @example this.visible = new $visibility.Visible(el);
+ */
+Visible = function (el, options) {
+ options = options || {};
+ this.el = el;
+ this.seen = false;
+ this.preload = false;
+ this.preloadThreshold = options && options.preloadThreshold || 0;
+ this.shownThreshold = options && options.shownThreshold || 0;
+ this.hiddenThreshold = options && min(options.shownThreshold, options.hiddenThreshold) || 0;
+
+ // protect against adding undefined elements which cause the entire service to error on scroll if theyre added to the list
+ if (this.el) {
+ list.push(this);
+ updateVisibilityForItem(this); // set immediately to visible or not
+ }
+};
+
+Visible.prototype = {
+ /**
+ * Stop triggering.
+ */
+ destroy: function () {
+ // remove from list
+ var index = list.indexOf(this);
+
+ if (index > -1) {
+ list.splice(index, 1);
+ }
+ }
+ /**
+ * @name Visible#on
+ * @function
+ * @param {'shown'|'hidden'} e EventName
+ * @param {function} cb Callback
+ */
+ /**
+ * @name Visible#trigger
+ * @function
+ * @param {'shown'|'hidden'} e
+ * @param {{}}
+ */
+};
+
+Eventify.enable(Visible.prototype);
+
+VisibleEvent = function (type, options) {
+ this.type = type;
+ Object.assign({},this,options);
+};
+
+// listen for scroll events (throttled)
+$document.addEventListener('scroll', _throttle(updateVisibility, 200));
+
+// public
+module.exports.getVerticallyVisiblePixels = getVerticallyVisiblePixels;
+module.exports.isElementNotHidden = isElementNotHidden;
+module.exports.Visible = Visible;
+module.exports.updateVisibility = updateVisibility;
diff --git a/app/services/universal/byline.js b/app/services/universal/byline.js
index 404765f..f1a6eec 100644
--- a/app/services/universal/byline.js
+++ b/app/services/universal/byline.js
@@ -1,33 +1,28 @@
'use strict';
const _get = require('lodash/get'),
- _join = require('lodash/join'),
- _map = require('lodash/map'),
_isObject = require('lodash/isObject');
/**
* Comma separate a list of author strings
* or simple-list objects
*
- * @param {String[]} opts
- * @return {String}
+ * @param {string[]} opts
+ * @return {string}
*/
function formatSimpleByline(opts = {}) {
const bylines = _get(opts.hash, 'bylines', []),
- authors = _map(bylines, (author) => _isObject(author) ? author.text : author);
+ authors = bylines.map((author) => _isObject(author) ? author.text : author);
if (authors.length === 1) {
return '
' + authors[0] + '';
} else if (authors.length === 2) {
return '
' + authors[0] + ' and ' + authors[1] + '';
} else {
- return _join(_map(authors, function (author, idx) {
- if (idx < authors.length - 1) {
- return '
' + author + ', ';
- } else {
- return '
and ' + author + '';
- }
- }), '');
+ return authors.map((author, index) => index < authors.length - 1
+ ? `
${author}, `
+ : `
and ${author}`
+ ).join('');
}
}
diff --git a/app/services/universal/rest.js b/app/services/universal/rest.js
index 71281ea..254230c 100644
--- a/app/services/universal/rest.js
+++ b/app/services/universal/rest.js
@@ -7,8 +7,8 @@ require('isomorphic-fetch');
/**
* if you're doing api calls to Clay, authenticate on the server/client side
- * @param {object} payload
- * @return {object}
+ * @param {Object} payload
+ * @return {Object}
*/
function authenticate(payload) {
// the access key is stringified at runtime
@@ -29,8 +29,8 @@ function addFakeCallback() {
* check status after doing http calls
* note: this is necessary because fetch doesn't reject on errors,
* only on network failure or incomplete requests
- * @param {object} res
- * @return {object}
+ * @param {Object} res
+ * @return {Object}
* @throws {Error} on non-2xx status
*/
function checkStatus(res) {
@@ -47,7 +47,7 @@ function checkStatus(res) {
/**
* GET
* @param {string} url
- * @param {object} opts See https://github.github.io/fetch/#options
+ * @param {Object} opts See https://github.github.io/fetch/#options
* @return {Promise}
*/
module.exports.get = function (url, opts) {
diff --git a/app/services/universal/utils.js b/app/services/universal/utils.js
index 6219bcb..0bfec69 100644
--- a/app/services/universal/utils.js
+++ b/app/services/universal/utils.js
@@ -62,7 +62,7 @@ function replaceVersion(uri, version) {
/**
* generate a url from a uri (and some site data)
* @param {string} uri
- * @param {object} locals
+ * @param {Object} locals
* @return {string}
*/
function uriToUrl(uri, locals) {
@@ -106,7 +106,7 @@ function formatStart(n) {
}
/*
*
- * @param {object} locals
+ * @param {Object} locals
* @param {string} [locals.site.protocol]
* @param {string} locals.site.host
* @param {string} [locals.site.port]
diff --git a/app/services/universal/youtube-video-player.js b/app/services/universal/youtube-video-player.js
new file mode 100644
index 0000000..b942add
--- /dev/null
+++ b/app/services/universal/youtube-video-player.js
@@ -0,0 +1,146 @@
+/* global YT:false */
+'use strict';
+
+const VIDEO_ID_RE = /v=([\w-]+)/,
+ videoQueue = [],
+ state = 'initializing';
+
+// Global client service for the YouTube player as primary video content playback.
+
+/**
+ * Adds a video configuration to the video queue
+ * @param {Object} videoConfig
+ * @param {string} videoConfig.videoContainerId
+ * @param {string} videoConfig.videoId
+ */
+function addVideo(videoConfig) {
+ if (videoConfig.videoContainerId && videoConfig.videoId) {
+ videoQueue.push(videoConfig);
+ } else {
+ console.warn('Video is missing Id or container Id to be render');
+ }
+}
+
+/**
+ * Extract video id from YT url
+ * @param {string} str
+ * @returns {string} video Id
+ */
+function extractVideoIdFromUrl(str) {
+ return VIDEO_ID_RE.test(str) ? VIDEO_ID_RE.exec(str)[1] : '';
+}
+
+/**
+ * create the YouTube / YT player
+ * @param {Object} videoConfig
+ * @param {string} videoConfig.videoContainerId
+ * @param {string} videoConfig.videoId
+ * @param {Object} videoConfig.playerParams
+ */
+function createPlayer(videoConfig) {
+ let elementId = videoConfig.videoContainerId || '',
+ videoId = videoConfig.videoId || '',
+ player = null, // eslint-disable-line no-unused-vars
+ playerParams = videoConfig.playerParams || {},
+ customParams = videoConfig.customParams || {},
+ playerEvents = {
+ ready: new Event('player-ready-' + elementId),
+ start: new Event('player-start-' + elementId),
+ finish: new Event('player-finish-' + elementId)
+ };
+
+ if (elementId && videoId) {
+ player = new YT.Player(elementId, { // eslint-disable-line no-unused-vars
+ videoId: videoId,
+ height: 'auto',
+ width: '100%',
+ playerVars: playerParams,
+ events: {
+ onReady: handleVideoReady(playerEvents.ready, customParams),
+ onStateChange: videoStateChangeWrapper(customParams, playerEvents)
+ }
+ });
+ }
+}
+
+/**
+ * handleVideoReady
+ * @param {Object} event
+ * @param {string} customParams
+ * @returns {function}
+ */
+function handleVideoReady(event, customParams) {
+ return function (e) {
+ document.dispatchEvent(event);
+ consumeNextVideoInQueue();
+
+ if (customParams && customParams.customPlayer && customParams.templateid) {
+ document.addEventListener(`customPlayer-${customParams.templateid}`, function () {
+ e.target.playVideo();
+ });
+ }
+
+ if (customParams && customParams.muted) {
+ e.target.setVolume(0);
+ }
+ };
+}
+
+/**
+ * Loads next video
+ * @param {Object} event - video event
+ * @param {boolean} shouldAutoplayNextVideo
+ */
+function loadNextVideo(event, shouldAutoplayNextVideo) {
+ if (shouldAutoplayNextVideo !== 'true') {
+ event.target.stopVideo();
+ }
+}
+
+/**
+ * Video state change wrapper
+ * @param {Object} customParams - custom configuration object
+ * @param {Object} playerEvents - YT player custom events
+ * @return {function} handles when the video state changes
+ */
+function videoStateChangeWrapper(customParams, playerEvents) {
+ let hasVideoStarted = false;
+
+ return function handleVideoStateChange(playerEvt) {
+ if (playerEvt.data === YT.PlayerState.PLAYING && !hasVideoStarted) {
+ document.dispatchEvent(Object.assign(playerEvents.start,
+ {player: {videoId: extractVideoIdFromUrl(playerEvt.target.getVideoUrl()), videoDuration: Math.ceil(playerEvt.target.getDuration())}}
+ ));
+ hasVideoStarted = true;
+ }
+
+ if (playerEvt.data === YT.PlayerState.ENDED) {
+ document.dispatchEvent(playerEvents.finish);
+ loadNextVideo(playerEvt, customParams.autoPlayNextVideo);
+ hasVideoStarted = false;
+ }
+ };
+}
+
+/**
+ * Consumes Next video in the queue
+ */
+function consumeNextVideoInQueue() {
+ if (videoQueue.length) {
+ createPlayer(videoQueue.shift());
+ }
+}
+
+/**
+ * Inits video player process
+ * @param {Object} videoConfig
+ */
+module.exports.init = function (videoConfig) {
+ if (videoConfig) {
+ addVideo(videoConfig);
+
+ if (state === 'initializing') {
+ consumeNextVideoInQueue();
+ }
+ }
+};
diff --git a/app/services/universal/youtube.js b/app/services/universal/youtube.js
new file mode 100644
index 0000000..71e67bb
--- /dev/null
+++ b/app/services/universal/youtube.js
@@ -0,0 +1,40 @@
+'use strict';
+
+const rest = require('./rest'),
+ querystring = require('query-string'),
+ _get = require('lodash/get'),
+ moment = require('moment'),
+ YT_API = 'https://www.googleapis.com/youtube/v3',
+ log = require('./log').setup({ file: __filename });
+
+/**
+ * Get duration as ISO 8601 and convert it to seconds
+ * @param {string} duration - The duration as ISO 8601
+ * @return {number} @duration in seconds
+ */
+function getDurationInSeconds(duration) {
+ return moment.duration(duration, moment.ISO_8601).asSeconds();
+}
+
+/**
+ * Get youtube video details
+ * @param {string} videoId - Youtube videoId
+ * @return {Object} - The video details
+ */
+function getVideoDetails(videoId) {
+ const videoSearchUrl = `${YT_API}/videos`,
+ qs = querystring.stringify({
+ part: 'snippet,contentDetails',
+ id: videoId,
+ key: process.env.YOUTUBE_API_KEY
+ });
+
+ return rest.get(`${videoSearchUrl}?${qs}`)
+ .then(res => Object.assign(
+ _get(res, 'items[0].snippet', {}),
+ { duration: getDurationInSeconds(_get(res, 'items[0].contentDetails.duration', 0)) }
+ ))
+ .catch(err => log('error', `Error fetching video details for video id ${videoId}: ${err.message}`));
+}
+
+module.exports.getVideoDetails = getVideoDetails;
diff --git a/app/styleguides/_default/components/youtube.css b/app/styleguides/_default/components/youtube.css
new file mode 100644
index 0000000..7a51be3
--- /dev/null
+++ b/app/styleguides/_default/components/youtube.css
@@ -0,0 +1,90 @@
+@import '_default/common/_vars.css';
+
+$text-stack: 'MillerText', Georgia, serif;
+
+.youtube {
+ display: flex;
+ flex-wrap: wrap;
+ margin: 0 0 24px;
+
+ .lede-video &,
+ .lede-video &.edit-mode {
+ margin: 0;
+ }
+
+ &-headline {
+ color: $black;
+ font: 21px / 25px $text-stack;
+ letter-spacing: -.01em;
+ margin: 0 0 6px;
+ }
+
+ &-caption {
+ color: $black;
+ }
+
+ &-headerline,
+ &-caption,
+ & .player-wrapper {
+ flex: 0 0 100%;
+ }
+
+ &.edit-mode {
+ height: auto;
+ margin: 0 0 20px;
+
+ .youtube-video-preview {
+ width: 100%;
+ }
+ }
+
+ &.border-top {
+ border-top: 1px solid $black;
+ }
+
+ &.border-top:before {
+ color: $black;
+ font: 15px Egyptienne, Georgia, serif;
+ letter-spacing: 2px;
+ padding: 8px 0 16px;
+ text-transform: uppercase;
+ }
+
+ & .player-wrapper {
+ margin: 0 0 10px;
+ }
+
+ & .player {
+ padding-top: calc((9/16) * 100%);
+ position: relative;
+ }
+
+ & .player iframe {
+ height: 100%;
+ left: 0;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ }
+
+ @media screen and (min-width: 768px) {
+
+ &.sponsored.break-out,
+ &.editorial.break-out {
+ width: 700px;
+ }
+ }
+
+ @media screen and (min-width: 1180px) {
+ margin: 0 0 40px;
+
+ &.embedded {
+ margin: 0 0 40px;
+ }
+
+ &.sponsored.break-out,
+ &.editorial.break-out {
+ width: 1100px;
+ }
+ }
+}
diff --git a/bootstrap-starter-data/_components.yml b/bootstrap-starter-data/_components.yml
index 628d7bd..5b9c72d 100644
--- a/bootstrap-starter-data/_components.yml
+++ b/bootstrap-starter-data/_components.yml
@@ -1,4 +1,8 @@
_components:
+ youtube-player-head:
+ instances:
+ new:
+ allowed: true
# meta components
meta-title:
instances:
diff --git a/bootstrap-starter-data/_layouts.yml b/bootstrap-starter-data/_layouts.yml
index 66f8263..dc08576 100644
--- a/bootstrap-starter-data/_layouts.yml
+++ b/bootstrap-starter-data/_layouts.yml
@@ -8,6 +8,8 @@ _layouts:
_ref: /_components/meta-site/instances/article
-
_ref: /_components/meta-icons/instances/claydemo
+ -
+ _ref: /_components/youtube-player-head/instances/new
top:
-
_ref: /_components/header/instances/claydemo