Commit 6f2a2b44 by muhammad-ammar

add HLS playback support in video player

TNL-6513
parent be5c4fad
...@@ -72,6 +72,7 @@ ...@@ -72,6 +72,7 @@
'ieshim': 'js/src/ie_shim', 'ieshim': 'js/src/ie_shim',
'tooltip_manager': 'js/src/tooltip_manager', 'tooltip_manager': 'js/src/tooltip_manager',
'draggabilly': 'js/vendor/draggabilly', 'draggabilly': 'js/vendor/draggabilly',
'hls': 'common/js/vendor/hls',
// Files needed for Annotations feature // Files needed for Annotations feature
'annotator': 'js/vendor/ova/annotator-full', 'annotator': 'js/vendor/ova/annotator-full',
......
...@@ -250,7 +250,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark ...@@ -250,7 +250,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark
} }
} }
.video-error { .video-error, .video-hls-error {
padding: ($baseline / 5); padding: ($baseline / 5);
background: black; background: black;
color: white !important; // the pattern library headings shim is more scoped color: white !important; // the pattern library headings shim is more scoped
......
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.576244,
XXXXXXXXT114-V015600_0_0.ts
#EXTINF:8.842178,
XXXXXXXXT114-V015600_0_1.ts
#EXTINF:9.609611,
XXXXXXXXT114-V015600_0_2.ts
#EXT-X-ENDLIST
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.576244,
XXXXXXXXT114-V015600_1_0.ts
#EXTINF:9.042378,
XXXXXXXXT114-V015600_1_1.ts
#EXTINF:9.609611,
XXXXXXXXT114-V015600_1_2.ts
#EXT-X-ENDLIST
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.576244,
XXXXXXXXT114-V015600_2_0.ts
#EXTINF:9.042378,
XXXXXXXXT114-V015600_2_1.ts
#EXTINF:9.609611,
XXXXXXXXT114-V015600_2_2.ts
#EXT-X-ENDLIST
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.609611,
XXXXXXXXT114-V015600_3_0.ts
#EXTINF:9.009011,
XXXXXXXXT114-V015600_3_1.ts
#EXTINF:9.609611,
XXXXXXXXT114-V015600_3_2.ts
#EXT-X-ENDLIST
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:11
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.609611,
XXXXXXXXT114-V015600_4_0.ts
#EXTINF:9.009011,
XXXXXXXXT114-V015600_4_1.ts
#EXTINF:9.609611,
XXXXXXXXT114-V015600_4_2.ts
#EXT-X-ENDLIST
#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=264787,RESOLUTION=1280x720
XXXXXXXXT114-V015600_1_/XXXXXXXXT114-V015600_1_.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=328415,RESOLUTION=1920x1080
XXXXXXXXT114-V015600_0_/XXXXXXXXT114-V015600_0_.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=70750,RESOLUTION=640x360
XXXXXXXXT114-V015600_3_/XXXXXXXXT114-V015600_3_.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=148269,RESOLUTION=960x540
XXXXXXXXT114-V015600_2_/XXXXXXXXT114-V015600_2_.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=41276,RESOLUTION=640x360
XXXXXXXXT114-V015600_4_/XXXXXXXXT114-V015600_4_.m3u8
<div class="course-content">
<div id="video_example">
<div id="example">
<div
id="video_id"
class="video closed"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["/base/fixtures/hls/hls.m3u8", "/base/fixtures/test.mp4","/base/fixtures/test.webm"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "/base/fixtures/youtube_iframe_api.js", "ytImageUrl": "", "ytTestTimeout": "1500", "ytMetadataUrl": "www.googleapis.com/youtube/v3/videos/", "source": ""}'
>
<div class="focus_grabber first"></div>
<div class="tc-wrapper">
<article class="video-wrapper">
<span tabindex="0" class="spinner" aria-hidden="false" aria-label="${_('Loading video player')}"></span>
<span tabindex="-1" class="btn-play is-hidden" aria-hidden="true" aria-label="${_('Play video')}"></span>
<section class="video-player">
<div id="id"></div>
<h4 class="hd hd-4 video-hls-error is-hidden">
Your browser does not support this video format. Try using a different browser.
</h4>
</section>
<section class="video-controls is-hidden"></section>
</article>
</div>
<div class="focus_grabber last"></div>
</div>
</div>
</div>
</div>
...@@ -39,6 +39,7 @@ var options = { ...@@ -39,6 +39,7 @@ var options = {
{pattern: 'common_static/js/src/utility.js', included: true}, {pattern: 'common_static/js/src/utility.js', included: true},
{pattern: 'common_static/js/test/add_ajax_prefix.js', included: true}, {pattern: 'common_static/js/test/add_ajax_prefix.js', included: true},
{pattern: 'common_static/js/test/i18n.js', included: true}, {pattern: 'common_static/js/test/i18n.js', included: true},
{pattern: 'common_static/common/js/vendor/hls.js', included: true},
{pattern: 'public/js/split_test_staff.js', included: true}, {pattern: 'public/js/split_test_staff.js', included: true},
{pattern: 'src/word_cloud/d3.min.js', included: true}, {pattern: 'src/word_cloud/d3.min.js', included: true},
...@@ -77,7 +78,8 @@ var options = { ...@@ -77,7 +78,8 @@ var options = {
], ],
fixtureFiles: [ fixtureFiles: [
{pattern: 'fixtures/*.*'} {pattern: 'fixtures/*.*'},
{pattern: 'fixtures/hls/**/*.*'}
], ],
runFiles: [ runFiles: [
......
...@@ -264,8 +264,17 @@ ...@@ -264,8 +264,17 @@
return state; return state;
}; };
jasmine.initializeHLSPlayer = function(params) {
return jasmine.initializePlayer('video_hls.html', params);
};
jasmine.initializePlayerYouTube = function(params) { jasmine.initializePlayerYouTube = function(params) {
// "video.html" contains HTML template for a YouTube video. // "video.html" contains HTML template for a YouTube video.
return jasmine.initializePlayer('video.html', params); return jasmine.initializePlayer('video.html', params);
}; };
jasmine.DescribeInfo = function(description, specDefinitions) {
this.description = description;
this.specDefinitions = specDefinitions;
};
}).call(this); }).call(this);
...@@ -35,11 +35,17 @@ ...@@ -35,11 +35,17 @@
baseUrl: '/base/', baseUrl: '/base/',
paths: { paths: {
moment: 'common_static/common/js/vendor/moment-with-locales', moment: 'common_static/common/js/vendor/moment-with-locales',
'draggabilly': 'common_static/js/vendor/draggabilly', draggabilly: 'common_static/js/vendor/draggabilly',
'edx-ui-toolkit': 'common_static/edx-ui-toolkit' 'edx-ui-toolkit': 'common_static/edx-ui-toolkit',
hls: 'common_static/common/js/vendor/hls'
}, },
'moment': { shim: {
exports: 'moment' moment: {
exports: 'moment'
},
hls: {
exports: 'Hls'
}
} }
}); });
}).call(this, RequireJS.requirejs, RequireJS.define); }).call(this, RequireJS.requirejs, RequireJS.define);
(function(undefined) { (function(undefined) {
describe('Video HTML5Video', function() { describe('Video HTML5Video', function() {
var STATUS = window.STATUS; var STATUS = window.STATUS;
var state, oldOTBD, playbackRates = [0.75, 1.0, 1.25, 1.5]; var state,
oldOTBD,
playbackRates = [0.75, 1.0, 1.25, 1.5],
describeInfo;
beforeEach(function() { beforeEach(function() {
oldOTBD = window.onTouchBasedDevice; oldOTBD = window.onTouchBasedDevice;
...@@ -17,10 +20,8 @@ ...@@ -17,10 +20,8 @@
window.onTouchBasedDevice = oldOTBD; window.onTouchBasedDevice = oldOTBD;
}); });
describe('on non-Touch devices', function() { describeInfo = new jasmine.DescribeInfo('on non-Touch devices ', function() {
beforeEach(function() { beforeEach(function() {
state = jasmine.initializePlayer('video_html5.html');
state.videoPlayer.player.config.events.onReady = jasmine.createSpy('onReady'); state.videoPlayer.player.config.events.onReady = jasmine.createSpy('onReady');
}); });
...@@ -321,6 +322,22 @@ ...@@ -321,6 +322,22 @@
}); });
}); });
describe('non-hls encoding', function() {
beforeEach(function(done) {
state = jasmine.initializePlayer('video_html5.html');
done();
});
jasmine.getEnv().describe(describeInfo.description, describeInfo.specDefinitions);
});
describe('hls encoding', function() {
beforeEach(function(done) {
state = jasmine.initializeHLSPlayer();
done();
});
jasmine.getEnv().describe(describeInfo.description, describeInfo.specDefinitions);
});
it('native controls are used on iPhone', function() { it('native controls are used on iPhone', function() {
window.onTouchBasedDevice.and.returnValue(['iPhone']); window.onTouchBasedDevice.and.returnValue(['iPhone']);
......
(function(undefined) { (function(undefined) {
'use strict'; 'use strict';
describe('VideoPlayer Events plugin', function() { var describeInfo, state, oldOTBD;
var state, oldOTBD, Logger = window.Logger;
beforeEach(function() { describeInfo = new jasmine.DescribeInfo('', function() {
oldOTBD = window.onTouchBasedDevice; var Logger = window.Logger;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice')
.and.returnValue(null);
state = jasmine.initializePlayer(); beforeEach(function() {
spyOn(Logger, 'log'); spyOn(Logger, 'log');
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10); spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
}); });
...@@ -27,7 +23,7 @@ ...@@ -27,7 +23,7 @@
state.el.trigger('ready'); state.el.trigger('ready');
expect(Logger.log).toHaveBeenCalledWith('load_video', { expect(Logger.log).toHaveBeenCalledWith('load_video', {
id: 'id', id: 'id',
code: 'html5' code: this.code
}); });
}); });
...@@ -36,7 +32,7 @@ ...@@ -36,7 +32,7 @@
state.el.trigger('play'); state.el.trigger('play');
expect(Logger.log).toHaveBeenCalledWith('play_video', { expect(Logger.log).toHaveBeenCalledWith('play_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
currentTime: 10 currentTime: 10
}); });
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeFalsy(); expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeFalsy();
...@@ -52,7 +48,7 @@ ...@@ -52,7 +48,7 @@
state.el.trigger('pause'); state.el.trigger('pause');
expect(Logger.log).toHaveBeenCalledWith('pause_video', { expect(Logger.log).toHaveBeenCalledWith('pause_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
currentTime: 10 currentTime: 10
}); });
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy(); expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
...@@ -62,7 +58,7 @@ ...@@ -62,7 +58,7 @@
state.el.trigger('speedchange', ['2.0', '1.0']); state.el.trigger('speedchange', ['2.0', '1.0']);
expect(Logger.log).toHaveBeenCalledWith('speed_change_video', { expect(Logger.log).toHaveBeenCalledWith('speed_change_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
current_time: 10, current_time: 10,
old_speed: '1.0', old_speed: '1.0',
new_speed: '2.0' new_speed: '2.0'
...@@ -73,7 +69,7 @@ ...@@ -73,7 +69,7 @@
state.el.trigger('seek', [1, 0, 'any']); state.el.trigger('seek', [1, 0, 'any']);
expect(Logger.log).toHaveBeenCalledWith('seek_video', { expect(Logger.log).toHaveBeenCalledWith('seek_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
old_time: 0, old_time: 0,
new_time: 1, new_time: 1,
type: 'any' type: 'any'
...@@ -91,7 +87,7 @@ ...@@ -91,7 +87,7 @@
state.el.trigger('ended'); state.el.trigger('ended');
expect(Logger.log).toHaveBeenCalledWith('stop_video', { expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
currentTime: 10 currentTime: 10
}); });
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy(); expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
...@@ -100,7 +96,7 @@ ...@@ -100,7 +96,7 @@
state.el.trigger('stop'); state.el.trigger('stop');
expect(Logger.log).toHaveBeenCalledWith('stop_video', { expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
currentTime: 10 currentTime: 10
}); });
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy(); expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
...@@ -110,7 +106,7 @@ ...@@ -110,7 +106,7 @@
state.el.trigger('skip', [false]); state.el.trigger('skip', [false]);
expect(Logger.log).toHaveBeenCalledWith('skip_video', { expect(Logger.log).toHaveBeenCalledWith('skip_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
currentTime: 10 currentTime: 10
}); });
}); });
...@@ -119,7 +115,7 @@ ...@@ -119,7 +115,7 @@
state.el.trigger('skip', [true]); state.el.trigger('skip', [true]);
expect(Logger.log).toHaveBeenCalledWith('do_not_show_again_video', { expect(Logger.log).toHaveBeenCalledWith('do_not_show_again_video', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
currentTime: 10 currentTime: 10
}); });
}); });
...@@ -128,7 +124,7 @@ ...@@ -128,7 +124,7 @@
state.el.trigger('language_menu:show'); state.el.trigger('language_menu:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.shown', { expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.shown', {
id: 'id', id: 'id',
code: 'html5' code: this.code
}); });
}); });
...@@ -136,7 +132,7 @@ ...@@ -136,7 +132,7 @@
state.el.trigger('language_menu:hide'); state.el.trigger('language_menu:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.hidden', { expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.hidden', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
language: 'en' language: 'en'
}); });
}); });
...@@ -145,7 +141,7 @@ ...@@ -145,7 +141,7 @@
state.el.trigger('transcript:show'); state.el.trigger('transcript:show');
expect(Logger.log).toHaveBeenCalledWith('show_transcript', { expect(Logger.log).toHaveBeenCalledWith('show_transcript', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
current_time: 10 current_time: 10
}); });
}); });
...@@ -154,7 +150,7 @@ ...@@ -154,7 +150,7 @@
state.el.trigger('transcript:hide'); state.el.trigger('transcript:hide');
expect(Logger.log).toHaveBeenCalledWith('hide_transcript', { expect(Logger.log).toHaveBeenCalledWith('hide_transcript', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
current_time: 10 current_time: 10
}); });
}); });
...@@ -163,7 +159,7 @@ ...@@ -163,7 +159,7 @@
state.el.trigger('captions:show'); state.el.trigger('captions:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.shown', { expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.shown', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
current_time: 10 current_time: 10
}); });
}); });
...@@ -172,7 +168,7 @@ ...@@ -172,7 +168,7 @@
state.el.trigger('captions:hide'); state.el.trigger('captions:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.hidden', { expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.hidden', {
id: 'id', id: 'id',
code: 'html5', code: this.code,
current_time: 10 current_time: 10
}); });
}); });
...@@ -200,4 +196,31 @@ ...@@ -200,4 +196,31 @@
}); });
}); });
}); });
describe('VideoPlayer Events plugin', function() {
beforeEach(function() {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice')
.and.returnValue(null);
});
describe('html5 encoding only', function() {
beforeEach(function(done) {
this.code = 'html5';
state = jasmine.initializePlayer('video_html5.html');
done();
});
jasmine.getEnv().describe(describeInfo.description, describeInfo.specDefinitions);
});
describe('hls encoding', function() {
beforeEach(function(done) {
this.code = 'hls';
state = jasmine.initializeHLSPlayer();
done();
});
jasmine.getEnv().describe(describeInfo.description, describeInfo.specDefinitions);
});
});
}).call(this); }).call(this);
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
'use strict'; 'use strict';
require( require(
['video/03_video_player.js'], ['video/03_video_player.js', 'hls'],
function(VideoPlayer) { function(VideoPlayer, HLS) {
describe('VideoPlayer', function() { describe('VideoPlayer', function() {
var state, oldOTBD, empty_arguments; var state, oldOTBD, empty_arguments;
...@@ -969,6 +969,48 @@ function(VideoPlayer) { ...@@ -969,6 +969,48 @@ function(VideoPlayer) {
expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('1.0'); expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('1.0');
}); });
}); });
describe('HLS Video', function() {
beforeEach(function() {
state = jasmine.initializeHLSPlayer();
});
it('does not show error message if hls is supported', function() {
expect($('.video-hls-error')).toHaveClass('is-hidden');
});
it('can extract hls video sources correctly', function() {
expect(state.HLSVideoSources).toEqual(['/base/fixtures/hls/hls.m3u8']);
expect(state.videoPlayer.player.hls).toBeDefined();
});
describe('on safari', function() {
beforeEach(function() {
spyOn(HLS, 'isSupported').and.returnValue(false);
state = jasmine.initializeHLSPlayer();
state.canPlayHLS = true;
state.browserIsSafari = true;
});
it('can use native hls playback support', function() {
expect(state.videoPlayer.player.hls).toBeUndefined();
});
});
});
describe('HLS Video Errors', function() {
beforeEach(function() {
spyOn(HLS, 'isSupported').and.returnValue(false);
state = jasmine.initializeHLSPlayer({sources: ['/base/fixtures/hls/hls.m3u8']});
});
it('shows error message if hls is not supported', function() {
expect($('.video-hls-error')).not.toHaveClass('is-hidden');
expect($('.video-hls-error').text().trim()).toEqual(
'Your browser does not support this video format. Try using a different browser.'
);
});
});
}); });
}); });
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); }(RequireJS.requirejs, RequireJS.require, RequireJS.define));
/* eslint no-console:0 */ /* eslint-disable no-console, no-param-reassign */
/** /**
* @file Initialize module works with the JSON config, and sets up various * @file Initialize module works with the JSON config, and sets up various
* settings, parameters, variables. After all setup actions are performed, it * settings, parameters, variables. After all setup actions are performed, it
...@@ -278,6 +278,18 @@ function(VideoPlayer, i18n, moment, _) { ...@@ -278,6 +278,18 @@ function(VideoPlayer, i18n, moment, _) {
return false; return false;
} }
/**
* Extract HLS video URLs from available video URLs.
*
* @param {object} state The object contaning the state (properties, methods, modules) of the Video player.
* @returns Array of available HLS video source urls.
*/
function extractHLSVideoSources(state) {
return _.filter(state.config.sources, function(source) {
return /\.m3u8$/.test(source);
});
}
// function _prepareHTML5Video(state) // function _prepareHTML5Video(state)
// The function prepare HTML5 video, parse HTML5 // The function prepare HTML5 video, parse HTML5
// video sources etc. // video sources etc.
...@@ -325,6 +337,7 @@ function(VideoPlayer, i18n, moment, _) { ...@@ -325,6 +337,7 @@ function(VideoPlayer, i18n, moment, _) {
state.controlHideTimeout = null; state.controlHideTimeout = null;
state.captionState = 'invisible'; state.captionState = 'invisible';
state.captionHideTimeout = null; state.captionHideTimeout = null;
state.HLSVideoSources = extractHLSVideoSources(state);
} }
function _initializeModules(state, i18n) { function _initializeModules(state, i18n) {
......
/* eslint-disable no-console, no-param-reassign */
/**
* HTML5 video player module to support HLS video playback.
*
*/
(function(requirejs, require, define) {
'use strict';
define('video/02_html5_hls_video.js', ['video/02_html5_video.js', 'hls'],
function(HTML5Video, HLS) {
var HLSVideo = {};
HLSVideo.Player = (function() {
/**
* Initialize HLS video player.
*
* @param {jQuery} el Reference to video player container element
* @param {Object} config Contains common config for video player
*/
function Player(el, config) {
var self = this;
// do common initialization independent of player type
this.init(el, config);
// If we have only HLS sources and browser doesn't support HLS then show error message.
if (config.HLSOnlySources && !config.canPlayHLS) {
this.showErrorMessage(null, '.video-hls-error');
return;
}
// Safari has native support to play HLS videos
if (config.browserIsSafari) {
this.videoEl.attr('src', config.videoSources[0]);
} else {
this.hls = new HLS();
this.hls.loadSource(config.videoSources[0]);
this.hls.attachMedia(this.video);
this.hls.on(HLS.Events.ERROR, this.onError.bind(this));
this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) {
console.log(
'[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ',
data.levels.map(function(level) {
return {
bitrate: level.bitrate,
resolution: level.width + 'x' + level.height
};
})
);
});
this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) {
var level = self.hls.levels[data.level];
console.log(
'[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ',
{
bitrate: level.bitrate,
resolution: level.width + 'x' + level.height
}
);
});
}
}
Player.prototype = Object.create(HTML5Video.Player.prototype);
Player.prototype.constructor = Player;
/**
* Handler for HLS video errors. This only takes care of fatal erros, non-fatal errors
* are automatically handled by hls.js
*
* @param {String} event `hlsError`
* @param {Object} data Contains the information regarding error occurred.
*/
Player.prototype.onError = function(event, data) {
if (data.fatal) {
switch (data.type) {
case HLS.ErrorTypes.NETWORK_ERROR:
console.error(
'[HLS Video]: Fatal network error encountered, try to recover. Details: %s',
data.details
);
this.hls.startLoad();
break;
case HLS.ErrorTypes.MEDIA_ERROR:
console.error(
'[HLS Video]: Fatal media error encountered, try to recover. Details: %s',
data.details
);
this.hls.recoverMediaError();
break;
default:
console.error(
'[HLS Video]: Unrecoverable error encountered. Details: %s',
data.details
);
break;
}
}
};
return Player;
}());
return HLSVideo;
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
/* eslint-disable no-console, no-param-reassign */
(function(requirejs, require, define) { (function(requirejs, require, define) {
// VideoPlayer module. // VideoPlayer module.
define( define(
'video/03_video_player.js', 'video/03_video_player.js',
['video/02_html5_video.js', 'video/00_resizer.js'], ['video/02_html5_video.js', 'video/02_html5_hls_video.js', 'video/00_resizer.js', 'hls', 'underscore'],
function(HTML5Video, Resizer) { function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
var dfd = $.Deferred(), var dfd = $.Deferred(),
VideoPlayer = function(state) { VideoPlayer = function(state) {
state.videoPlayer = {}; state.videoPlayer = {};
...@@ -100,8 +101,13 @@ function(HTML5Video, Resizer) { ...@@ -100,8 +101,13 @@ function(HTML5Video, Resizer) {
// initial configuration. Also make the created DOM elements available // initial configuration. Also make the created DOM elements available
// via the 'state' object. Much easier to work this way - you don't // via the 'state' object. Much easier to work this way - you don't
// have to do repeated jQuery element selects. // have to do repeated jQuery element selects.
// eslint-disable-next-line no-underscore-dangle
function _initialize(state) { function _initialize(state) {
var youTubeId, player, userAgent; var youTubeId,
player,
userAgent,
commonPlayerConfig,
eventToBeTriggered = 'loadedmetadata';
// The function is called just once to apply pre-defined configurations // The function is called just once to apply pre-defined configurations
// by student before video starts playing. Waits until the video's // by student before video starts playing. Waits until the video's
...@@ -147,19 +153,42 @@ function(HTML5Video, Resizer) { ...@@ -147,19 +153,42 @@ function(HTML5Video, Resizer) {
state.browserIsSafari = (userAgent.indexOf('safari') > -1 && state.browserIsSafari = (userAgent.indexOf('safari') > -1 &&
!state.browserIsChrome); !state.browserIsChrome);
if (state.videoType === 'html5') { // Browser can play HLS videos if either `Media Source Extensions`
state.videoPlayer.player = new HTML5Video.Player(state.el, { // feature is supported or browser is safari (native HLS support)
playerVars: state.videoPlayer.playerVars, state.canPlayHLS = state.HLSVideoSources.length > 0 && (HLS.isSupported() || state.browserIsSafari);
videoSources: state.config.sources, state.HLSOnlySources = state.config.sources.length > 0 &&
events: { state.config.sources.length === state.HLSVideoSources.length;
onReady: state.videoPlayer.onReady,
onStateChange: state.videoPlayer.onStateChange,
onError: state.videoPlayer.onError
}
});
commonPlayerConfig = {
playerVars: state.videoPlayer.playerVars,
videoSources: state.config.sources,
browserIsSafari: state.browserIsSafari,
events: {
onReady: state.videoPlayer.onReady,
onStateChange: state.videoPlayer.onStateChange,
onError: state.videoPlayer.onError
}
};
if (state.videoType === 'html5') {
if (state.canPlayHLS || state.HLSOnlySources) {
state.videoPlayer.player = new HTML5HLSVideo.Player(
state.el,
_.extend({}, commonPlayerConfig, {
videoSources: state.HLSVideoSources,
canPlayHLS: state.canPlayHLS,
HLSOnlySources: state.HLSOnlySources
})
);
// `loadedmetadata` event triggered too early on Safari due
// to which correct video dimensions were not calculated
eventToBeTriggered = state.browserIsSafari ? 'loadeddata' : eventToBeTriggered;
} else {
state.videoPlayer.player = new HTML5Video.Player(state.el, commonPlayerConfig);
}
player = state.videoEl = state.videoPlayer.player.videoEl; player = state.videoEl = state.videoPlayer.player.videoEl;
player[0].addEventListener('loadedmetadata', state.videoPlayer.onLoadMetadataHtml5, false); player[0].addEventListener(eventToBeTriggered, state.videoPlayer.onLoadMetadataHtml5, false);
player.on('remove', state.videoPlayer.destroy);
} else { } else {
youTubeId = state.youtubeId(); youTubeId = state.youtubeId();
...@@ -179,6 +208,8 @@ function(HTML5Video, Resizer) { ...@@ -179,6 +208,8 @@ function(HTML5Video, Resizer) {
videoWidth = player.attr('width') || player.width(), videoWidth = player.attr('width') || player.width(),
videoHeight = player.attr('height') || player.height(); videoHeight = player.attr('height') || player.height();
player.on('remove', state.videoPlayer.destroy);
_resize(state, videoWidth, videoHeight); _resize(state, videoWidth, videoHeight);
_updateVcrAndRegion(state, true); _updateVcrAndRegion(state, true);
}); });
...@@ -323,6 +354,9 @@ function(HTML5Video, Resizer) { ...@@ -323,6 +354,9 @@ function(HTML5Video, Resizer) {
if (player && _.isFunction(player.destroy)) { if (player && _.isFunction(player.destroy)) {
player.destroy(); player.destroy();
} }
if (this.canPlayHLS && player.hls) {
player.hls.destroy();
}
delete this.videoPlayer; delete this.videoPlayer;
} }
......
...@@ -142,7 +142,8 @@ ...@@ -142,7 +142,8 @@
log: function(eventName, data) { log: function(eventName, data) {
var logInfo = _.extend({ var logInfo = _.extend({
id: this.state.id, id: this.state.id,
code: this.state.isYoutubeType() ? this.state.youtubeId() : 'html5' // eslint-disable-next-line no-nested-ternary
code: this.state.isYoutubeType() ? this.state.youtubeId() : this.state.canPlayHLS ? 'hls' : 'html5'
}, data, this.options.data); }, data, this.options.data);
Logger.log(eventName, logInfo); Logger.log(eventName, logInfo);
} }
......
...@@ -124,6 +124,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -124,6 +124,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
resource_string(module, 'js/src/video/01_initialize.js'), resource_string(module, 'js/src/video/01_initialize.js'),
resource_string(module, 'js/src/video/025_focus_grabber.js'), resource_string(module, 'js/src/video/025_focus_grabber.js'),
resource_string(module, 'js/src/video/02_html5_video.js'), resource_string(module, 'js/src/video/02_html5_video.js'),
resource_string(module, 'js/src/video/02_html5_hls_video.js'),
resource_string(module, 'js/src/video/03_video_player.js'), resource_string(module, 'js/src/video/03_video_player.js'),
resource_string(module, 'js/src/video/035_video_accessible_menu.js'), resource_string(module, 'js/src/video/035_video_accessible_menu.js'),
resource_string(module, 'js/src/video/04_video_control.js'), resource_string(module, 'js/src/video/04_video_control.js'),
......
...@@ -39,7 +39,8 @@ ...@@ -39,7 +39,8 @@
'backbone-super': 'js/vendor/backbone-super', 'backbone-super': 'js/vendor/backbone-super',
'jasmine-imagediff': 'js/vendor/jasmine-imagediff', 'jasmine-imagediff': 'js/vendor/jasmine-imagediff',
'URI': 'js/vendor/URI.min', 'URI': 'js/vendor/URI.min',
'draggabilly': 'js/vendor/draggabilly' 'draggabilly': 'js/vendor/draggabilly',
'hls': 'common/js/vendor/hls'
}, },
shim: { shim: {
'gettext': { 'gettext': {
......
...@@ -33,7 +33,7 @@ CSS_CLASS_NAMES = { ...@@ -33,7 +33,7 @@ CSS_CLASS_NAMES = {
'captions_rendered': '.video.is-captions-rendered', 'captions_rendered': '.video.is-captions-rendered',
'captions': '.subtitles', 'captions': '.subtitles',
'captions_text': '.subtitles li span', 'captions_text': '.subtitles li span',
'captions_text_getter': '.subtitles li span[role="link"][data-index="1"]', 'captions_text_getter': '.subtitles li span[role="link"][data-index="{}"]',
'closed_captions': '.closed-captions', 'closed_captions': '.closed-captions',
'error_message': '.video .video-player .video-error', 'error_message': '.video .video-player .video-error',
'video_container': '.video', 'video_container': '.video',
...@@ -46,11 +46,13 @@ CSS_CLASS_NAMES = { ...@@ -46,11 +46,13 @@ CSS_CLASS_NAMES = {
'captions_lang_list': '.langs-list li', 'captions_lang_list': '.langs-list li',
'video_speed': '.speeds .value', 'video_speed': '.speeds .value',
'poster': '.poster', 'poster': '.poster',
'active_caption_text': '.subtitles-menu > li.current span',
} }
VIDEO_MODES = { VIDEO_MODES = {
'html5': '.video video', 'html5': '.video video',
'youtube': '.video iframe' 'youtube': '.video iframe',
'hls': '.video video',
} }
VIDEO_MENUS = { VIDEO_MENUS = {
...@@ -204,7 +206,7 @@ class VideoPage(PageObject): ...@@ -204,7 +206,7 @@ class VideoPage(PageObject):
Check that if video is rendered in `mode`. Check that if video is rendered in `mode`.
Arguments: Arguments:
mode (str): Video mode, `html5` or `youtube`. mode (str): Video mode, one of `html5`, `youtube`, `hls`.
Returns: Returns:
bool: Tells if video is rendered in `mode`. bool: Tells if video is rendered in `mode`.
...@@ -222,11 +224,26 @@ class VideoPage(PageObject): ...@@ -222,11 +224,26 @@ class VideoPage(PageObject):
""" """
is_present = self.q(css=selector).present is_present = self.q(css=selector).present
# There is no way to get actual HLS video URL. Becuase in hls video
# src attribute is not set to original url. https://github.com/video-dev/hls.js/issues/1052
# http://www.streambox.fr/playlists/x36xhzz/x36xhzz.m3u8 becomes
# "blob:https://studio-hlsvideo.sandbox.edx.org/0e2e72e0-904e-d946-9ce0-06c542894cda"
if mode == 'hls':
href_src = self.q(css=selector).attrs('src')[0]
is_present = href_src.startswith('blob:') or href_src.startswith('mediasource:')
return is_present, is_present return is_present, is_present
return Promise(_is_element_present, 'Video Rendering Failed in {0} mode.'.format(mode)).fulfill() return Promise(_is_element_present, 'Video Rendering Failed in {0} mode.'.format(mode)).fulfill()
@property @property
def video_download_url(self):
"""
Return video download url or None
"""
browser_query = self.q(css='.wrapper-download-video .btn-link.video-sources')
return browser_query.attrs('href')[0] if browser_query.visible else None
@property
def is_autoplay_enabled(self): def is_autoplay_enabled(self):
""" """
Extract autoplay value of `data-metadata` attribute to check video autoplay is enabled or disabled. Extract autoplay value of `data-metadata` attribute to check video autoplay is enabled or disabled.
...@@ -409,16 +426,26 @@ class VideoPage(PageObject): ...@@ -409,16 +426,26 @@ class VideoPage(PageObject):
return ' '.join(subs) return ' '.join(subs)
def click_first_line_in_transcript(self): def click_transcript_line(self, line_no):
""" """
Clicks a line in the transcript updating the current caption. Clicks a line in the transcript updating the current caption.
Arguments:
line_no (int): line number to be clicked
""" """
self.wait_for_captions() self.wait_for_captions()
captions_selector = self.q(css=CSS_CLASS_NAMES['captions_text_getter']) captions_selector = self.q(css=CSS_CLASS_NAMES['captions_text_getter'].format(line_no))
captions_selector.click() captions_selector.click()
@property @property
def active_caption_text(self):
"""
Return active caption text.
"""
return self.q(css=CSS_CLASS_NAMES['active_caption_text']).text[0]
@property
def speed(self): def speed(self):
""" """
Get current video speed value. Get current video speed value.
......
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
import datetime import datetime
import json import json
from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
import os
import ddt import ddt
from common.test.acceptance.tests.helpers import EventsTestMixin from common.test.acceptance.tests.helpers import EventsTestMixin
...@@ -149,6 +151,45 @@ class VideoEventsTest(VideoEventsTestMixin): ...@@ -149,6 +151,45 @@ class VideoEventsTest(VideoEventsTestMixin):
assert_events_equal(static_fields_pattern, load_video_event) assert_events_equal(static_fields_pattern, load_video_event)
class VideoHLSEventsTest(VideoEventsTestMixin):
"""
Test video player event emission for HLS video
"""
def test_event_data_for_hls(self):
"""
Scenario: Video component with HLS video emits events correctly
Given the course has a Video component with Youtube, HTML5 and HLS sources available.
And I play the video
And the video starts playing
And I watch 3 seconds of it
When I pause and seek the video
And I play the video to the end
Then I verify that all expected events are triggered
And triggered events have correct data
"""
video_events = ('load_video', 'play_video', 'pause_video', 'seek_video')
def is_video_event(event):
"""
Filter out anything other than the video events of interest
"""
return event['event_type'] in video_events
captured_events = []
with self.capture_events(is_video_event, captured_events=captured_events):
self.metadata = self.metadata_for_mode('hls')
self.navigate_to_video()
self.video.click_player_button('play')
self.video.wait_for_position('0:03')
self.video.click_player_button('pause')
self.video.seek('0:08')
expected_events = [{'name': event, 'event': {'code': 'hls'}} for event in video_events]
self.assert_events_match(expected_events, captured_events)
@attr(shard=8) @attr(shard=8)
@ddt.ddt @ddt.ddt
class VideoBumperEventsTest(VideoEventsTestMixin): class VideoBumperEventsTest(VideoEventsTestMixin):
......
0
00:00:00,000 --> 00:00:01,000
Hi, edX welcomes you0.
1
00:00:01,000 --> 00:00:02,000
Hi, edX welcomes you1.
2
00:00:02,000 --> 00:00:03,000
Hi, edX welcomes you2.
3
00:00:03,000 --> 00:00:04,000
Hi, edX welcomes you3.
4
00:00:04,000 --> 00:00:05,000
Hi, edX welcomes you4.
5
00:00:05,000 --> 00:00:06,000
Hi, edX welcomes you5.
6
00:00:06,000 --> 00:00:07,000
Hi, edX welcomes you6.
7
00:00:07,000 --> 00:00:08,000
Hi, edX welcomes you7.
8
00:00:08,000 --> 00:00:09,000
Hi, edX welcomes you8.
9
00:00:09,000 --> 00:00:10,000
Hi, edX welcomes you9.
...@@ -1740,7 +1740,8 @@ REQUIRE_JS_PATH_OVERRIDES = { ...@@ -1740,7 +1740,8 @@ REQUIRE_JS_PATH_OVERRIDES = {
'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory.js', 'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory.js',
'js/courseware/courseware_factory': 'js/courseware/courseware_factory.js', 'js/courseware/courseware_factory': 'js/courseware/courseware_factory.js',
'js/groups/views/cohorts_dashboard_factory': 'js/groups/views/cohorts_dashboard_factory.js', 'js/groups/views/cohorts_dashboard_factory': 'js/groups/views/cohorts_dashboard_factory.js',
'draggabilly': 'js/vendor/draggabilly.js' 'draggabilly': 'js/vendor/draggabilly.js',
'hls': 'common/js/vendor/hls.js'
} }
########################## DJANGO DEBUG TOOLBAR ############################### ########################## DJANGO DEBUG TOOLBAR ###############################
......
...@@ -35,6 +35,7 @@ var options = { ...@@ -35,6 +35,7 @@ var options = {
{pattern: 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js', included: true}, {pattern: 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js', included: true},
{pattern: 'xmodule_js/common_static/js/vendor/jquery-ui.min.js', included: true}, {pattern: 'xmodule_js/common_static/js/vendor/jquery-ui.min.js', included: true},
{pattern: 'xmodule_js/common_static/js/vendor/URI.min.js', included: true}, {pattern: 'xmodule_js/common_static/js/vendor/URI.min.js', included: true},
{pattern: 'common/js/vendor/hls.js', included: true},
{pattern: 'xmodule_js/src/capa/*.js', included: true}, {pattern: 'xmodule_js/src/capa/*.js', included: true},
{pattern: 'xmodule_js/src/video/*.js', included: true}, {pattern: 'xmodule_js/src/video/*.js', included: true},
......
...@@ -108,7 +108,8 @@ ...@@ -108,7 +108,8 @@
'handlebars': 'js/vendor/ova/catch/js/handlebars-1.1.2', 'handlebars': 'js/vendor/ova/catch/js/handlebars-1.1.2',
'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min', 'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min',
'jquery.tinymce': 'js/vendor/tinymce/js/tinymce/jquery.tinymce.min', 'jquery.tinymce': 'js/vendor/tinymce/js/tinymce/jquery.tinymce.min',
'picturefill': 'common/js/vendor/picturefill' 'picturefill': 'common/js/vendor/picturefill',
'hls': 'common/js/vendor/hls'
// end of files needed by OVA // end of files needed by OVA
}, },
shim: { shim: {
...@@ -224,6 +225,9 @@ ...@@ -224,6 +225,9 @@
// global namespace instead of being registered in require. // global namespace instead of being registered in require.
'draggabilly': { 'draggabilly': {
exports: 'Draggabilly' exports: 'Draggabilly'
},
'hls': {
exports: 'Hls'
} }
} }
}); });
......
...@@ -35,11 +35,14 @@ ...@@ -35,11 +35,14 @@
baseUrl: '/base/', baseUrl: '/base/',
paths: { paths: {
moment: 'xmodule_js/common_static/common/js/vendor/moment-with-locales', moment: 'xmodule_js/common_static/common/js/vendor/moment-with-locales',
'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly', draggabilly: 'xmodule_js/common_static/js/vendor/draggabilly',
'edx-ui-toolkit': 'edx-ui-toolkit' 'edx-ui-toolkit': 'edx-ui-toolkit',
hls: 'common/js/vendor/hls'
}, },
'moment': { shim: {
exports: 'moment' moment: {
exports: 'moment'
}
} }
}); });
}).call(this, RequireJS.requirejs, RequireJS.define); }).call(this, RequireJS.requirejs, RequireJS.define);
...@@ -26,6 +26,9 @@ from openedx.core.djangolib.js_utils import js_escaped_string ...@@ -26,6 +26,9 @@ from openedx.core.djangolib.js_utils import js_escaped_string
<div class="video-player"> <div class="video-player">
<div id="${id}"></div> <div id="${id}"></div>
<h4 class="hd hd-4 video-error is-hidden">${_('No playable video sources found.')}</h4> <h4 class="hd hd-4 video-error is-hidden">${_('No playable video sources found.')}</h4>
<h4 class="hd hd-4 video-hls-error is-hidden">
${_('Your browser does not support this video format. Try using a different browser.')}
</h4>
</div> </div>
<div class="video-player-post"></div> <div class="video-player-post"></div>
<div class="closed-captions"></div> <div class="closed-captions"></div>
...@@ -39,7 +42,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string ...@@ -39,7 +42,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
</div> </div>
<div class="focus_grabber last"></div> <div class="focus_grabber last"></div>
% if download_video_link or track or handout or branding_info: % if download_video_link or track or handout or branding_info:
<h3 class="hd hd-4 downloads-heading sr" id="video-download-transcripts_${id}">${_('Downloads and transcripts')}</h3> <h3 class="hd hd-4 downloads-heading sr" id="video-download-transcripts_${id}">${_('Downloads and transcripts')}</h3>
<div class="wrapper-downloads" role="region" aria-labelledby="video-download-transcripts_${id}"> <div class="wrapper-downloads" role="region" aria-labelledby="video-download-transcripts_${id}">
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
"coffee-script": "1.6.1", "coffee-script": "1.6.1",
"edx-pattern-library": "0.18.1", "edx-pattern-library": "0.18.1",
"edx-ui-toolkit": "1.5.1", "edx-ui-toolkit": "1.5.1",
"hls.js": "0.7.2",
"jquery": "~2.2.0", "jquery": "~2.2.0",
"jquery-migrate": "^1.4.1", "jquery-migrate": "^1.4.1",
"jquery.scrollto": "~2.1.2", "jquery.scrollto": "~2.1.2",
......
...@@ -58,6 +58,7 @@ NPM_INSTALLED_LIBRARIES = [ ...@@ -58,6 +58,7 @@ NPM_INSTALLED_LIBRARIES = [
'requirejs/require.js', 'requirejs/require.js',
'underscore.string/dist/underscore.string.js', 'underscore.string/dist/underscore.string.js',
'underscore/underscore.js', 'underscore/underscore.js',
'hls.js/dist/hls.js',
] ]
# A list of NPM installed developer libraries that should be copied into the common # A list of NPM installed developer libraries that should be copied into the common
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment