Commit c3d0a50d by Muhammad Ammar Committed by GitHub

Merge pull request #14514 from edx/ammar/tnl-6513-frontend-hls-support

HLS playback support in video player
parents 163c71e8 ccf6c23d
...@@ -934,6 +934,9 @@ INSTALLED_APPS = ( ...@@ -934,6 +934,9 @@ INSTALLED_APPS = (
# Self-paced course configuration # Self-paced course configuration
'openedx.core.djangoapps.self_paced', 'openedx.core.djangoapps.self_paced',
# Video module configs (This will be moved to Video once it becomes an XBlock)
'openedx.core.djangoapps.video_config',
# django-oauth2-provider (deprecated) # django-oauth2-provider (deprecated)
'provider', 'provider',
'provider.oauth2', 'provider.oauth2',
......
...@@ -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 */
/** /**
* @file HTML5 video player module. Provides methods to control the in-browser * @file HTML5 video player module. Provides methods to control the in-browser
* HTML5 video player. * HTML5 video player.
...@@ -16,11 +17,94 @@ ...@@ -16,11 +17,94 @@
(function(requirejs, require, define) { (function(requirejs, require, define) {
define( define(
'video/02_html5_video.js', 'video/02_html5_video.js',
[], ['underscore'],
function() { function(_) {
var HTML5Video = {}; var HTML5Video = {};
HTML5Video.Player = (function() { HTML5Video.Player = (function() {
/*
* Constructor function for HTML5 Video player.
*
* @param {String|Object} el A DOM element where the HTML5 player will
* be inserted (as returned by jQuery(selector) function), or a
* selector string which will be used to select an element. This is a
* required parameter.
*
* @param config - An object whose properties will be used as
* configuration options for the HTML5 video player. This is an
* optional parameter. In the case if this parameter is missing, or
* some of the config object's properties are missing, defaults will be
* used. The available options (and their defaults) are as
* follows:
*
* config = {
*
* videoSources: [], // An array with properties being video
* // sources. The property name is the
* // video format of the source. Supported
* // video formats are: 'mp4', 'webm', and
* // 'ogg'.
*
* events: { // Object's properties identify the
* // events that the API fires, and the
* // functions (event listeners) that the
* // API will call when those events occur.
* // If value is null, or property is not
* // specified, then no callback will be
* // called for that event.
*
* onReady: null,
* onStateChange: null
* }
* }
*/
function Player(el, config) {
var errorMessage, lastSource, sourceList;
// A simple test to see that the 'config' is a normal object.
if ($.isPlainObject(config) === false) {
return;
}
// We should have at least one video source. Otherwise there is no
// point to continue.
if (!config.videoSources && !config.videoSources.length) {
return;
}
// Create HTML markup for individual sources of the HTML5 <video> element.
sourceList = $.map(config.videoSources, function(source) {
return [
'<source ',
'src="', source,
// Following hack allows to open the same video twice
// https://code.google.com/p/chromium/issues/detail?id=31014
// Check whether the url already has a '?' inside, and if so,
// use '&' instead of '?' to prevent breaking the url's integrity.
(source.indexOf('?') === -1 ? '?' : '&'),
(new Date()).getTime(), '" />'
].join('');
});
// do common initialization independent of player type
if (this.init(el, config) === false) {
return;
}
// Create HTML markup for the <video> element, populating it with
// sources from previous step. Set playback not supported error message.
errorMessage = [
gettext('This browser cannot play .mp4, .ogg, or .webm files.'),
gettext('Try using a different browser, such as Google Chrome.')
].join('');
this.video.innerHTML = sourceList.join('') + errorMessage;
lastSource = this.videoEl.find('source').last();
lastSource.on('error', this.showErrorMessage.bind(this));
lastSource.on('error', this.onError.bind(this));
this.videoEl.on('error', this.onError.bind(this));
}
Player.prototype.callStateChangeCallback = function() { Player.prototype.callStateChangeCallback = function() {
if ($.isFunction(this.config.events.onStateChange)) { if ($.isFunction(this.config.events.onStateChange)) {
this.config.events.onStateChange({ this.config.events.onStateChange({
...@@ -89,27 +173,29 @@ function() { ...@@ -89,27 +173,29 @@ function() {
return [0.75, 1.0, 1.25, 1.5]; return [0.75, 1.0, 1.25, 1.5];
}; };
// eslint-disable-next-line no-underscore-dangle
Player.prototype._getLogs = function() { Player.prototype._getLogs = function() {
return this.logs; return this.logs;
}; };
Player.prototype.showErrorMessage = function() { Player.prototype.showErrorMessage = function(event, css) {
var cssSelecter = css || '.video-player .video-error';
this.el this.el
.find('.video-player div') .find('.video-player div')
.addClass('hidden') .addClass('hidden')
.end() .end()
.find('.video-player .video-error') .find(cssSelecter)
.removeClass('is-hidden') .removeClass('is-hidden')
.end() .end()
.addClass('is-initialized') .addClass('is-initialized')
.find('.spinner') .find('.spinner')
.attr({ .attr({
'aria-hidden': 'true', 'aria-hidden': 'true',
'tabindex': -1 tabindex: -1
}); });
}; };
Player.prototype.onError = function(event) { Player.prototype.onError = function() {
if ($.isFunction(this.config.events.onError)) { if ($.isFunction(this.config.events.onError)) {
this.config.events.onError(); this.config.events.onError();
} }
...@@ -122,11 +208,16 @@ function() { ...@@ -122,11 +208,16 @@ function() {
this.video.removeEventListener('pause', this.onPause, false); this.video.removeEventListener('pause', this.onPause, false);
this.video.removeEventListener('ended', this.onEnded, false); this.video.removeEventListener('ended', this.onEnded, false);
this.el this.el
.find('.video-player div').removeClass('is-hidden') .find('.video-player div')
.removeClass('is-hidden')
.end()
.find('.video-player .video-error')
.addClass('is-hidden')
.end() .end()
.find('.video-player .video-error').addClass('is-hidden') .removeClass('is-initialized')
.end().removeClass('is-initialized') .find('.spinner')
.find('.spinner').attr({'aria-hidden': 'false'}); .attr({'aria-hidden': 'false'});
this.videoEl.off('remove');
this.videoEl.remove(); this.videoEl.remove();
}; };
...@@ -157,57 +248,27 @@ function() { ...@@ -157,57 +248,27 @@ function() {
this.callStateChangeCallback(); this.callStateChangeCallback();
}; };
return Player; Player.prototype.init = function(el, config) {
var isTouch = window.onTouchBasedDevice() || '',
/* events = ['loadstart', 'progress', 'suspend', 'abort', 'error',
* Constructor function for HTML5 Video player. 'emptied', 'stalled', 'play', 'pause', 'loadedmetadata',
* 'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough',
* @param {String|Object} el A DOM element where the HTML5 player will 'seeking', 'seeked', 'timeupdate', 'ended', 'ratechange',
* be inserted (as returned by jQuery(selector) function), or a 'durationchange', 'volumechange'
* selector string which will be used to select an element. This is a ],
* required parameter. self = this,
* errorMessage;
* @param config - An object whose properties will be used as
* configuration options for the HTML5 video player. This is an this.config = config;
* optional parameter. In the case if this parameter is missing, or
* some of the config object's properties are missing, defaults will be
* used. The available options (and their defaults) are as
* follows:
*
* config = {
*
* videoSources: [], // An array with properties being video
* // sources. The property name is the
* // video format of the source. Supported
* // video formats are: 'mp4', 'webm', and
* // 'ogg'.
*
* events: { // Object's properties identify the
* // events that the API fires, and the
* // functions (event listeners) that the
* // API will call when those events occur.
* // If value is null, or property is not
* // specified, then no callback will be
* // called for that event.
*
* onReady: null,
* onStateChange: null
* }
* }
*/
function Player(el, config) {
var isTouch = onTouchBasedDevice() || '',
sourceList, _this, errorMessage, lastSource;
_.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded');
this.logs = []; this.logs = [];
// Initially we assume that el is a DOM element. If jQuery selector // Initially we assume that el is a DOM element. If jQuery selector
// fails to select something, we assume that el is an ID of a DOM // fails to select something, we assume that el is an ID of a DOM
// element. We try to select by ID. If jQuery fails this time, we // element. We try to select by ID. If jQuery fails this time, we
// return. Nothing breaks because the player 'onReady' event will // return. Nothing breaks because the player 'onReady' event will
// never be fired. // never be fired.
this.el = $(el); this.el = $(el);
if (this.el.length === 0) { if (this.el.length === 0) {
this.el = $('#' + el); this.el = $('#' + el);
...@@ -218,106 +279,51 @@ function() { ...@@ -218,106 +279,51 @@ function() {
} else { } else {
throw new Error(errorMessage); throw new Error(errorMessage);
} }
return; return false;
} }
} }
// A simple test to see that the 'config' is a normal object. // Because of problems with creating video element via jquery
if ($.isPlainObject(config)) { // (http://bugs.jquery.com/ticket/9174) we create it using native JS.
this.config = config;
} else {
return;
}
// We should have at least one video source. Otherwise there is no
// point to continue.
if (!config.videoSources && !config.videoSources.length) {
return;
}
// Will be used in inner functions to point to the current object.
_this = this;
// Create HTML markup for individual sources of the HTML5 <video>
// element.
sourceList = $.map(config.videoSources, function(source) {
return [
'<source ',
'src="', source,
// Following hack allows to open the same video twice
// https://code.google.com/p/chromium/issues/detail?id=31014
// Check whether the url already has a '?' inside, and if so,
// use '&' instead of '?' to prevent breaking the url's integrity.
(source.indexOf('?') === -1 ? '?' : '&'),
(new Date()).getTime(), '" />'
].join('');
});
// Create HTML markup for the <video> element, populating it with
// sources from previous step. Because of problems with creating
// video element via jquery (http://bugs.jquery.com/ticket/9174) we
// create it using native JS.
this.video = document.createElement('video'); this.video = document.createElement('video');
errorMessage = [ // Get the jQuery object and set error event handlers
gettext('This browser cannot play .mp4, .ogg, or .webm files.'), this.videoEl = $(this.video);
gettext('Try using a different browser, such as Google Chrome.')
].join('');
this.video.innerHTML = sourceList.join('') + errorMessage;
// Get the jQuery object, and set the player state to UNSTARTED.
// The player state is used by other parts of the VideoPlayer to // The player state is used by other parts of the VideoPlayer to
// determine what the video is currently doing. // determine what the video is currently doing.
this.videoEl = $(this.video);
lastSource = this.videoEl.find('source').last();
lastSource.on('error', this.showErrorMessage.bind(this));
lastSource.on('error', this.onError.bind(this));
this.videoEl.on('error', this.onError.bind(this));
if (/iP(hone|od)/i.test(isTouch[0])) {
this.videoEl.prop('controls', true);
}
this.playerState = HTML5Video.PlayerState.UNSTARTED; this.playerState = HTML5Video.PlayerState.UNSTARTED;
_.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded');
// Attach a 'click' event on the <video> element. It will cause the // Attach a 'click' event on the <video> element. It will cause the
// video to pause/play. // video to pause/play.
this.videoEl.on('click', function(event) { this.videoEl.on('click', function() {
var PlayerState = HTML5Video.PlayerState; var PlayerState = HTML5Video.PlayerState;
if (_this.playerState === PlayerState.PLAYING) { if (self.playerState === PlayerState.PLAYING) {
_this.playerState = PlayerState.PAUSED; self.playerState = PlayerState.PAUSED;
_this.pauseVideo(); self.pauseVideo();
} else { } else {
_this.playerState = PlayerState.PLAYING; self.playerState = PlayerState.PLAYING;
_this.playVideo(); self.playVideo();
} }
}); });
var events = ['loadstart', 'progress', 'suspend', 'abort', 'error',
'emptied', 'stalled', 'play', 'pause', 'loadedmetadata',
'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough',
'seeking', 'seeked', 'timeupdate', 'ended', 'ratechange',
'durationchange', 'volumechange'
];
this.debug = false; this.debug = false;
$.each(events, function(index, eventName) { $.each(events, function(index, eventName) {
_this.video.addEventListener(eventName, function() { self.video.addEventListener(eventName, function() {
_this.logs.push({ self.logs.push({
'event name': eventName, 'event name': eventName,
'state': _this.playerState state: self.playerState
}); });
if (_this.debug) { if (self.debug) {
console.log( console.log(
'event name:', eventName, 'event name:', eventName,
'state:', _this.playerState, 'state:', self.playerState,
'readyState:', _this.video.readyState, 'readyState:', self.video.readyState,
'networkState:', _this.video.networkState 'networkState:', self.video.networkState
); );
} }
...@@ -334,9 +340,17 @@ function() { ...@@ -334,9 +340,17 @@ function() {
this.video.addEventListener('pause', this.onPause, false); this.video.addEventListener('pause', this.onPause, false);
this.video.addEventListener('ended', this.onEnded, false); this.video.addEventListener('ended', this.onEnded, false);
if (/iP(hone|od)/i.test(isTouch[0])) {
this.videoEl.prop('controls', true);
}
// Place the <video> element on the page. // Place the <video> element on the page.
this.videoEl.appendTo(this.el.find('.video-player div')); this.videoEl.appendTo(el.find('.video-player > div:first-child'));
}
return true;
};
return Player;
}()); }());
// The YouTube API presents several constants which describe the player's // The YouTube API presents several constants which describe the player's
......
/* 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);
} }
......
...@@ -24,6 +24,7 @@ from pkg_resources import resource_string ...@@ -24,6 +24,7 @@ from pkg_resources import resource_string
from django.conf import settings from django.conf import settings
from openedx.core.lib.cache_utils import memoize_in_request_cache from openedx.core.lib.cache_utils import memoize_in_request_cache
from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from xblock.runtime import KvsFieldData from xblock.runtime import KvsFieldData
...@@ -124,6 +125,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -124,6 +125,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'),
...@@ -216,7 +218,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -216,7 +218,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
# stream. # stream.
if self.edx_video_id and edxval_api: if self.edx_video_id and edxval_api:
try: try:
val_profiles = ["youtube", "desktop_webm", "desktop_mp4", "hls"] val_profiles = ["youtube", "desktop_webm", "desktop_mp4"]
if HLSPlaybackEnabledFlag.feature_enabled(self.course_id):
val_profiles.append('hls')
# strip edx_video_id to prevent ValVideoNotFoundError error if unwanted spaces are there. TNL-5769 # strip edx_video_id to prevent ValVideoNotFoundError error if unwanted spaces are there. TNL-5769
val_video_urls = edxval_api.get_urls_for_profiles(self.edx_video_id.strip(), val_profiles) val_video_urls = edxval_api.get_urls_for_profiles(self.edx_video_id.strip(), val_profiles)
......
...@@ -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):
......
...@@ -35,6 +35,10 @@ HTML5_SOURCES_INCORRECT = [ ...@@ -35,6 +35,10 @@ HTML5_SOURCES_INCORRECT = [
'http://localhost:{0}/gizmo.mp99'.format(VIDEO_SOURCE_PORT), 'http://localhost:{0}/gizmo.mp99'.format(VIDEO_SOURCE_PORT),
] ]
HLS_SOURCES = [
'http://localhost:{0}/hls/history.m3u8'.format(VIDEO_SOURCE_PORT),
]
@skipIf(is_youtube_available() is False, 'YouTube is not available!') @skipIf(is_youtube_available() is False, 'YouTube is not available!')
class VideoBaseTest(UniqueCourseTest): class VideoBaseTest(UniqueCourseTest):
...@@ -155,13 +159,16 @@ class VideoBaseTest(UniqueCourseTest): ...@@ -155,13 +159,16 @@ class VideoBaseTest(UniqueCourseTest):
:return: dict :return: dict
""" """
metadata = {} metadata = {}
youtube_ids = {
'youtube_id_1_0': '',
'youtube_id_0_75': '',
'youtube_id_1_25': '',
'youtube_id_1_5': '',
}
if player_mode == 'html5': if player_mode == 'html5':
metadata.update(youtube_ids)
metadata.update({ metadata.update({
'youtube_id_1_0': '',
'youtube_id_0_75': '',
'youtube_id_1_25': '',
'youtube_id_1_5': '',
'html5_sources': HTML5_SOURCES 'html5_sources': HTML5_SOURCES
}) })
...@@ -176,14 +183,23 @@ class VideoBaseTest(UniqueCourseTest): ...@@ -176,14 +183,23 @@ class VideoBaseTest(UniqueCourseTest):
}) })
if player_mode == 'html5_unsupported_video': if player_mode == 'html5_unsupported_video':
metadata.update(youtube_ids)
metadata.update({ metadata.update({
'youtube_id_1_0': '',
'youtube_id_0_75': '',
'youtube_id_1_25': '',
'youtube_id_1_5': '',
'html5_sources': HTML5_SOURCES_INCORRECT 'html5_sources': HTML5_SOURCES_INCORRECT
}) })
if player_mode == 'hls':
metadata.update(youtube_ids)
metadata.update({
'html5_sources': HLS_SOURCES,
})
if player_mode == 'html5_and_hls':
metadata.update(youtube_ids)
metadata.update({
'html5_sources': HTML5_SOURCES + HLS_SOURCES,
})
if additional_data: if additional_data:
metadata.update(additional_data) metadata.update(additional_data)
...@@ -611,12 +627,12 @@ class YouTubeVideoTest(VideoBaseTest): ...@@ -611,12 +627,12 @@ class YouTubeVideoTest(VideoBaseTest):
self.video.click_player_button('pause') self.video.click_player_button('pause')
self.video.select_language('en') self.video.select_language('en')
self.video.click_first_line_in_transcript() self.video.click_transcript_line(line_no=1)
self._verify_closed_caption_text('Welcome to edX.') self._verify_closed_caption_text('Welcome to edX.')
self.video.select_language('zh') self.video.select_language('zh')
unicode_text = "我们今天要讲的题目是".decode('utf-8') unicode_text = "我们今天要讲的题目是".decode('utf-8')
self.video.click_first_line_in_transcript() self.video.click_transcript_line(line_no=1)
self._verify_closed_caption_text(unicode_text) self._verify_closed_caption_text(unicode_text)
def test_multiple_videos_in_sequentials_load_and_work(self): def test_multiple_videos_in_sequentials_load_and_work(self):
...@@ -1264,3 +1280,196 @@ class LMSVideoModuleA11yTest(VideoBaseTest): ...@@ -1264,3 +1280,196 @@ class LMSVideoModuleA11yTest(VideoBaseTest):
include=["div.video"] include=["div.video"]
) )
self.video.a11y_audit.check_for_accessibility_errors() self.video.a11y_audit.check_for_accessibility_errors()
@attr(shard=4)
class VideoPlayOrderTest(VideoBaseTest):
"""
Test video play order with multiple videos
Priority of video formats is:
* Youtube
* HLS
* HTML5
"""
def setUp(self):
super(VideoPlayOrderTest, self).setUp()
def test_play_youtube_video(self):
"""
Scenario: Correct video is played when we have different video formats.
Given the course has a Video component with Youtube, HTML5 and HLS sources available.
When I view the Video component
Then it should play the Youtube video
"""
additional_data = {'youtube_id_1_0': 'b7xgknqkQk8'}
self.metadata = self.metadata_for_mode('html5_and_hls', additional_data=additional_data)
self.navigate_to_video()
# Verify that the video is youtube
self.assertTrue(self.video.is_video_rendered('youtube'))
def test_play_html5_hls_video(self):
"""
Scenario: HLS video is played when we have HTML5 and HLS video formats only.
Given the course has a Video component with HTML5 and HLS sources available.
When I view the Video component
Then it should play the HLS video
"""
self.metadata = self.metadata_for_mode('html5_and_hls')
self.navigate_to_video()
# Verify that the video is hls
self.assertTrue(self.video.is_video_rendered('hls'))
@attr(shard=4)
class HLSVideoTest(VideoBaseTest):
"""
Tests related to HLS video
"""
def test_video_play_pause(self):
"""
Scenario: Video play and pause is working as expected for hls video
Given the course has a Video component with only HLS source available.
When I view the Video component
Then I can see play and pause are working as expected
"""
self.metadata = self.metadata_for_mode('hls')
self.navigate_to_video()
self.video.click_player_button('play')
self.assertEqual(self.video.state, 'playing')
self.video.click_player_button('pause')
self.assertEqual(self.video.state, 'pause')
def test_video_seek(self):
"""
Scenario: Video seek is working as expected for hls video
Given the course has a Video component with only HLS source available.
When I view the Video component
Then I can seek the video as expected
"""
self.metadata = self.metadata_for_mode('hls')
self.navigate_to_video()
self.video.click_player_button('play')
self.video.wait_for_position('0:02')
self.video.click_player_button('pause')
self.video.seek('0:05')
self.assertEqual(self.video.position, '0:05')
def test_video_position_save_state(self):
"""
Scenario: Video position save state functionality is working as expected for hls video
Given the course has a Video component with only HLS source available.
When I view the Video component
Then I can see video save state is working as expected
"""
self.metadata = self.metadata_for_mode('hls')
self.navigate_to_video()
self.video.click_player_button('play')
self.video.wait_for_position('0:04')
self.video.click_player_button('pause')
self.assertEqual(self.video.position, '0:04')
self.video.reload_page()
self.assertEqual(self.video.duration, '0:09')
self.assertEqual(self.video.position, '0:04')
self.video.click_player_button('play')
self.assertGreaterEqual(self.video.seconds, 4)
def test_video_download_link(self):
"""
Scenario: Correct video url is selected for download
Given the course has a Video component with Youtube, HTML5 and HLS sources available.
When I view the Video component
Then HTML5 video download url is available
"""
self.metadata = self.metadata_for_mode('html5_and_hls', additional_data={'download_video': True})
self.navigate_to_video()
# Verify that the video download url is correct
self.assertEqual(self.video.video_download_url, HTML5_SOURCES[0])
def test_no_video_download_link_for_hls(self):
"""
Scenario: Video download url is not shown for hls videos
Given the course has a Video component with only HLS sources available.
When I view the Video component
Then there is no video download url shown
"""
additional_data = {'download_video': True}
self.metadata = self.metadata_for_mode('hls', additional_data=additional_data)
self.navigate_to_video()
# Verify that the video download url is not shown
self.assertEqual(self.video.video_download_url, None)
def test_hls_video_with_youtube_blocked(self):
"""
Scenario: HLS video is rendered when the YouTube API is blocked
Given the YouTube API is blocked
And the course has a Video component with Youtube, HTML5 and HLS sources available
Then the HLS video is rendered
"""
# configure youtube server
self.youtube_configuration.update({
'youtube_api_blocked': True,
})
self.metadata = self.metadata_for_mode('html5_and_hls', additional_data={'youtube_id_1_0': 'b7xgknqkQk8'})
self.navigate_to_video()
self.assertTrue(self.video.is_video_rendered('hls'))
def test_hls_video_with_youtube_delayed_response_time(self):
"""
Scenario: HLS video is rendered when the YouTube API response time is slow
Given the YouTube server response time is greater than 1.5 seconds
And the course has a Video component with Youtube, HTML5 and HLS sources available
Then the HLS video is rendered
"""
# configure youtube server
self.youtube_configuration.update({
'time_to_response': 7.0,
})
self.metadata = self.metadata_for_mode('html5_and_hls', additional_data={'youtube_id_1_0': 'b7xgknqkQk8'})
self.navigate_to_video()
self.assertTrue(self.video.is_video_rendered('hls'))
def test_hls_video_with_transcript(self):
"""
Scenario: Transcript work as expected for an HLS video
Given the course has a Video component with "HLS" video only
And I have defined a transcript for the video
Then I see the correct text in the captions for transcript
Then I click on a caption line
And video position should be updated accordingly
Then I change video position
And video caption should be updated accordingly
"""
data = {'transcripts': {'zh': 'transcript.srt'}}
self.metadata = self.metadata_for_mode('hls', additional_data=data)
self.assets.append('transcript.srt')
self.navigate_to_video()
self.assertIn("Hi, edX welcomes you0.", self.video.captions_text)
for line_no in range(5):
self.video.click_transcript_line(line_no=line_no)
self.video.wait_for_position('0:0{}'.format(line_no))
for line_no in range(5):
self.video.seek('0:0{}'.format(line_no))
self.assertEqual(self.video.active_caption_text, 'Hi, edX welcomes you{}.'.format(line_no))
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.
...@@ -177,6 +177,7 @@ class TestVideoNonYouTube(TestVideo): ...@@ -177,6 +177,7 @@ class TestVideoNonYouTube(TestVideo):
@attr(shard=1) @attr(shard=1)
@ddt.ddt
class TestGetHtmlMethod(BaseTestXmodule): class TestGetHtmlMethod(BaseTestXmodule):
''' '''
Make sure that `get_html` works correctly. Make sure that `get_html` works correctly.
...@@ -855,6 +856,33 @@ class TestGetHtmlMethod(BaseTestXmodule): ...@@ -855,6 +856,33 @@ class TestGetHtmlMethod(BaseTestXmodule):
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context) self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
) )
@ddt.data(
(True, ['youtube', 'desktop_webm', 'desktop_mp4', 'hls']),
(False, ['youtube', 'desktop_webm', 'desktop_mp4'])
)
@ddt.unpack
def test_get_html_on_toggling_hls_feature(self, hls_feature_enabled, expected_val_profiles):
"""
Verify val profiles on toggling HLS Playback feature.
"""
with patch('xmodule.video_module.video_module.edxval_api.get_urls_for_profiles') as get_urls_for_profiles:
get_urls_for_profiles.return_value = {
'desktop_webm': 'https://webm.com/dw.webm',
'hls': 'https://hls.com/hls.m3u8',
'youtube': 'https://yt.com/?v=v0TFmdO4ZP0',
'desktop_mp4': 'https://mp4.com/dm.mp4'
}
with patch('xmodule.video_module.video_module.HLSPlaybackEnabledFlag.feature_enabled') as feature_enabled:
feature_enabled.return_value = hls_feature_enabled
video_xml = '<video display_name="Video" download_video="true" edx_video_id="12345-67890">[]</video>'
self.initialize_module(data=video_xml)
self.item_descriptor.render(STUDENT_VIEW)
get_urls_for_profiles.assert_called_with(
self.item_descriptor.edx_video_id,
expected_val_profiles,
)
@patch('xmodule.video_module.video_module.HLSPlaybackEnabledFlag.feature_enabled', Mock(return_value=True))
@patch('xmodule.video_module.video_module.edxval_api.get_urls_for_profiles') @patch('xmodule.video_module.video_module.edxval_api.get_urls_for_profiles')
def test_get_html_hls(self, get_urls_for_profiles): def test_get_html_hls(self, get_urls_for_profiles):
""" """
......
...@@ -1743,7 +1743,8 @@ REQUIRE_JS_PATH_OVERRIDES = { ...@@ -1743,7 +1743,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 ###############################
...@@ -2155,6 +2156,9 @@ INSTALLED_APPS = ( ...@@ -2155,6 +2156,9 @@ INSTALLED_APPS = (
# Verified Track Content Cohorting (Beta feature that will hopefully be removed) # Verified Track Content Cohorting (Beta feature that will hopefully be removed)
'openedx.core.djangoapps.verified_track_content', 'openedx.core.djangoapps.verified_track_content',
# Video module configs (This will be moved to Video once it becomes an XBlock)
'openedx.core.djangoapps.video_config',
# Learner's dashboard # Learner's dashboard
'learner_dashboard', 'learner_dashboard',
......
...@@ -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}">
......
# TODO Move this Application to video codebase when Video XModule becomes an XBlock. Reference: TNL-6867.
"""
Django admin dashboard configuration for Video XModule.
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from openedx.core.djangoapps.video_config.forms import CourseHLSPlaybackFlagAdminForm
from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabledFlag, HLSPlaybackEnabledFlag
class CourseHLSPlaybackEnabledFlagAdmin(KeyedConfigurationModelAdmin):
"""
Admin of HLS Playback feature on course-by-course basis.
Allows searching by course id.
"""
form = CourseHLSPlaybackFlagAdminForm
search_fields = ['course_id']
fieldsets = (
(None, {
'fields': ('course_id', 'enabled'),
'description': 'Enter a valid course id. If it is invalid, an error message will be displayed.'
}),
)
admin.site.register(HLSPlaybackEnabledFlag, ConfigurationModelAdmin)
admin.site.register(CourseHLSPlaybackEnabledFlag, CourseHLSPlaybackEnabledFlagAdmin)
"""
Defines a form for providing validation of HLS Playback course-specific configuration.
"""
import logging
from django import forms
from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabledFlag
from opaque_keys import InvalidKeyError
from xmodule.modulestore.django import modulestore
from opaque_keys.edx.locator import CourseLocator
log = logging.getLogger(__name__)
class CourseHLSPlaybackFlagAdminForm(forms.ModelForm):
"""
Form for course-specific HLS Playback configuration.
"""
class Meta(object):
model = CourseHLSPlaybackEnabledFlag
fields = '__all__'
def clean_course_id(self):
"""
Validate the course id
"""
cleaned_id = self.cleaned_data["course_id"]
try:
course_key = CourseLocator.from_string(cleaned_id)
except InvalidKeyError:
msg = u'Course id invalid. Entered course id was: "{course_id}."'.format(
course_id=cleaned_id
)
raise forms.ValidationError(msg)
if not modulestore().has_course(course_key):
msg = u'Course not found. Entered course id was: "{course_key}". '.format(
course_key=unicode(course_key)
)
raise forms.ValidationError(msg)
return course_key
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion
import openedx.core.djangoapps.xmodule_django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CourseHLSPlaybackEnabledFlag',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
migrations.CreateModel(
name='HLSPlaybackEnabledFlag',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('enabled_for_all_courses', models.BooleanField(default=False)),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
]
"""
Configuration models for Video XModule
"""
from config_models.models import ConfigurationModel
from django.db.models import BooleanField
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
class HLSPlaybackEnabledFlag(ConfigurationModel):
"""
Enables HLS Playback across the platform.
When this feature flag is set to true, individual courses
must also have HLS Playback enabled for this feature to
take effect.
"""
# this field overrides course-specific settings
enabled_for_all_courses = BooleanField(default=False)
@classmethod
def feature_enabled(cls, course_id):
"""
Looks at the currently active configuration model to determine whether
the HLS Playback feature is available.
If the feature flag is not enabled, the feature is not available.
If the flag is enabled for all the courses, feature is available.
If the flag is enabled and the provided course_id is for an course
with HLS Playback enabled, the feature is available.
Arguments:
course_id (CourseKey): course id for whom feature will be checked.
"""
if not HLSPlaybackEnabledFlag.is_enabled():
return False
elif not HLSPlaybackEnabledFlag.current().enabled_for_all_courses:
feature = (CourseHLSPlaybackEnabledFlag.objects
.filter(course_id=course_id)
.order_by('-change_date')
.first())
return feature.enabled if feature else False
return True
def __unicode__(self):
current_model = HLSPlaybackEnabledFlag.current()
return u"HLSPlaybackEnabledFlag: enabled {is_enabled}".format(
is_enabled=current_model.is_enabled()
)
class CourseHLSPlaybackEnabledFlag(ConfigurationModel):
"""
Enables HLS Playback for a specific course. Global feature must be
enabled for this to take effect.
"""
KEY_FIELDS = ('course_id',)
course_id = CourseKeyField(max_length=255, db_index=True)
def __unicode__(self):
not_en = "Not "
if self.enabled:
not_en = ""
return u"Course '{course_key}': HLS Playback {not_enabled}Enabled".format(
course_key=unicode(self.course_id),
not_enabled=not_en
)
"""
Tests for the models that configures HLS Playback feature.
"""
import ddt
import itertools
from contextlib import contextmanager
from django.test import TestCase
from opaque_keys.edx.locator import CourseLocator
from openedx.core.djangoapps.video_config.models import CourseHLSPlaybackEnabledFlag, HLSPlaybackEnabledFlag
@contextmanager
def hls_playback_feature_flags(
global_flag, enabled_for_all_courses=False,
course_id=None, enabled_for_course=False
):
"""
Yields HLS Playback Configuration records for unit tests
Arguments:
global_flag (bool): Specifies whether feature is enabled globally
enabled_for_all_courses (bool): Specifies whether feature is enabled for all courses
course_id (CourseLocator): Course locator for course specific configurations
enabled_for_course (bool): Specifies whether feature should be available for a course
"""
HLSPlaybackEnabledFlag.objects.create(enabled=global_flag, enabled_for_all_courses=enabled_for_all_courses)
if course_id:
CourseHLSPlaybackEnabledFlag.objects.create(course_id=course_id, enabled=enabled_for_course)
yield
@ddt.ddt
class TestHLSPlaybackFlag(TestCase):
"""
Tests the behavior of the flags for HLS Playback feature.
These are set via Django admin settings.
"""
def setUp(self):
super(TestHLSPlaybackFlag, self).setUp()
self.course_id_1 = CourseLocator(org="edx", course="course", run="run")
self.course_id_2 = CourseLocator(org="edx", course="course2", run="run")
@ddt.data(
*itertools.product(
(True, False),
(True, False),
(True, False),
)
)
@ddt.unpack
def test_hls_playback_feature_flags(self, global_flag, enabled_for_all_courses, enabled_for_course_1):
"""
Tests that the feature flags works correctly on tweaking global flags in combination
with course-specific flags.
"""
with hls_playback_feature_flags(
global_flag=global_flag,
enabled_for_all_courses=enabled_for_all_courses,
course_id=self.course_id_1,
enabled_for_course=enabled_for_course_1
):
self.assertEqual(
HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1),
global_flag and (enabled_for_all_courses or enabled_for_course_1)
)
self.assertEqual(
HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2),
global_flag and enabled_for_all_courses
)
def test_enable_disable_course_flag(self):
"""
Ensures that the flag, once enabled for a course, can also be disabled.
"""
with hls_playback_feature_flags(
global_flag=True,
enabled_for_all_courses=False,
course_id=self.course_id_1,
enabled_for_course=True
):
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
with hls_playback_feature_flags(
global_flag=True,
enabled_for_all_courses=False,
course_id=self.course_id_1,
enabled_for_course=False
):
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
def test_enable_disable_globally(self):
"""
Ensures that the flag, once enabled globally, can also be disabled.
"""
with hls_playback_feature_flags(
global_flag=True,
enabled_for_all_courses=True,
):
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
self.assertTrue(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2))
with hls_playback_feature_flags(
global_flag=True,
enabled_for_all_courses=False,
):
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2))
with hls_playback_feature_flags(
global_flag=False,
):
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_1))
self.assertFalse(HLSPlaybackEnabledFlag.feature_enabled(self.course_id_2))
...@@ -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