Merge pull request #14514 from edx/ammar/tnl-6513-frontend-hls-support
HLS playback support in video player
This commit is contained in:
@@ -934,6 +934,9 @@ INSTALLED_APPS = (
|
||||
# Self-paced course configuration
|
||||
'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)
|
||||
'provider',
|
||||
'provider.oauth2',
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
'ieshim': 'js/src/ie_shim',
|
||||
'tooltip_manager': 'js/src/tooltip_manager',
|
||||
'draggabilly': 'js/vendor/draggabilly',
|
||||
'hls': 'common/js/vendor/hls',
|
||||
|
||||
// Files needed for Annotations feature
|
||||
'annotator': 'js/vendor/ova/annotator-full',
|
||||
|
||||
@@ -250,7 +250,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark
|
||||
}
|
||||
}
|
||||
|
||||
.video-error {
|
||||
.video-error, .video-hls-error {
|
||||
padding: ($baseline / 5);
|
||||
background: black;
|
||||
color: white !important; // the pattern library headings shim is more scoped
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
#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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
#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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
#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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
#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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
#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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
common/lib/xmodule/xmodule/js/fixtures/hls/hls.m3u8
Normal file
11
common/lib/xmodule/xmodule/js/fixtures/hls/hls.m3u8
Normal file
@@ -0,0 +1,11 @@
|
||||
#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
|
||||
29
common/lib/xmodule/xmodule/js/fixtures/video_hls.html
Normal file
29
common/lib/xmodule/xmodule/js/fixtures/video_hls.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<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 = {
|
||||
{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/i18n.js', included: true},
|
||||
{pattern: 'common_static/common/js/vendor/hls.js', included: true},
|
||||
{pattern: 'public/js/split_test_staff.js', included: true},
|
||||
{pattern: 'src/word_cloud/d3.min.js', included: true},
|
||||
|
||||
@@ -77,7 +78,8 @@ var options = {
|
||||
],
|
||||
|
||||
fixtureFiles: [
|
||||
{pattern: 'fixtures/*.*'}
|
||||
{pattern: 'fixtures/*.*'},
|
||||
{pattern: 'fixtures/hls/**/*.*'}
|
||||
],
|
||||
|
||||
runFiles: [
|
||||
|
||||
@@ -264,8 +264,17 @@
|
||||
return state;
|
||||
};
|
||||
|
||||
jasmine.initializeHLSPlayer = function(params) {
|
||||
return jasmine.initializePlayer('video_hls.html', params);
|
||||
};
|
||||
|
||||
jasmine.initializePlayerYouTube = function(params) {
|
||||
// "video.html" contains HTML template for a YouTube video.
|
||||
return jasmine.initializePlayer('video.html', params);
|
||||
};
|
||||
|
||||
jasmine.DescribeInfo = function(description, specDefinitions) {
|
||||
this.description = description;
|
||||
this.specDefinitions = specDefinitions;
|
||||
};
|
||||
}).call(this);
|
||||
|
||||
@@ -35,11 +35,17 @@
|
||||
baseUrl: '/base/',
|
||||
paths: {
|
||||
moment: 'common_static/common/js/vendor/moment-with-locales',
|
||||
'draggabilly': 'common_static/js/vendor/draggabilly',
|
||||
'edx-ui-toolkit': 'common_static/edx-ui-toolkit'
|
||||
draggabilly: 'common_static/js/vendor/draggabilly',
|
||||
'edx-ui-toolkit': 'common_static/edx-ui-toolkit',
|
||||
hls: 'common_static/common/js/vendor/hls'
|
||||
},
|
||||
'moment': {
|
||||
exports: 'moment'
|
||||
shim: {
|
||||
moment: {
|
||||
exports: 'moment'
|
||||
},
|
||||
hls: {
|
||||
exports: 'Hls'
|
||||
}
|
||||
}
|
||||
});
|
||||
}).call(this, RequireJS.requirejs, RequireJS.define);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
(function(undefined) {
|
||||
describe('Video HTML5Video', function() {
|
||||
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() {
|
||||
oldOTBD = window.onTouchBasedDevice;
|
||||
@@ -17,10 +20,8 @@
|
||||
window.onTouchBasedDevice = oldOTBD;
|
||||
});
|
||||
|
||||
describe('on non-Touch devices', function() {
|
||||
describeInfo = new jasmine.DescribeInfo('on non-Touch devices ', function() {
|
||||
beforeEach(function() {
|
||||
state = jasmine.initializePlayer('video_html5.html');
|
||||
|
||||
state.videoPlayer.player.config.events.onReady = jasmine.createSpy('onReady');
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
window.onTouchBasedDevice.and.returnValue(['iPhone']);
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
(function(undefined) {
|
||||
'use strict';
|
||||
describe('VideoPlayer Events plugin', function() {
|
||||
var state, oldOTBD, Logger = window.Logger;
|
||||
var describeInfo, state, oldOTBD;
|
||||
|
||||
describeInfo = new jasmine.DescribeInfo('', function() {
|
||||
var Logger = window.Logger;
|
||||
|
||||
beforeEach(function() {
|
||||
oldOTBD = window.onTouchBasedDevice;
|
||||
window.onTouchBasedDevice = jasmine
|
||||
.createSpy('onTouchBasedDevice')
|
||||
.and.returnValue(null);
|
||||
|
||||
state = jasmine.initializePlayer();
|
||||
spyOn(Logger, 'log');
|
||||
spyOn(state.videoEventsPlugin, 'getCurrentTime').and.returnValue(10);
|
||||
});
|
||||
@@ -27,7 +23,7 @@
|
||||
state.el.trigger('ready');
|
||||
expect(Logger.log).toHaveBeenCalledWith('load_video', {
|
||||
id: 'id',
|
||||
code: 'html5'
|
||||
code: this.code
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +32,7 @@
|
||||
state.el.trigger('play');
|
||||
expect(Logger.log).toHaveBeenCalledWith('play_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
currentTime: 10
|
||||
});
|
||||
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeFalsy();
|
||||
@@ -52,7 +48,7 @@
|
||||
state.el.trigger('pause');
|
||||
expect(Logger.log).toHaveBeenCalledWith('pause_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
currentTime: 10
|
||||
});
|
||||
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
|
||||
@@ -62,7 +58,7 @@
|
||||
state.el.trigger('speedchange', ['2.0', '1.0']);
|
||||
expect(Logger.log).toHaveBeenCalledWith('speed_change_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
current_time: 10,
|
||||
old_speed: '1.0',
|
||||
new_speed: '2.0'
|
||||
@@ -73,7 +69,7 @@
|
||||
state.el.trigger('seek', [1, 0, 'any']);
|
||||
expect(Logger.log).toHaveBeenCalledWith('seek_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
old_time: 0,
|
||||
new_time: 1,
|
||||
type: 'any'
|
||||
@@ -91,7 +87,7 @@
|
||||
state.el.trigger('ended');
|
||||
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
currentTime: 10
|
||||
});
|
||||
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
|
||||
@@ -100,7 +96,7 @@
|
||||
state.el.trigger('stop');
|
||||
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
currentTime: 10
|
||||
});
|
||||
expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy();
|
||||
@@ -110,7 +106,7 @@
|
||||
state.el.trigger('skip', [false]);
|
||||
expect(Logger.log).toHaveBeenCalledWith('skip_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
currentTime: 10
|
||||
});
|
||||
});
|
||||
@@ -119,7 +115,7 @@
|
||||
state.el.trigger('skip', [true]);
|
||||
expect(Logger.log).toHaveBeenCalledWith('do_not_show_again_video', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
currentTime: 10
|
||||
});
|
||||
});
|
||||
@@ -128,7 +124,7 @@
|
||||
state.el.trigger('language_menu:show');
|
||||
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.shown', {
|
||||
id: 'id',
|
||||
code: 'html5'
|
||||
code: this.code
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,7 +132,7 @@
|
||||
state.el.trigger('language_menu:hide');
|
||||
expect(Logger.log).toHaveBeenCalledWith('edx.video.language_menu.hidden', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
language: 'en'
|
||||
});
|
||||
});
|
||||
@@ -145,7 +141,7 @@
|
||||
state.el.trigger('transcript:show');
|
||||
expect(Logger.log).toHaveBeenCalledWith('show_transcript', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
current_time: 10
|
||||
});
|
||||
});
|
||||
@@ -154,7 +150,7 @@
|
||||
state.el.trigger('transcript:hide');
|
||||
expect(Logger.log).toHaveBeenCalledWith('hide_transcript', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
current_time: 10
|
||||
});
|
||||
});
|
||||
@@ -163,7 +159,7 @@
|
||||
state.el.trigger('captions:show');
|
||||
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.shown', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
current_time: 10
|
||||
});
|
||||
});
|
||||
@@ -172,7 +168,7 @@
|
||||
state.el.trigger('captions:hide');
|
||||
expect(Logger.log).toHaveBeenCalledWith('edx.video.closed_captions.hidden', {
|
||||
id: 'id',
|
||||
code: 'html5',
|
||||
code: this.code,
|
||||
current_time: 10
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
'use strict';
|
||||
|
||||
require(
|
||||
['video/03_video_player.js'],
|
||||
function(VideoPlayer) {
|
||||
['video/03_video_player.js', 'hls'],
|
||||
function(VideoPlayer, HLS) {
|
||||
describe('VideoPlayer', function() {
|
||||
var state, oldOTBD, empty_arguments;
|
||||
|
||||
@@ -969,6 +969,48 @@ function(VideoPlayer) {
|
||||
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));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint no-console:0 */
|
||||
/* eslint-disable no-console, no-param-reassign */
|
||||
/**
|
||||
* @file Initialize module works with the JSON config, and sets up various
|
||||
* settings, parameters, variables. After all setup actions are performed, it
|
||||
@@ -278,6 +278,18 @@ function(VideoPlayer, i18n, moment, _) {
|
||||
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)
|
||||
// The function prepare HTML5 video, parse HTML5
|
||||
// video sources etc.
|
||||
@@ -325,6 +337,7 @@ function(VideoPlayer, i18n, moment, _) {
|
||||
state.controlHideTimeout = null;
|
||||
state.captionState = 'invisible';
|
||||
state.captionHideTimeout = null;
|
||||
state.HLSVideoSources = extractHLSVideoSources(state);
|
||||
}
|
||||
|
||||
function _initializeModules(state, i18n) {
|
||||
|
||||
108
common/lib/xmodule/xmodule/js/src/video/02_html5_hls_video.js
Normal file
108
common/lib/xmodule/xmodule/js/src/video/02_html5_hls_video.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/* 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));
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-console, no-param-reassign */
|
||||
/**
|
||||
* @file HTML5 video player module. Provides methods to control the in-browser
|
||||
* HTML5 video player.
|
||||
@@ -16,11 +17,94 @@
|
||||
(function(requirejs, require, define) {
|
||||
define(
|
||||
'video/02_html5_video.js',
|
||||
[],
|
||||
function() {
|
||||
['underscore'],
|
||||
function(_) {
|
||||
var HTML5Video = {};
|
||||
|
||||
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() {
|
||||
if ($.isFunction(this.config.events.onStateChange)) {
|
||||
this.config.events.onStateChange({
|
||||
@@ -89,27 +173,29 @@ function() {
|
||||
return [0.75, 1.0, 1.25, 1.5];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
Player.prototype._getLogs = function() {
|
||||
return this.logs;
|
||||
};
|
||||
|
||||
Player.prototype.showErrorMessage = function() {
|
||||
Player.prototype.showErrorMessage = function(event, css) {
|
||||
var cssSelecter = css || '.video-player .video-error';
|
||||
this.el
|
||||
.find('.video-player div')
|
||||
.addClass('hidden')
|
||||
.end()
|
||||
.find('.video-player .video-error')
|
||||
.find(cssSelecter)
|
||||
.removeClass('is-hidden')
|
||||
.end()
|
||||
.addClass('is-initialized')
|
||||
.find('.spinner')
|
||||
.attr({
|
||||
'aria-hidden': 'true',
|
||||
'tabindex': -1
|
||||
tabindex: -1
|
||||
});
|
||||
};
|
||||
|
||||
Player.prototype.onError = function(event) {
|
||||
Player.prototype.onError = function() {
|
||||
if ($.isFunction(this.config.events.onError)) {
|
||||
this.config.events.onError();
|
||||
}
|
||||
@@ -122,11 +208,16 @@ function() {
|
||||
this.video.removeEventListener('pause', this.onPause, false);
|
||||
this.video.removeEventListener('ended', this.onEnded, false);
|
||||
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().removeClass('is-initialized')
|
||||
.find('.spinner').attr({'aria-hidden': 'false'});
|
||||
.find('.video-player .video-error')
|
||||
.addClass('is-hidden')
|
||||
.end()
|
||||
.removeClass('is-initialized')
|
||||
.find('.spinner')
|
||||
.attr({'aria-hidden': 'false'});
|
||||
this.videoEl.off('remove');
|
||||
this.videoEl.remove();
|
||||
};
|
||||
|
||||
@@ -157,57 +248,27 @@ function() {
|
||||
this.callStateChangeCallback();
|
||||
};
|
||||
|
||||
return Player;
|
||||
Player.prototype.init = function(el, config) {
|
||||
var isTouch = window.onTouchBasedDevice() || '',
|
||||
events = ['loadstart', 'progress', 'suspend', 'abort', 'error',
|
||||
'emptied', 'stalled', 'play', 'pause', 'loadedmetadata',
|
||||
'loadeddata', 'waiting', 'playing', 'canplay', 'canplaythrough',
|
||||
'seeking', 'seeked', 'timeupdate', 'ended', 'ratechange',
|
||||
'durationchange', 'volumechange'
|
||||
],
|
||||
self = this,
|
||||
errorMessage;
|
||||
|
||||
/*
|
||||
* 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 isTouch = onTouchBasedDevice() || '',
|
||||
sourceList, _this, errorMessage, lastSource;
|
||||
|
||||
_.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded');
|
||||
this.config = config;
|
||||
this.logs = [];
|
||||
|
||||
// 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
|
||||
// element. We try to select by ID. If jQuery fails this time, we
|
||||
// return. Nothing breaks because the player 'onReady' event will
|
||||
// never be fired.
|
||||
|
||||
this.el = $(el);
|
||||
|
||||
if (this.el.length === 0) {
|
||||
this.el = $('#' + el);
|
||||
|
||||
@@ -218,106 +279,51 @@ function() {
|
||||
} else {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A simple test to see that the 'config' is a normal object.
|
||||
if ($.isPlainObject(config)) {
|
||||
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.
|
||||
// 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');
|
||||
|
||||
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;
|
||||
|
||||
// Get the jQuery object, and set the player state to UNSTARTED.
|
||||
// The player state is used by other parts of the VideoPlayer to
|
||||
// determine what the video is currently doing.
|
||||
// Get the jQuery object and set error event handlers
|
||||
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);
|
||||
}
|
||||
|
||||
// The player state is used by other parts of the VideoPlayer to
|
||||
// determine what the video is currently doing.
|
||||
this.playerState = HTML5Video.PlayerState.UNSTARTED;
|
||||
|
||||
_.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded');
|
||||
|
||||
// Attach a 'click' event on the <video> element. It will cause the
|
||||
// video to pause/play.
|
||||
this.videoEl.on('click', function(event) {
|
||||
this.videoEl.on('click', function() {
|
||||
var PlayerState = HTML5Video.PlayerState;
|
||||
|
||||
if (_this.playerState === PlayerState.PLAYING) {
|
||||
_this.playerState = PlayerState.PAUSED;
|
||||
_this.pauseVideo();
|
||||
if (self.playerState === PlayerState.PLAYING) {
|
||||
self.playerState = PlayerState.PAUSED;
|
||||
self.pauseVideo();
|
||||
} else {
|
||||
_this.playerState = PlayerState.PLAYING;
|
||||
_this.playVideo();
|
||||
self.playerState = PlayerState.PLAYING;
|
||||
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;
|
||||
$.each(events, function(index, eventName) {
|
||||
_this.video.addEventListener(eventName, function() {
|
||||
_this.logs.push({
|
||||
self.video.addEventListener(eventName, function() {
|
||||
self.logs.push({
|
||||
'event name': eventName,
|
||||
'state': _this.playerState
|
||||
state: self.playerState
|
||||
});
|
||||
|
||||
if (_this.debug) {
|
||||
if (self.debug) {
|
||||
console.log(
|
||||
'event name:', eventName,
|
||||
'state:', _this.playerState,
|
||||
'readyState:', _this.video.readyState,
|
||||
'networkState:', _this.video.networkState
|
||||
'state:', self.playerState,
|
||||
'readyState:', self.video.readyState,
|
||||
'networkState:', self.video.networkState
|
||||
);
|
||||
}
|
||||
|
||||
@@ -334,9 +340,17 @@ function() {
|
||||
this.video.addEventListener('pause', this.onPause, 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.
|
||||
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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable no-console, no-param-reassign */
|
||||
(function(requirejs, require, define) {
|
||||
// VideoPlayer module.
|
||||
define(
|
||||
'video/03_video_player.js',
|
||||
['video/02_html5_video.js', 'video/00_resizer.js'],
|
||||
function(HTML5Video, Resizer) {
|
||||
['video/02_html5_video.js', 'video/02_html5_hls_video.js', 'video/00_resizer.js', 'hls', 'underscore'],
|
||||
function(HTML5Video, HTML5HLSVideo, Resizer, HLS, _) {
|
||||
var dfd = $.Deferred(),
|
||||
VideoPlayer = function(state) {
|
||||
state.videoPlayer = {};
|
||||
@@ -100,8 +101,13 @@ function(HTML5Video, Resizer) {
|
||||
// initial configuration. Also make the created DOM elements available
|
||||
// via the 'state' object. Much easier to work this way - you don't
|
||||
// have to do repeated jQuery element selects.
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
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
|
||||
// by student before video starts playing. Waits until the video's
|
||||
@@ -147,19 +153,42 @@ function(HTML5Video, Resizer) {
|
||||
state.browserIsSafari = (userAgent.indexOf('safari') > -1 &&
|
||||
!state.browserIsChrome);
|
||||
|
||||
if (state.videoType === 'html5') {
|
||||
state.videoPlayer.player = new HTML5Video.Player(state.el, {
|
||||
playerVars: state.videoPlayer.playerVars,
|
||||
videoSources: state.config.sources,
|
||||
events: {
|
||||
onReady: state.videoPlayer.onReady,
|
||||
onStateChange: state.videoPlayer.onStateChange,
|
||||
onError: state.videoPlayer.onError
|
||||
}
|
||||
});
|
||||
// Browser can play HLS videos if either `Media Source Extensions`
|
||||
// feature is supported or browser is safari (native HLS support)
|
||||
state.canPlayHLS = state.HLSVideoSources.length > 0 && (HLS.isSupported() || state.browserIsSafari);
|
||||
state.HLSOnlySources = state.config.sources.length > 0 &&
|
||||
state.config.sources.length === state.HLSVideoSources.length;
|
||||
|
||||
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[0].addEventListener('loadedmetadata', state.videoPlayer.onLoadMetadataHtml5, false);
|
||||
player[0].addEventListener(eventToBeTriggered, state.videoPlayer.onLoadMetadataHtml5, false);
|
||||
player.on('remove', state.videoPlayer.destroy);
|
||||
} else {
|
||||
youTubeId = state.youtubeId();
|
||||
|
||||
@@ -179,6 +208,8 @@ function(HTML5Video, Resizer) {
|
||||
videoWidth = player.attr('width') || player.width(),
|
||||
videoHeight = player.attr('height') || player.height();
|
||||
|
||||
player.on('remove', state.videoPlayer.destroy);
|
||||
|
||||
_resize(state, videoWidth, videoHeight);
|
||||
_updateVcrAndRegion(state, true);
|
||||
});
|
||||
@@ -323,6 +354,9 @@ function(HTML5Video, Resizer) {
|
||||
if (player && _.isFunction(player.destroy)) {
|
||||
player.destroy();
|
||||
}
|
||||
if (this.canPlayHLS && player.hls) {
|
||||
player.hls.destroy();
|
||||
}
|
||||
delete this.videoPlayer;
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,8 @@
|
||||
log: function(eventName, data) {
|
||||
var logInfo = _.extend({
|
||||
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);
|
||||
Logger.log(eventName, logInfo);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ from pkg_resources import resource_string
|
||||
from django.conf import settings
|
||||
|
||||
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.fields import ScopeIds
|
||||
from xblock.runtime import KvsFieldData
|
||||
@@ -124,6 +125,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
|
||||
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/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/035_video_accessible_menu.js'),
|
||||
resource_string(module, 'js/src/video/04_video_control.js'),
|
||||
@@ -216,7 +218,10 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
|
||||
# stream.
|
||||
if self.edx_video_id and edxval_api:
|
||||
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
|
||||
val_video_urls = edxval_api.get_urls_for_profiles(self.edx_video_id.strip(), val_profiles)
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
'backbone-super': 'js/vendor/backbone-super',
|
||||
'jasmine-imagediff': 'js/vendor/jasmine-imagediff',
|
||||
'URI': 'js/vendor/URI.min',
|
||||
'draggabilly': 'js/vendor/draggabilly'
|
||||
'draggabilly': 'js/vendor/draggabilly',
|
||||
'hls': 'common/js/vendor/hls'
|
||||
},
|
||||
shim: {
|
||||
'gettext': {
|
||||
|
||||
@@ -33,7 +33,7 @@ CSS_CLASS_NAMES = {
|
||||
'captions_rendered': '.video.is-captions-rendered',
|
||||
'captions': '.subtitles',
|
||||
'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',
|
||||
'error_message': '.video .video-player .video-error',
|
||||
'video_container': '.video',
|
||||
@@ -46,11 +46,13 @@ CSS_CLASS_NAMES = {
|
||||
'captions_lang_list': '.langs-list li',
|
||||
'video_speed': '.speeds .value',
|
||||
'poster': '.poster',
|
||||
'active_caption_text': '.subtitles-menu > li.current span',
|
||||
}
|
||||
|
||||
VIDEO_MODES = {
|
||||
'html5': '.video video',
|
||||
'youtube': '.video iframe'
|
||||
'youtube': '.video iframe',
|
||||
'hls': '.video video',
|
||||
}
|
||||
|
||||
VIDEO_MENUS = {
|
||||
@@ -204,7 +206,7 @@ class VideoPage(PageObject):
|
||||
Check that if video is rendered in `mode`.
|
||||
|
||||
Arguments:
|
||||
mode (str): Video mode, `html5` or `youtube`.
|
||||
mode (str): Video mode, one of `html5`, `youtube`, `hls`.
|
||||
|
||||
Returns:
|
||||
bool: Tells if video is rendered in `mode`.
|
||||
@@ -222,10 +224,25 @@ class VideoPage(PageObject):
|
||||
|
||||
"""
|
||||
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 Promise(_is_element_present, 'Video Rendering Failed in {0} mode.'.format(mode)).fulfill()
|
||||
|
||||
@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):
|
||||
"""
|
||||
@@ -409,15 +426,25 @@ class VideoPage(PageObject):
|
||||
|
||||
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.
|
||||
|
||||
Arguments:
|
||||
line_no (int): line number to be clicked
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
@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):
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from mock import patch
|
||||
from nose.plugins.attrib import attr
|
||||
import os
|
||||
import ddt
|
||||
|
||||
from common.test.acceptance.tests.helpers import EventsTestMixin
|
||||
@@ -149,6 +151,45 @@ class VideoEventsTest(VideoEventsTestMixin):
|
||||
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)
|
||||
@ddt.ddt
|
||||
class VideoBumperEventsTest(VideoEventsTestMixin):
|
||||
|
||||
@@ -35,6 +35,10 @@ HTML5_SOURCES_INCORRECT = [
|
||||
'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!')
|
||||
class VideoBaseTest(UniqueCourseTest):
|
||||
@@ -155,13 +159,16 @@ class VideoBaseTest(UniqueCourseTest):
|
||||
:return: dict
|
||||
"""
|
||||
metadata = {}
|
||||
youtube_ids = {
|
||||
'youtube_id_1_0': '',
|
||||
'youtube_id_0_75': '',
|
||||
'youtube_id_1_25': '',
|
||||
'youtube_id_1_5': '',
|
||||
}
|
||||
|
||||
if player_mode == 'html5':
|
||||
metadata.update(youtube_ids)
|
||||
metadata.update({
|
||||
'youtube_id_1_0': '',
|
||||
'youtube_id_0_75': '',
|
||||
'youtube_id_1_25': '',
|
||||
'youtube_id_1_5': '',
|
||||
'html5_sources': HTML5_SOURCES
|
||||
})
|
||||
|
||||
@@ -176,14 +183,23 @@ class VideoBaseTest(UniqueCourseTest):
|
||||
})
|
||||
|
||||
if player_mode == 'html5_unsupported_video':
|
||||
metadata.update(youtube_ids)
|
||||
metadata.update({
|
||||
'youtube_id_1_0': '',
|
||||
'youtube_id_0_75': '',
|
||||
'youtube_id_1_25': '',
|
||||
'youtube_id_1_5': '',
|
||||
'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:
|
||||
metadata.update(additional_data)
|
||||
|
||||
@@ -611,12 +627,12 @@ class YouTubeVideoTest(VideoBaseTest):
|
||||
self.video.click_player_button('pause')
|
||||
|
||||
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.video.select_language('zh')
|
||||
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)
|
||||
|
||||
def test_multiple_videos_in_sequentials_load_and_work(self):
|
||||
@@ -1264,3 +1280,196 @@ class LMSVideoModuleA11yTest(VideoBaseTest):
|
||||
include=["div.video"]
|
||||
)
|
||||
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))
|
||||
|
||||
39
common/test/data/uploads/transcript.srt
Normal file
39
common/test/data/uploads/transcript.srt
Normal file
@@ -0,0 +1,39 @@
|
||||
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):
|
||||
|
||||
|
||||
@attr(shard=1)
|
||||
@ddt.ddt
|
||||
class TestGetHtmlMethod(BaseTestXmodule):
|
||||
'''
|
||||
Make sure that `get_html` works correctly.
|
||||
@@ -855,6 +856,33 @@ class TestGetHtmlMethod(BaseTestXmodule):
|
||||
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')
|
||||
def test_get_html_hls(self, get_urls_for_profiles):
|
||||
"""
|
||||
|
||||
@@ -1743,7 +1743,8 @@ REQUIRE_JS_PATH_OVERRIDES = {
|
||||
'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/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 ###############################
|
||||
@@ -2155,6 +2156,9 @@ INSTALLED_APPS = (
|
||||
# Verified Track Content Cohorting (Beta feature that will hopefully be removed)
|
||||
'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_dashboard',
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ var options = {
|
||||
{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/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/video/*.js', included: true},
|
||||
|
||||
@@ -108,7 +108,8 @@
|
||||
'handlebars': 'js/vendor/ova/catch/js/handlebars-1.1.2',
|
||||
'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.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
|
||||
},
|
||||
shim: {
|
||||
@@ -224,6 +225,9 @@
|
||||
// global namespace instead of being registered in require.
|
||||
'draggabilly': {
|
||||
exports: 'Draggabilly'
|
||||
},
|
||||
'hls': {
|
||||
exports: 'Hls'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,11 +35,14 @@
|
||||
baseUrl: '/base/',
|
||||
paths: {
|
||||
moment: 'xmodule_js/common_static/common/js/vendor/moment-with-locales',
|
||||
'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly',
|
||||
'edx-ui-toolkit': 'edx-ui-toolkit'
|
||||
draggabilly: 'xmodule_js/common_static/js/vendor/draggabilly',
|
||||
'edx-ui-toolkit': 'edx-ui-toolkit',
|
||||
hls: 'common/js/vendor/hls'
|
||||
},
|
||||
'moment': {
|
||||
exports: 'moment'
|
||||
shim: {
|
||||
moment: {
|
||||
exports: 'moment'
|
||||
}
|
||||
}
|
||||
});
|
||||
}).call(this, RequireJS.requirejs, RequireJS.define);
|
||||
|
||||
@@ -26,6 +26,9 @@ from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
<div class="video-player">
|
||||
<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-hls-error is-hidden">
|
||||
${_('Your browser does not support this video format. Try using a different browser.')}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="video-player-post"></div>
|
||||
<div class="closed-captions"></div>
|
||||
@@ -39,7 +42,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
</div>
|
||||
|
||||
<div class="focus_grabber last"></div>
|
||||
|
||||
|
||||
% 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>
|
||||
<div class="wrapper-downloads" role="region" aria-labelledby="video-download-transcripts_${id}">
|
||||
|
||||
1
openedx/core/djangoapps/video_config/__init__.py
Normal file
1
openedx/core/djangoapps/video_config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# TODO Move this Application to video codebase when Video XModule becomes an XBlock. Reference: TNL-6867.
|
||||
27
openedx/core/djangoapps/video_config/admin.py
Normal file
27
openedx/core/djangoapps/video_config/admin.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
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)
|
||||
45
openedx/core/djangoapps/video_config/forms.py
Normal file
45
openedx/core/djangoapps/video_config/forms.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
68
openedx/core/djangoapps/video_config/models.py
Normal file
68
openedx/core/djangoapps/video_config/models.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
112
openedx/core/djangoapps/video_config/tests/test_models.py
Normal file
112
openedx/core/djangoapps/video_config/tests/test_models.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
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 @@
|
||||
"coffee-script": "1.6.1",
|
||||
"edx-pattern-library": "0.18.1",
|
||||
"edx-ui-toolkit": "1.5.1",
|
||||
"hls.js": "0.7.2",
|
||||
"jquery": "~2.2.0",
|
||||
"jquery-migrate": "^1.4.1",
|
||||
"jquery.scrollto": "~2.1.2",
|
||||
|
||||
@@ -58,6 +58,7 @@ NPM_INSTALLED_LIBRARIES = [
|
||||
'requirejs/require.js',
|
||||
'underscore.string/dist/underscore.string.js',
|
||||
'underscore/underscore.js',
|
||||
'hls.js/dist/hls.js',
|
||||
]
|
||||
|
||||
# A list of NPM installed developer libraries that should be copied into the common
|
||||
|
||||
Reference in New Issue
Block a user