diff --git a/app/assets/javascripts/initializePage.js b/app/assets/javascripts/initializePage.js index b467a015f..9fd341b0a 100644 --- a/app/assets/javascripts/initializePage.js +++ b/app/assets/javascripts/initializePage.js @@ -7,7 +7,8 @@ initializeCommentPreview, initializeTimeFixer, initializeDashboardSort, initializePWAFunctionality, initializeEllipsisMenu, initializeArchivedPostFilter, initializeCreditsPage, - initializeUserProfilePage, initializePodcastPlayback, initializeDrawerSliders, + initializeUserProfilePage, initializePodcastPlayback, + initializeVideoPlayback, initializeDrawerSliders, initializeHeroBannerClose, initializeOnboardingTaskCard, initScrolling, nextPage:writable, fetching:writable, done:writable, adClicked:writable, initializePaymentPointers, initializeSpecialNavigationFunctionality, initializeBroadcast, @@ -58,6 +59,7 @@ function callInitializers() { initializeCreditsPage(); initializeUserProfilePage(); initializePodcastPlayback(); + initializeVideoPlayback(); initializeDrawerSliders(); initializeHeroBannerClose(); initializeOnboardingTaskCard(); diff --git a/app/assets/javascripts/initializers/initializeVideoPlayback.js b/app/assets/javascripts/initializers/initializeVideoPlayback.js new file mode 100644 index 000000000..4b704030c --- /dev/null +++ b/app/assets/javascripts/initializers/initializeVideoPlayback.js @@ -0,0 +1,195 @@ +/** + * This script hunts for video tags and initializes the correct player + * depending on the platform: + * - web: jwplayer + * - iOS/Android: Native player + * + * Once jwplayer is initialized there's no follow up actions to be taken. + * Mobile Native players send back information into the DOM in order to + * interact and update the UI, therefore a MutationObserver is registered. + */ + +/* eslint no-use-before-define: 0 */ +/* eslint no-param-reassign: 0 */ +/* eslint no-useless-escape: 0 */ +/* global jwplayer */ + +function initializeVideoPlayback() { + var nativeBridgeMessage; + var currentTime = '0'; + + function getById(name) { + return document.getElementById(name); + } + + function isNativeIOS() { + return ( + navigator.userAgent === 'DEV-Native-ios' && + window && + window.webkit && + window.webkit.messageHandlers && + window.webkit.messageHandlers.video + ); + } + + function isNativeAndroid() { + return ( + navigator.userAgent === 'DEV-Native-android' && + typeof AndroidBridge !== 'undefined' && + AndroidBridge !== null && + AndroidBridge.videoMessage !== undefined + ); + } + + function getParameterByName(name, url) { + if (!url) url = window.location.href; + name = name.replace(/[\[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); + } + + function timeToSeconds(hms) { + var a; + if (hms.length < 3) { + return hms; + } else if (hms.length < 6) { + a = hms.split(':'); + return (hms = +a[0] * 60 + +a[1]); + } else { + a = hms.split(':'); + return (hms = +a[0] * 60 * 60 + +a[1] * 60 + +a[2]); + } + } + + function initWebPlayer(seconds, metadata) { + var waitingOnJWP = setInterval(function () { + if (typeof jwplayer !== 'undefined') { + clearInterval(waitingOnJWP); + var playerInstance = jwplayer(`video-player-${metadata.id}`); + playerInstance.setup({ + file: metadata.video_source_url, + mediaid: metadata.video_code, + autostart: true, + image: metadata.video_thumbnail_url, + playbackRateControls: true, + tracks: [ + { + file: metadata.video_closed_caption_track_url, + label: 'English', + kind: 'captions', + default: false, + }, + ], + }); + if (seconds) { + jwplayer().on('ready', function (event) { + jwplayer().play(); + }); + jwplayer().on('firstFrame', function () { + jwplayer().seek(seconds); + }); + } + } + }, 2); + } + + function videoMetadata(videoSource) { + try { + return JSON.parse(videoSource.dataset.meta); + } catch (e) { + console.log('Unable to load Podcast Episode metadata', e); // eslint-disable-line no-console + } + } + + function requestFocus() { + var metadata = videoMetadata(videoSource); + var playerElement = getById(`video-player-${metadata.id}`); + + getById('pause-butt').classList.add('active'); + getById('play-butt').classList.remove('active'); + + nativeBridgeMessage({ + action: 'play', + url: metadata.video_source_url, + seconds: currentTime, + }); + } + + function handleVideoMessages(mutation) { + if (mutation.type !== 'attributes') { + return; + } + + var message = {}; + try { + var messageData = getById('video-player-source').dataset.message; + message = JSON.parse(messageData); + } catch (e) { + console.log(e); // eslint-disable-line no-console + return; + } + + if (message.action == 'pause') { + getById('pause-butt').classList.remove('active'); + getById('play-butt').classList.add('active'); + } else if (message.action == 'tick') { + currentTime = message.currentTime; + } + } + + function initializePlayer(videoSource) { + var seconds = timeToSeconds(getParameterByName('t') || '0'); + var metadata = videoMetadata(videoSource); + + if (isNativeIOS()) { + nativeBridgeMessage = function (message) { + try { + window.webkit.messageHandlers.video.postMessage(message); + } catch (err) { + console.log(err.message); // eslint-disable-line no-console + } + }; + } else if (isNativeAndroid()) { + nativeBridgeMessage = function (message) { + try { + AndroidBridge.videoMessage(JSON.stringify(message)); + } catch (err) { + console.log(err.message); // eslint-disable-line no-console + } + }; + } else { + // jwplayer is initialized and no further interaction is needed + initWebPlayer(seconds, metadata); + return; + } + + var playerElement = getById(`video-player-${metadata.id}`); + playerElement.addEventListener('click', requestFocus); + + playerElement.classList.add('native'); + getById('pause-butt').classList.add('active'); + + var mutationObserver = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + handleVideoMessages(mutation); + }); + }); + mutationObserver.observe(videoSource, { attributes: true }); + + currentTime = `${seconds}`; + nativeBridgeMessage({ + action: 'play', + url: metadata.video_source_url, + seconds: currentTime, + }); + } + + // If an video player element is found initialize it + var videoSource = getById('video-player-source'); + if (videoSource !== null) { + initializePlayer(videoSource); + } +} diff --git a/app/assets/stylesheets/minimal.scss b/app/assets/stylesheets/minimal.scss index ec320258a..05cd43c6d 100644 --- a/app/assets/stylesheets/minimal.scss +++ b/app/assets/stylesheets/minimal.scss @@ -27,6 +27,7 @@ @import 'tag-edit'; @import 'sidebar-data'; @import 'video-collection'; +@import 'video-player'; @import 'listings'; @import 'credits'; @import 'item-list'; diff --git a/app/assets/stylesheets/video-player.scss b/app/assets/stylesheets/video-player.scss new file mode 100644 index 000000000..bb0bf4bc1 --- /dev/null +++ b/app/assets/stylesheets/video-player.scss @@ -0,0 +1,40 @@ +@import 'variables'; +@import '_mixins'; + +.crayons-article__video { + .crayons-article__video__player { + background-position: center; + background-size: cover; + + &.native { + height: calc(100vw * (9/16)); // Maintain 16:9 ratio + } + + img { + display: none; + width: calc(100vw * (9/16) * (1/2)); + height: calc((100vw * (9/16) * (1/2)) + (100vw * (9/16) * (1/6))); + padding-top: calc(100vw * (9/16) * (1/6)); + margin: 0 auto; + + &.active { + display: block; + } + } + + // All the calculations are kept exactly the same, except they're scaled + // down by (2/3). This because tablets use extra columns and the + // calculations are based on viewport size (the columns "don't count") + @media screen and (min-width: 640px) { + &.native { + height: calc(100vw * (9/16) * (2/3)); + } + + img { + width: calc(100vw * (9/16) * (1/2) * (2/3)); + height: calc((100vw * (9/16) * (1/2) * (2/3)) + (100vw * (9/16) * (1/6) * (2/3))); + padding-top: calc(100vw * (9/16) * (1/6) * (2/3)); + } + } + } +} diff --git a/app/decorators/article_decorator.rb b/app/decorators/article_decorator.rb index b4f1278b4..e8b838ff0 100644 --- a/app/decorators/article_decorator.rb +++ b/app/decorators/article_decorator.rb @@ -70,4 +70,14 @@ class ArticleDecorator < ApplicationDecorator modified_description + " Tagged with #{cached_tag_list}." end + + def video_metadata + { + id: id, + video_code: video_code, + video_source_url: video_source_url, + video_thumbnail_url: cloudinary_video_url, + video_closed_caption_track_url: video_closed_caption_track_url + } + end end diff --git a/app/views/articles/_video_player.html.erb b/app/views/articles/_video_player.html.erb index 3e297cbb8..eb3296ec0 100644 --- a/app/views/articles/_video_player.html.erb +++ b/app/views/articles/_video_player.html.erb @@ -1,8 +1,11 @@
<% minutes, seconds = article.video_duration_in_minutes.split(":") %> -
+
-
+
+ Play Button" /> + Pause Button" /> +
<% unless internal_navigation? || user_signed_in? %> <% end %>
- - diff --git a/spec/decorators/article_decorator_spec.rb b/spec/decorators/article_decorator_spec.rb index 442c3a58c..00a636c68 100644 --- a/spec/decorators/article_decorator_spec.rb +++ b/spec/decorators/article_decorator_spec.rb @@ -183,4 +183,24 @@ RSpec.describe ArticleDecorator, type: :decorator do .description_and_tags).to eq(search_optimized_description_replacement) end end + + describe "#video_metadata" do + it "responds with a hash representation of video metadata" do + article_with_video = create(:article, + video_code: "ABC", + video_source_url: "https://cdn.com/ABC.m3u8", + video_thumbnail_url: "https://cdn.com/ABC.png", + video_closed_caption_track_url: "https://cdn.com/ABC_captions") + + expect(article_with_video.decorate.video_metadata).to eq( + { + id: article_with_video.id, + video_code: article_with_video.video_code, + video_source_url: article_with_video.video_source_url, + video_thumbnail_url: article_with_video.cloudinary_video_url, + video_closed_caption_track_url: article_with_video.video_closed_caption_track_url + }, + ) + end + end end