From 0a064dde89c9713321d01b7639891be7d9ce22f6 Mon Sep 17 00:00:00 2001 From: Alberto Elias Date: Thu, 2 Jun 2016 13:26:41 +0100 Subject: [PATCH] Brings in @VladDubrovski work adding a videojs prototype --- main.scss | 4 + src/models/video-factory.js | 5 + src/models/videojs.js | 235 ++++++++++++++++++++++++++++++ test/models/video-factory.test.js | 7 + test/models/videojs.test.js | 180 +++++++++++++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 src/models/videojs.js create mode 100644 test/models/videojs.test.js diff --git a/main.scss b/main.scss index 0fbb5d5..02fe90d 100644 --- a/main.scss +++ b/main.scss @@ -67,5 +67,9 @@ $_o-video_applied: false !default; } } + [data-o-video-player="videojs"] .o-video--videojs { + position: absolute; + } + $_o-video_applied: true !global; } diff --git a/src/models/video-factory.js b/src/models/video-factory.js index 8a39f17..8053305 100644 --- a/src/models/video-factory.js +++ b/src/models/video-factory.js @@ -1,9 +1,14 @@ const Video = require('./video'); const Brightcove = require('./brightcove'); +const VideoJsPlayer = require('./videojs'); module.exports = (el, opts) => { const source = el.getAttribute('data-o-video-source').toLowerCase(); + const player = (el.getAttribute('data-o-video-player') || 'html5').toLowerCase(); if (source === 'brightcove') { + if (player === 'videojs') { + return new VideoJsPlayer(el, opts); + } return new Brightcove(el, opts); } else { return new Video(el, opts); diff --git a/src/models/videojs.js b/src/models/videojs.js new file mode 100644 index 0000000..9d60f00 --- /dev/null +++ b/src/models/videojs.js @@ -0,0 +1,235 @@ +/* global fetch, videojs */ + +const crossDomainFetch = require('o-fetch-jsonp').crossDomainFetch; +const Video = require('./video'); +const getAppropriateRendition = require('../libs/get-appropriate-rendition'); + +let currentlyPlayingVideo = null; +let requestedVideo = null; +let videoJsPromise; +let videoJsPluginsPromise; +let videoElementIdOrder = 0; +let advertising; + +const pauseOtherVideos = (video) => { + requestedVideo = video; + if(currentlyPlayingVideo && currentlyPlayingVideo !== requestedVideo){ + currentlyPlayingVideo.pause(); + } + + currentlyPlayingVideo = video; +}; + +const ensureVideoJsLibraryLoaded = () => { + if (videoJsPromise) { + return videoJsPromise; + } + + const videojsScript = document.createElement('script'); + videojsScript.setAttribute('type', 'text/javascript'); + videojsScript.setAttribute('src', `//vjs.zencdn.net/5.9.2/video.min.js`); + videojsScript.setAttribute('async', true); + videojsScript.setAttribute('defer', true); + document.getElementsByTagName("head")[0].appendChild(videojsScript); + + const videojsStyles = document.createElement('link') + videojsStyles.setAttribute('rel', 'stylesheet') + videojsStyles.setAttribute('type', 'text/css') + videojsStyles.setAttribute('href', '//vjs.zencdn.net/5.9.2/video-js.min.css') + document.getElementsByTagName('head')[0].appendChild(videojsStyles); + + videoJsPromise = new Promise(resolve => { + videojsScript.addEventListener('load', () => { + resolve(); + }); + }); + + return videoJsPromise; +} + +const ensureVideoJsPluginsAreLoaded = () => { + if(videoJsPluginsPromise) { + return videoJsPluginsPromise; + } + + if(advertising) { + const googleSdkScript = document.createElement('script'); + googleSdkScript.setAttribute('type', 'text/javascript'); + googleSdkScript.setAttribute('src', `//imasdk.googleapis.com/js/sdkloader/ima3.js`); + googleSdkScript.setAttribute('async', true); + googleSdkScript.setAttribute('defer', true); + document.getElementsByTagName("head")[0].appendChild(googleSdkScript); + + let googleSdkScriptPromise = new Promise(sdkLoaded => { + googleSdkScript.addEventListener('load', () => { + sdkLoaded(); + }); + }); + + const videoJsAdPluginsScript = document.createElement('script'); + videoJsAdPluginsScript.setAttribute('type', 'text/javascript'); + videoJsAdPluginsScript.setAttribute('src', `https://next-geebee.ft.com/assets/videojs/videojs-plugins.min.js`); + videoJsAdPluginsScript.setAttribute('async', true); + videoJsAdPluginsScript.setAttribute('defer', true); + document.getElementsByTagName("head")[0].appendChild(videoJsAdPluginsScript); + + let videoJsAdPluginsScriptPromise = new Promise(pluginsLoaded => { + videoJsAdPluginsScript.addEventListener('load', () => { + pluginsLoaded(); + }); + }); + + const videoJsAdPluginStyles = document.createElement('link') + videoJsAdPluginStyles.setAttribute('rel', 'stylesheet') + videoJsAdPluginStyles.setAttribute('type', 'text/css') + videoJsAdPluginStyles.setAttribute('href', 'https://next-geebee.ft.com/assets/videojs/videojs-plugins.min.css') + document.getElementsByTagName('head')[0].appendChild(videoJsAdPluginStyles); + videoJsPluginsPromise = Promise.all([googleSdkScriptPromise, videoJsAdPluginsScriptPromise]); + return videoJsPluginsPromise; + } else { + return Promise.resolve(); + } +} + +const ensureAllScriptsAreLoaded = () => { + return ensureVideoJsLibraryLoaded().then(() => { + return ensureVideoJsPluginsAreLoaded(); + }); +}; + + +// use the image resizing service, if width supplied +const updatePosterUrl = (posterImage, width) => { + let url = `https://image.webservices.ft.com/v1/images/raw/${encodeURIComponent(posterImage)}?source=o-video`; + if (width) { + url += `&fit=scale-down&width=${width}`; + } + return url; +}; + +class VideoJsPlayer extends Video { + constructor(el, opts) { + advertising = opts && opts['advertising'] ? true : false; + ensureAllScriptsAreLoaded(); + super(el, opts); + } + + getData() { + const dataPromise = this.opts.data ? Promise.resolve(this.opts.data) : crossDomainFetch(`//next-video.ft.com/api/${this.id}`) + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw Error('Brightcove responded with a ' + response.status + ' (' + response.statusText + ') for id ' + this.id); + } + }); + + return dataPromise.then(data => { + this.brightcoveData = data; + this.posterImage = updatePosterUrl(data.videoStillURL, this.opts.optimumWidth); + this.rendition = getAppropriateRendition(data.renditions); + }); + } + + renderVideo() { + if (this.rendition) { + if (this.opts.placeholder) { + this.addPlaceholder(); + } else { + this.addVideo(); + } + } + return this; + } + + init() { + const initPromise = this.getData().then(() => this.renderVideo()); + return Promise.all([initPromise, videoJsPromise]); + } + + info() { + const date = new Date(+this.brightcoveData.publishedDate); + return { + posterImage: this.posterImage, + id: this.brightcoveData.id, + length: this.brightcoveData.length, + longDescription: this.brightcoveData.longDescription, + name: this.brightcoveData.name, + publishedDate: date.toISOString(), + publishedDateReadable: date.toUTCString(), + shortDescription: this.brightcoveData.shortDescription, + tags: this.brightcoveData.tags, + }; + } + + addVideo() { + let videoIdProperty = 'test-video-' + videoElementIdOrder++; + this.el = document.createElement('video'); + this.el.setAttribute('poster', this.posterImage); + this.el.setAttribute('src', this.rendition.url); + this.el.setAttribute('id', videoIdProperty); + this.el.className = Array.isArray(this.classes) ? this.classes.join(' ') : this.classes; + this.el.classList.add('o-video--videojs'); + this.containerEl.appendChild(this.el); + this.el.addEventListener('playing', () => pauseOtherVideos(this.el)); + return ensureAllScriptsAreLoaded() + .then(() => { + let videoPlayer = videojs(videoIdProperty, {"controls": true,"autoplay": true,"preload": "auto"}).width('100%'); + if(advertising) { + this.advertising(videoPlayer, videoIdProperty); + } + }); + } + + advertising(player, videoIdProperty) { + player.ima({ + id: videoIdProperty, + adTagUrl: 'http://pubads.g.doubleclick.net/gampad/ads?env=vp&gdfp_req=1&impl=s&output=xml_vast2&iu=/5887/ft.com&sz=592x333|400x225&unviewed_position_start=1&scp=pos%3Dvideo' + }); + player.ima.requestAds(); + } + + addPlaceholder() { + this.placeholderEl = document.createElement('img'); + this.placeholderEl.setAttribute('src', this.posterImage); + this.placeholderEl.className = Array.isArray(this.classes) ? this.classes.join(' ') : this.classes; + this.containerEl.classList.add('o-video--placeholder'); + + this.containerEl.appendChild(this.placeholderEl); + + let titleEl; + if (this.opts.placeholderTitle) { + titleEl = document.createElement('div'); + titleEl.className = 'o-video__title'; + titleEl.textContent = this.brightcoveData.name; + this.containerEl.appendChild(titleEl); + } + + if (this.opts.playButton) { + + const playButtonEl = document.createElement('button'); + playButtonEl.className = 'o-video__play-button'; + playButtonEl.textContent = 'Play video'; + + this.containerEl.appendChild(playButtonEl); + + playButtonEl.addEventListener('click', () => { + this.containerEl.removeChild(playButtonEl); + if (titleEl) { + this.containerEl.removeChild(titleEl); + } + this.removePlaceholder(); + this.addVideo(); + this.el.focus(); + }); + } + } + + removePlaceholder() { + this.containerEl.classList.remove('o-video--placeholder'); + this.containerEl.removeChild(this.placeholderEl); + } + +} + +module.exports = VideoJsPlayer; diff --git a/test/models/video-factory.test.js b/test/models/video-factory.test.js index 45be400..36565c2 100644 --- a/test/models/video-factory.test.js +++ b/test/models/video-factory.test.js @@ -2,6 +2,7 @@ const videoFactory = require('../../src/models/video-factory'); const Brightcove = require('../../src/models/brightcove'); const Video = require('../../src/models/video'); +const VideoJS = require('../../src/models/videojs'); describe('Video Factory', () => { @@ -26,6 +27,12 @@ describe('Video Factory', () => { videoFactory(containerEl).should.be.a.instanceof(Brightcove); }); + it('should create a VideoJS object if player is \'videojs\'', () => { + containerEl.setAttribute('data-o-video-source', 'brightcove'); + containerEl.setAttribute('data-o-video-player', 'videojs'); + videoFactory(containerEl).should.be.an.instanceOf(VideoJS); + }); + it('should create a standard Video object if unknown source', () => { containerEl.setAttribute('data-o-video-source', 'other'); videoFactory(containerEl).should.be.an.instanceOf(Video); diff --git a/test/models/videojs.test.js b/test/models/videojs.test.js new file mode 100644 index 0000000..5154080 --- /dev/null +++ b/test/models/videojs.test.js @@ -0,0 +1,180 @@ +/* global describe, it, beforeEach, afterEach, sinon, expect */ +let VideoJS = require('../../src/models/videojs'); +const brightcoveResponse = require('../fixtures/brightcove.json'); + +const checkGoogleVideoSdkLoaded = function() { + return [].slice.call(document.getElementsByTagName('script')) + .some(function (s) { + if(s.src.indexOf('//imasdk.googleapis.com/js/sdkloader/ima3.js') > -1) { + return true; + } else { + return false; + } + }); +} + + +describe('VideoJS', () => { + + let containerEl; + let fetchStub; + + beforeEach(() => { + containerEl = document.createElement('div'); + containerEl.setAttribute('data-o-video-id', '4084879507001'); + document.body.appendChild(containerEl); + fetchStub = sinon.stub(window, 'fetch'); + const res = new window.Response(JSON.stringify(brightcoveResponse), { + status: 200, + headers: { + 'Content-type': 'application/json' + } + }); + fetchStub.returns(Promise.resolve(res)); + VideoJS = require('../../src/models/videojs'); + }); + + afterEach(() => { + document.body.removeChild(containerEl); + fetchStub.restore(); + VideoJS - false; + }); + + it('should exist', () => { + VideoJS.should.exist; + }); + + it('should be able to instantiate', () => { + const videojsPlayer = new VideoJS(containerEl); + videojsPlayer.should.exist; + }); + + it('should return a Promise on `init`', () => { + const videojsPlayer = new VideoJS(containerEl); + videojsPlayer.init().should.be.an.instanceOf(Promise); + }); + + it('should return the VideoJS instance on `init`', () => { + const videojsPlayer = new VideoJS(containerEl); + videojsPlayer.init().should.eventually.equal(videojsPlayer); + }); + + it('should create a video element on `init`', () => { + const videojsPlayer = new VideoJS(containerEl); + return videojsPlayer + .init() + .then(() => { + const videoEl = containerEl.querySelector('video'); + videoEl.getAttribute('poster').should.equal( + 'https://image.webservices.ft.com/v1/images/raw/' + + 'https%3A%2F%2Fbcsecure01-a.akamaihd.net%2F13%2F47628783001%2F201502%2F2470%2F47628783001_4085962850001_MAS-VIDEO-AuthersNote-stock-market.jpg%3FpubId%3D47628783001' + + '?source=o-video' + ); + videoEl.getAttribute('src').should.equal( + 'http://brightcove.vo.llnwd.net/v1/uds/pd/47628783001/201502/3842/47628783001_4085577922001_A-hated-rally.mp4' + ); + const checkSdkIsLoaded = checkGoogleVideoSdkLoaded(); + checkSdkIsLoaded.should.equal(false); + }); + }); + + it('should throw error if can\'t init', () => { + // bad response instead + const badRes = new window.Response(null, { + status: 404, + statusText: 'Not Found' + }); + fetchStub.returns(Promise.resolve(badRes)); + const videojsPlayer = new VideoJS(containerEl); + return videojsPlayer.init().should.be.rejectedWith('Brightcove responded with a 404 (Not Found) for id 4084879507001'); + }); + + it('should be able to create as a placeholder', () => { + const videojsPlayer = new VideoJS(containerEl, { placeholder: true }); + return videojsPlayer + .init() + .then(() => { + const placholderEl = containerEl.querySelector('img'); + placholderEl.getAttribute('src').should.equal( + 'https://image.webservices.ft.com/v1/images/raw/' + + 'https%3A%2F%2Fbcsecure01-a.akamaihd.net%2F13%2F47628783001%2F201502%2F2470%2F47628783001_4085962850001_MAS-VIDEO-AuthersNote-stock-market.jpg%3FpubId%3D47628783001' + + '?source=o-video' + ); + containerEl.querySelector('.o-video__play-button').should.exist; + }); + }); + + it('should be able to create a placeholder with a title', () => { + const videojsPlayer = new VideoJS(containerEl, { placeholder: true, placeholderTitle: true }); + return videojsPlayer + .init() + .then(() => { + const placholderEl = containerEl.querySelector('img'); + placholderEl.getAttribute('src').should.equal( + 'https://image.webservices.ft.com/v1/images/raw/' + + 'https%3A%2F%2Fbcsecure01-a.akamaihd.net%2F13%2F47628783001%2F201502%2F2470%2F47628783001_4085962850001_MAS-VIDEO-AuthersNote-stock-market.jpg%3FpubId%3D47628783001' + + '?source=o-video' + ); + containerEl.querySelector('.o-video__play-button').should.exist; + containerEl.querySelector('.o-video__title').textContent.should.equal('A hated rally'); + }); + }); + + it('should be able to suppress placeholder play button', () => { + const videojsPlayer = new VideoJS(containerEl, { placeholder: true, playButton:false }); + return videojsPlayer + .init() + .then(() => { + expect(containerEl.querySelector('.o-video__play-button')).to.be.null; + }); + }); + + it('should send poster through image service if optimumWidth defined', () => { + const videojsPlayer = new VideoJS(containerEl, { optimumWidth: 300 }); + return videojsPlayer + .init() + .then(() => { + containerEl.querySelector('video').getAttribute('poster').should.equal( + 'https://image.webservices.ft.com/v1/images/raw/' + + 'https%3A%2F%2Fbcsecure01-a.akamaihd.net%2F13%2F47628783001%2F201502%2F2470%2F47628783001_4085962850001_MAS-VIDEO-AuthersNote-stock-market.jpg%3FpubId%3D47628783001' + + '?source=o-video&fit=scale-down&width=300' + ); + }); + }); + + it('should add supplied classes to video tag', () => { + const videojsPlayer = new VideoJS(containerEl, { classes: ['class-one', 'class-two'] }); + return videojsPlayer + .init() + .then(() => { + const videoElement = containerEl.querySelector('video'); + videoElement.className.should.contain('class-one'); + videoElement.className.should.contain('class-two'); + }); + }); + + it('should not fetch from brightcove if full data provided in opts', () => { + const videojsPlayer = new VideoJS(containerEl, { data: { + prop: 'val', + videoStillURL: 'abc', + renditions: [] + }}); + return videojsPlayer + .getData() + .then(() => { + videojsPlayer.brightcoveData.prop.should.equal('val'); + }); + }); + + it('should add the video advertising script if the configuration parameter is passed', (done) => { + const videojsPlayer = new VideoJS(containerEl, {advertising: true}); + return videojsPlayer + .init() + .then(() => { + const checkSdkIsLoaded = checkGoogleVideoSdkLoaded(); + checkSdkIsLoaded.should.equal(true); + done(); + }); + }); + +});