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}} +
+ {{!-- The