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 = (
# 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
......
#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 = {
{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);
(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']);
......
(function(undefined) {
'use strict';
describe('VideoPlayer Events plugin', function() {
var state, oldOTBD, Logger = window.Logger;
var describeInfo, state, oldOTBD;
beforeEach(function() {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice')
.and.returnValue(null);
describeInfo = new jasmine.DescribeInfo('', function() {
var Logger = window.Logger;
state = jasmine.initializePlayer();
beforeEach(function() {
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));
/* 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) {
......
/* eslint-disable no-console, no-param-reassign */
/**
* HTML5 video player module to support HLS video playback.
*
*/
(function(requirejs, require, define) {
'use strict';
define('video/02_html5_hls_video.js', ['video/02_html5_video.js', 'hls'],
function(HTML5Video, HLS) {
var HLSVideo = {};
HLSVideo.Player = (function() {
/**
* Initialize HLS video player.
*
* @param {jQuery} el Reference to video player container element
* @param {Object} config Contains common config for video player
*/
function Player(el, config) {
var self = this;
// do common initialization independent of player type
this.init(el, config);
// If we have only HLS sources and browser doesn't support HLS then show error message.
if (config.HLSOnlySources && !config.canPlayHLS) {
this.showErrorMessage(null, '.video-hls-error');
return;
}
// Safari has native support to play HLS videos
if (config.browserIsSafari) {
this.videoEl.attr('src', config.videoSources[0]);
} else {
this.hls = new HLS();
this.hls.loadSource(config.videoSources[0]);
this.hls.attachMedia(this.video);
this.hls.on(HLS.Events.ERROR, this.onError.bind(this));
this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) {
console.log(
'[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ',
data.levels.map(function(level) {
return {
bitrate: level.bitrate,
resolution: level.width + 'x' + level.height
};
})
);
});
this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) {
var level = self.hls.levels[data.level];
console.log(
'[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ',
{
bitrate: level.bitrate,
resolution: level.width + 'x' + level.height
}
);
});
}
}
Player.prototype = Object.create(HTML5Video.Player.prototype);
Player.prototype.constructor = Player;
/**
* Handler for HLS video errors. This only takes care of fatal erros, non-fatal errors
* are automatically handled by hls.js
*
* @param {String} event `hlsError`
* @param {Object} data Contains the information regarding error occurred.
*/
Player.prototype.onError = function(event, data) {
if (data.fatal) {
switch (data.type) {
case HLS.ErrorTypes.NETWORK_ERROR:
console.error(
'[HLS Video]: Fatal network error encountered, try to recover. Details: %s',
data.details
);
this.hls.startLoad();
break;
case HLS.ErrorTypes.MEDIA_ERROR:
console.error(
'[HLS Video]: Fatal media error encountered, try to recover. Details: %s',
data.details
);
this.hls.recoverMediaError();
break;
default:
console.error(
'[HLS Video]: Unrecoverable error encountered. Details: %s',
data.details
);
break;
}
}
};
return Player;
}());
return HLSVideo;
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
/* eslint-disable no-console, no-param-reassign */
(function(requirejs, require, define) {
// 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,11 +224,26 @@ 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):
"""
Extract autoplay value of `data-metadata` attribute to check video autoplay is enabled or disabled.
......@@ -409,16 +426,26 @@ 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):
"""
Get current video speed value.
......
......@@ -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):
......
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}">
......
# 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 @@
"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
......
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