diff --git a/package.json b/package.json
index a8b762450e44..d3e14e536056 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"watch-sass": "scripts/watch_sass.sh",
"test": "npm run test-jest && npm run test-karma",
"test-jest": "jest",
- "test-karma": "npm run test-karma-vanilla && npm run test-karma-require && echo 'WARNING: Skipped broken webpack tests. For details, see: https://github.com/openedx/edx-platform/issues/35956'",
+ "test-karma": "npm run test-karma-vanilla && npm run test-karma-require && npm run test-xmodule-webpack && echo 'WARNING: Skipped broken lms-webpack and cms-webpack tests. For details, see: https://github.com/openedx/edx-platform/issues/35956'",
"test-karma-vanilla": "npm run test-cms-vanilla && npm run test-xmodule-vanilla && npm run test-common-vanilla",
"test-karma-require": "npm run test-cms-require && npm run test-common-require",
"test-karma-webpack": "npm run test-cms-webpack && npm run test-lms-webpack && npm run test-xmodule-webpack",
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index f78de7473150..8ae221c9dadd 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -13,16 +13,16 @@
# This file contains all common constraints for edx-repos
-c common_constraints.txt
+# Date: 2025-10-07
+# Stay on LTS version, remove once this is added to common constraint
+Django<6.0
+
# Date: 2020-02-26
# As it is not clarified what exact breaking changes will be introduced as per
# the next major release, ensure the installed version is within boundaries.
# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35280
celery>=5.2.2,<6.0.0
-# Date: 2024-02-02
-# Stay on LTS version, remove once this is added to common constraint
-Django<5.0
-
# Date: 2020-02-10
# django-oauth-toolkit version >=2.0.0 has breaking changes. More details
# mentioned on this issue https://github.com/openedx/edx-platform/issues/32884
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 730460139839..be367e3d8972 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -167,7 +167,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
@@ -277,7 +277,7 @@ django-fernet-fields-v2==0.9
# via
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/kernel.in
# edx-enterprise
@@ -781,7 +781,7 @@ mysqlclient==2.2.7
# via
# -r requirements/edx/kernel.in
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# via
# -r requirements/edx/kernel.in
# xblocks-contrib
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 9df3845c2e62..136ae43a6f6c 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -331,7 +331,7 @@ distlib==0.4.0
# via
# -r requirements/edx/testing.txt
# virtualenv
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/doc.txt
@@ -469,7 +469,7 @@ django-fernet-fields-v2==0.9
# -r requirements/edx/testing.txt
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1307,7 +1307,7 @@ mysqlclient==2.2.7
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index faba8969f10b..6310ee6cece0 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -225,7 +225,7 @@ defusedxml==0.7.1
# ora2
# python3-openid
# social-auth-core
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
@@ -342,7 +342,7 @@ django-fernet-fields-v2==0.9
# -r requirements/edx/base.txt
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -950,7 +950,7 @@ mysqlclient==2.2.7
# via
# -r requirements/edx/base.txt
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# via
# -r requirements/edx/base.txt
# xblocks-contrib
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 7de977c71684..895c92da1c9f 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -251,7 +251,7 @@ dill==0.4.0
# via pylint
distlib==0.4.0
# via virtualenv
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
@@ -368,7 +368,7 @@ django-fernet-fields-v2==0.9
# -r requirements/edx/base.txt
# edx-enterprise
# enterprise-integrated-channels
-django-filter==25.1
+django-filter==25.2
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -995,7 +995,7 @@ mysqlclient==2.2.7
# via
# -r requirements/edx/base.txt
# openedx-forum
-nh3==0.3.0
+nh3==0.3.1
# via
# -r requirements/edx/base.txt
# xblocks-contrib
diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt
index fd67805f02c0..0507348b225e 100644
--- a/scripts/user_retirement/requirements/base.txt
+++ b/scripts/user_retirement/requirements/base.txt
@@ -34,7 +34,7 @@ cryptography==45.0.7
# via
# -c requirements/constraints.txt
# pyjwt
-django==4.2.25
+django==5.2.7
# via
# -c requirements/constraints.txt
# django-crum
diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt
index 31b20fc6d8fd..01ba9d65a406 100644
--- a/scripts/user_retirement/requirements/testing.txt
+++ b/scripts/user_retirement/requirements/testing.txt
@@ -52,7 +52,7 @@ cryptography==45.0.7
# pyjwt
ddt==1.7.2
# via -r scripts/user_retirement/requirements/testing.in
-django==4.2.25
+django==5.2.7
# via
# -r scripts/user_retirement/requirements/base.txt
# django-crum
diff --git a/webpack.builtinblocks.config.js b/webpack.builtinblocks.config.js
index 1c5a9b1e0e9d..c0f2fdaeb4fa 100644
--- a/webpack.builtinblocks.config.js
+++ b/webpack.builtinblocks.config.js
@@ -79,14 +79,13 @@ module.exports = {
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/sequence/edit.js'
],
- VideoBlockDisplay: [
- './xmodule/js/src/xmodule.js',
- './xmodule/js/src/video/10_main.js'
- ],
VideoBlockEditor: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/tabs/tabs-aggregator.js'
],
+ VideoBlockDisplay: [
+ './xmodule/assets/video/public/js/10_main.js'
+ ],
WordCloudBlockDisplay: [
'./xmodule/js/src/xmodule.js',
'./xmodule/assets/word_cloud/src/js/word_cloud.js'
diff --git a/webpack.common.config.js b/webpack.common.config.js
index 36ac75708c23..f6fd4fa357f2 100644
--- a/webpack.common.config.js
+++ b/webpack.common.config.js
@@ -505,15 +505,6 @@ module.exports = Merge.merge({
}
]
},
- {
- test: /xmodule\/js\/src\/video\/10_main.js/,
- use: [
- {
- loader: 'imports-loader',
- options: 'this=>window'
- }
- ]
- },
/*
* END BUILT-IN XBLOCK ASSETS WITH GLOBAL DEFINITIONS
***************************************************************************************************** */
@@ -680,9 +671,11 @@ module.exports = Merge.merge({
$: 'jQuery',
backbone: 'Backbone',
canvas: 'canvas',
+ fs: 'fs',
gettext: 'gettext',
jquery: 'jQuery',
logger: 'Logger',
+ path: 'path',
underscore: '_',
URI: 'URI',
XBlockToXModuleShim: 'XBlockToXModuleShim',
diff --git a/xmodule/assets/video/public/js/00_async_process.js b/xmodule/assets/video/public/js/00_async_process.js
new file mode 100644
index 000000000000..a909e8225a2f
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_async_process.js
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * Provides convenient way to process big amount of data without UI blocking.
+ *
+ * @param {array} list Array to process.
+ * @param {function} process Calls this function on each item in the list.
+ * @return {array} Returns a Promise object to observe when all actions of a
+ * certain type bound to the collection, queued or not, have finished.
+ */
+let AsyncProcess = {
+ array: function(list, process) {
+ if (!_.isArray(list)) {
+ return $.Deferred().reject().promise();
+ }
+
+ if (!_.isFunction(process) || !list.length) {
+ return $.Deferred().resolve(list).promise();
+ }
+
+ let MAX_DELAY = 50, // maximum amount of time that js code should be allowed to run continuously
+ dfd = $.Deferred();
+ let result = [];
+ let index = 0;
+ let len = list.length;
+
+ let getCurrentTime = function() {
+ return (new Date()).getTime();
+ };
+
+ let handler = function() {
+ let start = getCurrentTime();
+
+ do {
+ result[index] = process(list[index], index);
+ index++;
+ } while (index < len && getCurrentTime() - start < MAX_DELAY);
+
+ if (index < len) {
+ setTimeout(handler, 25);
+ } else {
+ dfd.resolve(result);
+ }
+ };
+
+ setTimeout(handler, 25);
+
+ return dfd.promise();
+ }
+};
+
+export default AsyncProcess;
diff --git a/xmodule/assets/video/public/js/00_component.js b/xmodule/assets/video/public/js/00_component.js
new file mode 100644
index 000000000000..2ac183b1982f
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_component.js
@@ -0,0 +1,81 @@
+'use strict';
+
+import _ from 'underscore';
+
+
+/**
+ * Creates a new object with the specified prototype object and properties.
+ * @param {Object} o The object which should be the prototype of the
+ * newly-created object.
+ * @private
+ * @throws {TypeError, Error}
+ * @return {Object}
+ */
+let inherit = Object.create || (function() {
+ let F = function() {};
+
+ return function(o) {
+ if (arguments.length > 1) {
+ throw Error('Second argument not supported');
+ }
+ if (_.isNull(o) || _.isUndefined(o)) {
+ throw Error('Cannot set a null [[Prototype]]');
+ }
+ if (!_.isObject(o)) {
+ throw TypeError('Argument must be an object');
+ }
+
+ F.prototype = o;
+
+ return new F();
+ };
+}());
+
+/**
+ * Component module.
+ * @exports video/00_component.js
+ * @constructor
+ * @return {jquery Promise}
+ */
+let Component = function() {
+ if ($.isFunction(this.initialize)) {
+ // eslint-disable-next-line prefer-spread
+ return this.initialize.apply(this, arguments);
+ }
+};
+
+/**
+ * Returns new constructor that inherits form the current constructor.
+ * @static
+ * @param {Object} protoProps The object containing which will be added to
+ * the prototype.
+ * @return {Object}
+ */
+Component.extend = function(protoProps, staticProps) {
+ let Parent = this;
+ let Child = function() {
+ if ($.isFunction(this.initialize)) {
+ // eslint-disable-next-line prefer-spread
+ return this.initialize.apply(this, arguments);
+ }
+ };
+
+ // Inherit methods and properties from the Parent prototype.
+ Child.prototype = inherit(Parent.prototype);
+ Child.constructor = Parent;
+ // Provide access to parent's methods and properties
+ Child.__super__ = Parent.prototype;
+
+ // Extends inherited methods and properties by methods/properties
+ // passed as argument.
+ if (protoProps) {
+ $.extend(Child.prototype, protoProps);
+ }
+
+ // Inherit static methods and properties
+ $.extend(Child, Parent, staticProps);
+
+ return Child;
+};
+
+export default Component;
diff --git a/xmodule/assets/video/public/js/00_i18n.js b/xmodule/assets/video/public/js/00_i18n.js
new file mode 100644
index 000000000000..1962ed4ee8c4
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_i18n.js
@@ -0,0 +1,35 @@
+'use strict';
+
+/**
+ * i18n module.
+ * @exports video/00_i18n.js
+ * @return {object}
+ */
+
+let i18n = {
+ Play: gettext('Play'),
+ Pause: gettext('Pause'),
+ Mute: gettext('Mute'),
+ Unmute: gettext('Unmute'),
+ 'Exit full browser': gettext('Exit full browser'),
+ 'Fill browser': gettext('Fill browser'),
+ Speed: gettext('Speed'),
+ 'Auto-advance': gettext('Auto-advance'),
+ Volume: gettext('Volume'),
+ // Translators: Volume level equals 0%.
+ Muted: gettext('Muted'),
+ // Translators: Volume level in range ]0,20]%
+ 'Very low': gettext('Very low'),
+ // Translators: Volume level in range ]20,40]%
+ Low: gettext('Low'),
+ // Translators: Volume level in range ]40,60]%
+ Average: gettext('Average'),
+ // Translators: Volume level in range ]60,80]%
+ Loud: gettext('Loud'),
+ // Translators: Volume level in range ]80,99]%
+ 'Very loud': gettext('Very loud'),
+ // Translators: Volume level equals 100%.
+ Maximum: gettext('Maximum')
+};
+
+export default i18n;
diff --git a/xmodule/assets/video/public/js/00_iterator.js b/xmodule/assets/video/public/js/00_iterator.js
new file mode 100644
index 000000000000..5b597f200ec5
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_iterator.js
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Provides convenient way to work with iterable data.
+ * @exports video/00_iterator.js
+ * @constructor
+ * @param {array} list Array to be iterated.
+ */
+let Iterator = function(list) {
+ this.list = list;
+ this.index = 0;
+ this.size = this.list.length;
+ this.lastIndex = this.list.length - 1;
+};
+
+Iterator.prototype = {
+
+ /**
+ * Checks validity of provided index for the iterator.
+ * @access protected
+ * @param {numebr} index
+ * @return {boolean}
+ */
+ _isValid: function(index) {
+ return _.isNumber(index) && index < this.size && index >= 0;
+ },
+
+ /**
+ * Returns next element.
+ * @param {number} [index] Updates current position.
+ * @return {any}
+ */
+ next: function(index) {
+ if (!(this._isValid(index))) {
+ index = this.index;
+ }
+
+ this.index = (index >= this.lastIndex) ? 0 : index + 1;
+
+ return this.list[this.index];
+ },
+
+ /**
+ * Returns previous element.
+ * @param {number} [index] Updates current position.
+ * @return {any}
+ */
+ prev: function(index) {
+ if (!(this._isValid(index))) {
+ index = this.index;
+ }
+
+ this.index = (index < 1) ? this.lastIndex : index - 1;
+
+ return this.list[this.index];
+ },
+
+ /**
+ * Returns last element in the list.
+ * @return {any}
+ */
+ last: function() {
+ return this.list[this.lastIndex];
+ },
+
+ /**
+ * Returns first element in the list.
+ * @return {any}
+ */
+ first: function() {
+ return this.list[0];
+ },
+
+ /**
+ * Returns `true` if current position is last for the iterator.
+ * @return {boolean}
+ */
+ isEnd: function() {
+ return this.index === this.lastIndex;
+ }
+};
+
+export default Iterator;
diff --git a/xmodule/assets/video/public/js/00_resizer.js b/xmodule/assets/video/public/js/00_resizer.js
new file mode 100644
index 000000000000..d892ec4d1873
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_resizer.js
@@ -0,0 +1,236 @@
+'use strict';
+
+import _ from 'underscore';
+
+
+let Resizer = function(params) {
+ let defaults = {
+ container: window,
+ element: null,
+ containerRatio: null,
+ elementRatio: null
+ },
+ callbacksList = [],
+ delta = {
+ height: 0,
+ width: 0
+ },
+ module = {};
+ let mode = null,
+ config;
+
+ // eslint-disable-next-line no-shadow
+ let initialize = function(params) {
+ if (!config) {
+ config = defaults;
+ }
+
+ config = $.extend(true, {}, config, params);
+
+ if (!config.element) {
+ console.log(
+ 'Required parameter `element` is not passed.'
+ );
+ }
+
+ return module;
+ };
+
+ let getData = function() {
+ let $container = $(config.container),
+ containerWidth = $container.width() + delta.width,
+ containerHeight = $container.height() + delta.height;
+ let containerRatio = config.containerRatio;
+
+ let $element = $(config.element);
+ let elementRatio = config.elementRatio;
+
+ if (!containerRatio) {
+ containerRatio = containerWidth / containerHeight;
+ }
+
+ if (!elementRatio) {
+ elementRatio = $element.width() / $element.height();
+ }
+
+ return {
+ containerWidth: containerWidth,
+ containerHeight: containerHeight,
+ containerRatio: containerRatio,
+ element: $element,
+ elementRatio: elementRatio
+ };
+ };
+
+ let align = function() {
+ let data = getData();
+
+ switch (mode) {
+ case 'height':
+ alignByHeightOnly();
+ break;
+
+ case 'width':
+ alignByWidthOnly();
+ break;
+
+ default:
+ if (data.containerRatio >= data.elementRatio) {
+ alignByHeightOnly();
+ } else {
+ alignByWidthOnly();
+ }
+ break;
+ }
+
+ fireCallbacks();
+
+ return module;
+ };
+
+ let alignByWidthOnly = function() {
+ let data = getData(),
+ height = data.containerWidth / data.elementRatio;
+
+ data.element.css({
+ height: height,
+ width: data.containerWidth,
+ top: 0.5 * (data.containerHeight - height),
+ left: 0
+ });
+
+ return module;
+ };
+
+ let alignByHeightOnly = function() {
+ let data = getData(),
+ width = data.containerHeight * data.elementRatio;
+
+ data.element.css({
+ height: data.containerHeight,
+ width: data.containerHeight * data.elementRatio,
+ top: 0,
+ left: 0.5 * (data.containerWidth - width)
+ });
+
+ return module;
+ };
+
+ let setMode = function(param) {
+ if (_.isString(param)) {
+ mode = param;
+ align();
+ }
+
+ return module;
+ };
+
+ let setElement = function(element) {
+ config.element = element;
+
+ return module;
+ };
+
+ let addCallback = function(func) {
+ if ($.isFunction(func)) {
+ callbacksList.push(func);
+ } else {
+ console.error('[Video info]: TypeError: Argument is not a function.');
+ }
+
+ return module;
+ };
+
+ let addOnceCallback = function(func) {
+ if ($.isFunction(func)) {
+ let decorator = function() {
+ func();
+ removeCallback(func);
+ };
+
+ addCallback(decorator);
+ } else {
+ console.error('TypeError: Argument is not a function.');
+ }
+
+ return module;
+ };
+
+ let fireCallbacks = function() {
+ $.each(callbacksList, function(index, callback) {
+ callback();
+ });
+ };
+
+ let removeCallbacks = function() {
+ callbacksList.length = 0;
+
+ return module;
+ };
+
+ let removeCallback = function(func) {
+ let index = $.inArray(func, callbacksList);
+
+ if (index !== -1) {
+ return callbacksList.splice(index, 1);
+ }
+ };
+
+ let resetDelta = function() {
+ // eslint-disable-next-line no-multi-assign
+ delta.height = delta.width = 0;
+
+ return module;
+ };
+
+ let addDelta = function(value, side) {
+ if (_.isNumber(value) && _.isNumber(delta[side])) {
+ delta[side] += value;
+ }
+
+ return module;
+ };
+
+ let substractDelta = function(value, side) {
+ if (_.isNumber(value) && _.isNumber(delta[side])) {
+ delta[side] -= value;
+ }
+
+ return module;
+ };
+
+ let destroy = function() {
+ let data = getData();
+ data.element.css({
+ height: '', width: '', top: '', left: ''
+ });
+ removeCallbacks();
+ resetDelta();
+ mode = null;
+ };
+
+ initialize.apply(module, arguments);
+
+ return $.extend(true, module, {
+ align: align,
+ alignByWidthOnly: alignByWidthOnly,
+ alignByHeightOnly: alignByHeightOnly,
+ destroy: destroy,
+ setParams: initialize,
+ setMode: setMode,
+ setElement: setElement,
+ callbacks: {
+ add: addCallback,
+ once: addOnceCallback,
+ remove: removeCallback,
+ removeAll: removeCallbacks
+ },
+ delta: {
+ add: addDelta,
+ substract: substractDelta,
+ reset: resetDelta
+ }
+ });
+};
+
+export default Resizer;
diff --git a/xmodule/assets/video/public/js/00_sjson.js b/xmodule/assets/video/public/js/00_sjson.js
new file mode 100644
index 000000000000..99d870ff84a7
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_sjson.js
@@ -0,0 +1,108 @@
+'use strict';
+
+let Sjson = function(data) {
+ let sjson = {
+ start: data.start.concat(),
+ text: data.text.concat()
+ },
+ module = {};
+
+ let getter = function(propertyName) {
+ return function() {
+ return sjson[propertyName];
+ };
+ };
+
+ let getStartTimes = getter('start');
+
+ let getCaptions = getter('text');
+
+ let size = function() {
+ return sjson.text.length;
+ };
+
+ function search(time, startTime, endTime) {
+ let start = getStartTimes(),
+ max = size() - 1,
+ min = 0,
+ results,
+ index;
+
+ // if we specify a start and end time to search,
+ // search the filtered list of captions in between
+ // the start / end times.
+ // Else, search the unfiltered list.
+ if (typeof startTime !== 'undefined'
+ && typeof endTime !== 'undefined') {
+ results = filter(startTime, endTime);
+ start = results.start;
+ max = results.captions.length - 1;
+ } else {
+ start = getStartTimes();
+ }
+ while (min < max) {
+ index = Math.ceil((max + min) / 2);
+
+ if (time < start[index]) {
+ max = index - 1;
+ }
+
+ if (time >= start[index]) {
+ min = index;
+ }
+ }
+
+ return min;
+ }
+
+ function filter(start, end) {
+ /* filters captions that occur between inputs
+ * `start` and `end`. Start and end should
+ * be Numbers (doubles) corresponding to the
+ * number of seconds elapsed since the beginning
+ * of the video.
+ *
+ * Returns an object with properties
+ * "start" and "captions" representing
+ * parallel arrays of start times and
+ * their corresponding captions.
+ */
+ let filteredTimes = [];
+ let filteredCaptions = [];
+ let startTimes = getStartTimes();
+ let captions = getCaptions();
+
+ if (startTimes.length !== captions.length) {
+ console.warn('video caption and start time arrays do not match in length');
+ }
+
+ // if end is null, then it's been set to
+ // some erroneous value, so filter using the
+ // entire array as long as it's not empty
+ if (end === null && startTimes.length) {
+ end = startTimes[startTimes.length - 1];
+ }
+
+ _.filter(startTimes, function(currentStartTime, i) {
+ if (currentStartTime >= start && currentStartTime <= end) {
+ filteredTimes.push(currentStartTime);
+ filteredCaptions.push(captions[i]);
+ }
+ });
+
+ return {
+ start: filteredTimes,
+ captions: filteredCaptions
+ };
+ }
+
+ return {
+ getCaptions: getCaptions,
+ getStartTimes: getStartTimes,
+ getSize: size,
+ filter: filter,
+ search: search
+ };
+};
+
+export default Sjson;
diff --git a/xmodule/assets/video/public/js/00_video_storage.js b/xmodule/assets/video/public/js/00_video_storage.js
new file mode 100644
index 000000000000..f2293336fe01
--- /dev/null
+++ b/xmodule/assets/video/public/js/00_video_storage.js
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * Provides convenient way to store key value pairs.
+ *
+ * @param {string} namespace Namespace that is used to store data.
+ * @return {object} VideoStorage API.
+ */
+let VideoStorage = function(namespace, id) {
+ /**
+ * Adds new value to the storage or rewrites existent.
+ *
+ * @param {string} name Identifier of the data.
+ * @param {any} value Data to store.
+ * @param {boolean} instanceSpecific Data with this flag will be added
+ * to instance specific storage.
+ */
+ let setItem = function(name, value, instanceSpecific) {
+ if (name) {
+ if (instanceSpecific) {
+ window[namespace][id][name] = value;
+ } else {
+ window[namespace][name] = value;
+ }
+ }
+ };
+
+ /**
+ * Returns the current value associated with the given name.
+ *
+ * @param {string} name Identifier of the data.
+ * @param {boolean} instanceSpecific Data with this flag will be added
+ * to instance specific storage.
+ * @return {any} The current value associated with the given name.
+ * If the given key does not exist in the list
+ * associated with the object then this method must return null.
+ */
+ let getItem = function(name, instanceSpecific) {
+ if (instanceSpecific) {
+ return window[namespace][id][name];
+ } else {
+ return window[namespace][name];
+ }
+ };
+
+ /**
+ * Removes the current value associated with the given name.
+ *
+ * @param {string} name Identifier of the data.
+ * @param {boolean} instanceSpecific Data with this flag will be added
+ * to instance specific storage.
+ */
+ let removeItem = function(name, instanceSpecific) {
+ if (instanceSpecific) {
+ delete window[namespace][id][name];
+ } else {
+ delete window[namespace][name];
+ }
+ };
+
+ /**
+ * Empties the storage.
+ *
+ */
+ let clear = function() {
+ window[namespace] = {};
+ window[namespace][id] = {};
+ };
+
+ /**
+ * Initializes the module: creates a storage with proper namespace.
+ *
+ * @private
+ */
+ (function initialize() {
+ if (!namespace) {
+ namespace = 'VideoStorage';
+ }
+ if (!id) {
+ // Generate random alpha-numeric string.
+ id = Math.random().toString(36).slice(2);
+ }
+
+ window[namespace] = window[namespace] || {};
+ window[namespace][id] = window[namespace][id] || {};
+ }());
+
+ return {
+ clear: clear,
+ getItem: getItem,
+ removeItem: removeItem,
+ setItem: setItem
+ };
+};
+
+export default VideoStorage;
diff --git a/xmodule/assets/video/public/js/01_initialize.js b/xmodule/assets/video/public/js/01_initialize.js
new file mode 100644
index 000000000000..85248b3f0266
--- /dev/null
+++ b/xmodule/assets/video/public/js/01_initialize.js
@@ -0,0 +1,845 @@
+/* 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
+ * invokes the video player to play the specified video. This module must be
+ * invoked first. It provides several functions which do not fit in with other
+ * modules.
+ *
+ * @external VideoPlayer
+ *
+ * @module Initialize
+ */
+
+import VideoPlayer from './03_video_player.js';
+import i18n from './00_i18n.js';
+import _ from 'underscore';
+import moment from 'moment';
+
+/**
+ * @function
+ *
+ * Initialize module exports this function.
+ *
+ * @param {object} state The object containg the state of the video player.
+ * All other modules, their parameters, public variables, etc. are
+ * available via this object.
+ * @param {DOM element} element Container of the entire Video DOM element.
+ */
+let Initialize = function(state, element) {
+ _makeFunctionsPublic(state);
+
+ state.initialize(element)
+ .done(function() {
+ if (state.isYoutubeType()) {
+ state.parseSpeed();
+ }
+ // On iPhones and iPods native controls are used.
+ if (/iP(hone|od)/i.test(state.isTouch[0])) {
+ _hideWaitPlaceholder(state);
+ state.el.trigger('initialize', arguments);
+
+ return false;
+ }
+
+ _initializeModules(state, i18n)
+ .done(function() {
+ // On iPad ready state occurs just after start playing.
+ // We hide controls before video starts playing.
+ if (/iPad|Android/i.test(state.isTouch[0])) {
+ state.el.on('play', _.once(function() {
+ state.trigger('videoControl.show', null);
+ }));
+ } else {
+ // On PC show controls immediately.
+ state.trigger('videoControl.show', null);
+ }
+
+ _hideWaitPlaceholder(state);
+ state.el.trigger('initialize', arguments);
+ });
+ });
+};
+
+/* eslint-disable no-use-before-define */
+let methodsDict = {
+ bindTo: bindTo,
+ fetchMetadata: fetchMetadata,
+ getCurrentLanguage: getCurrentLanguage,
+ getDuration: getDuration,
+ getPlayerMode: getPlayerMode,
+ getVideoMetadata: getVideoMetadata,
+ initialize: initialize,
+ isHtml5Mode: isHtml5Mode,
+ isFlashMode: isFlashMode,
+ isYoutubeType: isYoutubeType,
+ parseSpeed: parseSpeed,
+ parseYoutubeStreams: parseYoutubeStreams,
+ setPlayerMode: setPlayerMode,
+ setSpeed: setSpeed,
+ setAutoAdvance: setAutoAdvance,
+ speedToString: speedToString,
+ trigger: trigger,
+ youtubeId: youtubeId,
+ loadHtmlPlayer: loadHtmlPlayer,
+ loadYoutubePlayer: loadYoutubePlayer,
+ loadYouTubeIFrameAPI: loadYouTubeIFrameAPI
+};
+/* eslint-enable no-use-before-define */
+
+let _youtubeApiDeferred = null;
+let _oldOnYouTubeIframeAPIReady;
+
+Initialize.prototype = methodsDict;
+
+export default Initialize;
+
+// ***************************************************************
+// Private functions start here. Private functions start with underscore.
+// ***************************************************************
+
+/**
+ * @function _makeFunctionsPublic
+ *
+ * Functions which will be accessible via 'state' object. When called,
+ * these functions will get the 'state'
+ * object as a context.
+ *
+ * @param {object} state The object containg the state (properties,
+ * methods, modules) of the Video player.
+ */
+function _makeFunctionsPublic(state) {
+ bindTo(methodsDict, state, state);
+}
+
+// function _renderElements(state)
+//
+// Create any necessary DOM elements, attach them, and set their
+// 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.
+function _renderElements(state) {
+ // Launch embedding of actual video content, or set it up so that it
+ // will be done as soon as the appropriate video player (YouTube or
+ // stand-alone HTML5) is loaded, and can handle embedding.
+ //
+ // Note that the loading of stand alone HTML5 player API is handled by
+ // Require JS. At the time when we reach this code, the stand alone
+ // HTML5 player is already loaded, so no further testing in that case
+ // is required.
+ let video;
+ let onYTApiReady;
+ let setupOnYouTubeIframeAPIReady;
+
+ if (state.videoType === 'youtube') {
+ state.youtubeApiAvailable = false;
+
+ onYTApiReady = function() {
+ console.log('[Video info]: YouTube API is available and is loaded.');
+ if (state.htmlPlayerLoaded) { return; }
+
+ console.log('[Video info]: Starting YouTube player.');
+ video = VideoPlayer(state);
+
+ state.modules.push(video);
+ state.__dfd__.resolve();
+ state.youtubeApiAvailable = true;
+ };
+
+ if (window.YT) {
+ // If we have a Deferred object responsible for calling OnYouTubeIframeAPIReady
+ // callbacks, make sure that they have all been called by trying to resolve the
+ // Deferred object. Upon resolving, all the OnYouTubeIframeAPIReady will be
+ // called. If the object has been already resolved, the callbacks will not
+ // be called a second time.
+ if (_youtubeApiDeferred) {
+ _youtubeApiDeferred.resolve();
+ }
+
+ window.YT.ready(onYTApiReady);
+ } else {
+ // There is only one global variable window.onYouTubeIframeAPIReady which
+ // is supposed to be a function that will be called by the YouTube API
+ // when it finished initializing. This function will update this global function
+ // so that it resolves our Deferred object, which will call all of the
+ // OnYouTubeIframeAPIReady callbacks.
+ //
+ // If this global function is already defined, we store it first, and make
+ // sure that it gets executed when our Deferred object is resolved.
+ setupOnYouTubeIframeAPIReady = function() {
+ _oldOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady || undefined;
+
+ window.onYouTubeIframeAPIReady = function() {
+ _youtubeApiDeferred.resolve();
+ };
+
+ window.onYouTubeIframeAPIReady.done = _youtubeApiDeferred.done;
+
+ if (_oldOnYouTubeIframeAPIReady) {
+ window.onYouTubeIframeAPIReady.done(_oldOnYouTubeIframeAPIReady);
+ }
+ };
+
+ // If a Deferred object hasn't been created yet, create one now. It will
+ // be responsible for calling OnYouTubeIframeAPIReady callbacks once the
+ // YouTube API loads. After creating the Deferred object, load the YouTube
+ // API.
+ if (!_youtubeApiDeferred) {
+ _youtubeApiDeferred = $.Deferred();
+ setupOnYouTubeIframeAPIReady();
+ } else if (!window.onYouTubeIframeAPIReady || !window.onYouTubeIframeAPIReady.done) {
+ // The Deferred object could have been already defined in a previous
+ // initialization of the video module. However, since then the global variable
+ // window.onYouTubeIframeAPIReady could have been overwritten. If so,
+ // we should set it up again.
+ setupOnYouTubeIframeAPIReady();
+ }
+
+ // Attach a callback to our Deferred object to be called once the
+ // YouTube API loads.
+ window.onYouTubeIframeAPIReady.done(function() {
+ window.YT.ready(onYTApiReady);
+ });
+ }
+ } else {
+ video = VideoPlayer(state);
+
+ state.modules.push(video);
+ state.__dfd__.resolve();
+ state.htmlPlayerLoaded = true;
+ }
+}
+
+function _waitForYoutubeApi(state) {
+ console.log('[Video info]: Starting to wait for YouTube API to load.');
+ window.setTimeout(function() {
+ // If YouTube API will load OK, it will run `onYouTubeIframeAPIReady`
+ // callback, which will set `state.youtubeApiAvailable` to `true`.
+ // If something goes wrong at this stage, `state.youtubeApiAvailable` is
+ // `false`.
+ if (!state.youtubeApiAvailable) {
+ console.log('[Video info]: YouTube API is not available.');
+ if (!state.htmlPlayerLoaded) {
+ state.loadHtmlPlayer();
+ }
+ }
+ state.el.trigger('youtube_availability', [state.youtubeApiAvailable]);
+ }, state.config.ytTestTimeout);
+}
+
+function loadYouTubeIFrameAPI(scriptTag) {
+ let firstScriptTag = document.getElementsByTagName('script')[0];
+ firstScriptTag.parentNode.insertBefore(scriptTag, firstScriptTag);
+}
+
+// function _parseYouTubeIDs(state)
+// The function parse YouTube stream ID's.
+// @return
+// false: We don't have YouTube video IDs to work with; most likely
+// we have HTML5 video sources.
+// true: Parsing of YouTube video IDs went OK, and we can proceed
+// onwards to play YouTube videos.
+function _parseYouTubeIDs(state) {
+ if (state.parseYoutubeStreams(state.config.streams)) {
+ state.videoType = 'youtube';
+
+ return true;
+ }
+
+ console.log(
+ '[Video info]: Youtube Video IDs are incorrect or absent.'
+ );
+
+ 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.
+function _prepareHTML5Video(state) {
+ state.speeds = ['0.75', '1.0', '1.25', '1.50', '2.0'];
+ // If none of the supported video formats can be played and there is no
+ // short-hand video links, than hide the spinner and show error message.
+ if (!state.config.sources.length) {
+ _hideWaitPlaceholder(state);
+ state.el
+ .find('.video-player div')
+ .addClass('hidden');
+ state.el
+ .find('.video-player .video-error')
+ .removeClass('is-hidden');
+
+ return false;
+ }
+
+ state.videoType = 'html5';
+
+ if (!_.keys(state.config.transcriptLanguages).length) {
+ state.config.showCaptions = false;
+ }
+ state.setSpeed(state.speed);
+
+ return true;
+}
+
+function _hideWaitPlaceholder(state) {
+ state.el
+ .addClass('is-initialized')
+ .find('.spinner')
+ .attr({
+ 'aria-hidden': 'true',
+ tabindex: -1
+ });
+}
+
+function _setConfigurations(state) {
+ state.setPlayerMode(state.config.mode);
+ // Possible value are: 'visible', 'hiding', and 'invisible'.
+ state.controlState = 'visible';
+ state.controlHideTimeout = null;
+ state.captionState = 'invisible';
+ state.captionHideTimeout = null;
+ state.HLSVideoSources = extractHLSVideoSources(state);
+}
+
+// eslint-disable-next-line no-shadow
+function _initializeModules(state, i18n) {
+ let dfd = $.Deferred(),
+ modulesList = $.map(state.modules, function(module) {
+ let options = state.options[module.moduleName] || {};
+ if (_.isFunction(module)) {
+ return module(state, i18n, options);
+ } else if ($.isPlainObject(module)) {
+ return module;
+ }
+ });
+
+ $.when.apply(null, modulesList)
+ .done(dfd.resolve);
+
+ return dfd.promise();
+}
+
+function _getConfiguration(data, storage) {
+ let isBoolean = function(value) {
+ let regExp = /^true$/i;
+ return regExp.test(value.toString());
+ },
+ // List of keys that will be extracted form the configuration.
+ extractKeys = [],
+ // Compatibility keys used to change names of some parameters in
+ // the final configuration.
+ compatKeys = {
+ start: 'startTime',
+ end: 'endTime'
+ },
+ // Conversions used to pre-process some configuration data.
+ conversions = {
+ showCaptions: isBoolean,
+ autoplay: isBoolean,
+ autohideHtml5: isBoolean,
+ autoAdvance: function(value) {
+ let shouldAutoAdvance = storage.getItem('auto_advance');
+ if (_.isUndefined(shouldAutoAdvance)) {
+ return isBoolean(value) || false;
+ } else {
+ return shouldAutoAdvance;
+ }
+ },
+ savedVideoPosition: function(value) {
+ return storage.getItem('savedVideoPosition', true)
+ || Number(value)
+ || 0;
+ },
+ speed: function(value) {
+ return storage.getItem('speed', true) || value;
+ },
+ generalSpeed: function(value) {
+ return storage.getItem('general_speed')
+ || value
+ || '1.0';
+ },
+ transcriptLanguage: function(value) {
+ return storage.getItem('language')
+ || value
+ || 'en';
+ },
+ ytTestTimeout: function(value) {
+ value = parseInt(value, 10);
+
+ if (!isFinite(value)) {
+ value = 1500;
+ }
+
+ return value;
+ },
+ startTime: function(value) {
+ value = parseInt(value, 10);
+ if (!isFinite(value) || value < 0) {
+ return 0;
+ }
+
+ return value;
+ },
+ endTime: function(value) {
+ value = parseInt(value, 10);
+
+ if (!isFinite(value) || value === 0) {
+ return null;
+ }
+
+ return value;
+ }
+ },
+ config = {};
+
+ data = _.extend({
+ startTime: 0,
+ endTime: null,
+ sub: '',
+ streams: ''
+ }, data);
+
+ $.each(data, function(option, value) {
+ // Extract option that is in `extractKeys`.
+ if ($.inArray(option, extractKeys) !== -1) {
+ return;
+ }
+
+ // Change option name to key that is in `compatKeys`.
+ if (compatKeys[option]) {
+ option = compatKeys[option];
+ }
+
+ // Pre-process data.
+ if (conversions[option]) {
+ if (_.isFunction(conversions[option])) {
+ value = conversions[option].call(this, value);
+ } else {
+ throw new TypeError(option + ' is not a function.');
+ }
+ }
+ config[option] = value;
+ });
+
+ return config;
+}
+
+// ***************************************************************
+// Public functions start here.
+// These are available via the 'state' object. Their context ('this'
+// keyword) is the 'state' object. The magic private function that makes
+// them available and sets up their context is makeFunctionsPublic().
+// ***************************************************************
+
+// function bindTo(methodsDict, obj, context, rewrite)
+// Creates a new function with specific context and assigns it to the provided
+// object.
+// eslint-disable-next-line no-shadow
+function bindTo(methodsDict, obj, context, rewrite) {
+ $.each(methodsDict, function(name, method) {
+ if (_.isFunction(method)) {
+ if (_.isUndefined(rewrite)) {
+ rewrite = true;
+ }
+
+ if (_.isUndefined(obj[name]) || rewrite) {
+ obj[name] = _.bind(method, context);
+ }
+ }
+ });
+}
+
+function loadYoutubePlayer() {
+ if (this.htmlPlayerLoaded) { return; }
+
+ console.log(
+ '[Video info]: Fetch metadata for YouTube video.'
+ );
+
+ this.fetchMetadata();
+ this.parseSpeed();
+}
+
+function loadHtmlPlayer() {
+ // When the youtube link doesn't work for any reason
+ // (for example, firewall) any
+ // alternate sources should automatically play.
+ if (!_prepareHTML5Video(this)) {
+ console.log(
+ '[Video info]: Continue loading '
+ + 'YouTube video.'
+ );
+
+ // Non-YouTube sources were not found either.
+
+ this.el.find('.video-player div')
+ .removeClass('hidden');
+ this.el.find('.video-player .video-error')
+ .addClass('is-hidden');
+
+ // If in reality the timeout was to short, try to
+ // continue loading the YouTube video anyways.
+ this.loadYoutubePlayer();
+ } else {
+ console.log(
+ '[Video info]: Start HTML5 player.'
+ );
+
+ // In-browser HTML5 player does not support quality
+ // control.
+ this.el.find('.quality_control').hide();
+ _renderElements(this);
+ }
+}
+
+// function initialize(element)
+// The function set initial configuration and preparation.
+
+function initialize(element) {
+ let self = this,
+ el = this.el,
+ id = this.id,
+ container = el.find('.video-wrapper'),
+ __dfd__ = $.Deferred(),
+ isTouch = onTouchBasedDevice() || '';
+
+ if (isTouch) {
+ el.addClass('is-touch');
+ }
+
+ $.extend(this, {
+ __dfd__: __dfd__,
+ container: container,
+ isFullScreen: false,
+ isTouch: isTouch
+ });
+
+ console.log('[Video info]: Initializing video with id "%s".', id);
+
+ // We store all settings passed to us by the server in one place. These
+ // are "read only", so don't modify them. All variable content lives in
+ // 'state' object.
+ // jQuery .data() return object with keys in lower camelCase format.
+ this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), {
+ element: element,
+ fadeOutTimeout: 1400,
+ captionsFreezeTime: 10000,
+ mode: $.cookie('edX_video_player_mode'),
+ // Available HD qualities will only be accessible once the video has
+ // been played once, via player.getAvailableQualityLevels.
+ availableHDQualities: []
+ });
+
+ if (this.config.endTime < this.config.startTime) {
+ this.config.endTime = null;
+ }
+
+ this.lang = this.config.transcriptLanguage;
+ this.speed = this.speedToString(
+ this.config.speed || this.config.generalSpeed
+ );
+ this.auto_advance = this.config.autoAdvance;
+ this.htmlPlayerLoaded = false;
+ this.duration = this.metadata.duration;
+
+ _setConfigurations(this);
+
+ // If `prioritizeHls` is set to true than `hls` is the primary playback
+ if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) {
+ // If we do not have YouTube ID's, try parsing HTML5 video sources.
+ if (!_prepareHTML5Video(this)) {
+ __dfd__.reject();
+ // Non-YouTube sources were not found either.
+ return __dfd__.promise();
+ }
+
+ console.log('[Video info]: Start player in HTML5 mode.');
+ _renderElements(this);
+ } else {
+ _renderElements(this);
+
+ _waitForYoutubeApi(this);
+
+ let scriptTag = document.createElement('script');
+
+ scriptTag.src = this.config.ytApiUrl;
+ scriptTag.async = true;
+
+ $(scriptTag).on('load', function() {
+ self.loadYoutubePlayer();
+ });
+ $(scriptTag).on('error', function() {
+ console.log(
+ '[Video info]: YouTube returned an error for '
+ + 'video with id "' + self.id + '".'
+ );
+ // If the video is already loaded in `_waitForYoutubeApi` by the
+ // time we get here, then we shouldn't load it again.
+ if (!self.htmlPlayerLoaded) {
+ self.loadHtmlPlayer();
+ }
+ });
+
+ window.Video.loadYouTubeIFrameAPI(scriptTag);
+ }
+ return __dfd__.promise();
+}
+
+// function parseYoutubeStreams(state, youtubeStreams)
+//
+// Take a string in the form:
+// "iCawTYPtehk:0.75,KgpclqP-LBA:1.0,9-2670d5nvU:1.5"
+// parse it, and make it available via the 'state' object. If we are
+// not given a string, or it's length is zero, then we return false.
+//
+// @return
+// false: We don't have YouTube video IDs to work with; most likely
+// we have HTML5 video sources.
+// true: Parsing of YouTube video IDs went OK, and we can proceed
+// onwards to play YouTube videos.
+function parseYoutubeStreams(youtubeStreams) {
+ if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) {
+ return false;
+ }
+
+ this.videos = {};
+
+ _.each(youtubeStreams.split(/,/), function(video) {
+ let speed;
+ video = video.split(/:/);
+ speed = this.speedToString(video[0]);
+ this.videos[speed] = video[1];
+ }, this);
+
+ return _.isString(this.videos['1.0']);
+}
+
+// function fetchMetadata()
+//
+// When dealing with YouTube videos, we must fetch meta data that has
+// certain key facts not available while the video is loading. For
+// example the length of the video can be determined from the meta
+// data.
+function fetchMetadata() {
+ let self = this,
+ metadataXHRs = [];
+
+ this.metadata = {};
+
+ metadataXHRs = _.map(this.videos, function(url, speed) {
+ return self.getVideoMetadata(url, function(data) {
+ if (data.items.length > 0) {
+ let metaDataItem = data.items[0];
+ self.metadata[metaDataItem.id] = metaDataItem.contentDetails;
+ }
+ });
+ });
+
+ $.when.apply(this, metadataXHRs).done(function() {
+ self.el.trigger('metadata_received');
+
+ // Not only do we trigger the "metadata_received" event, we also
+ // set a flag to notify that metadata has been received. This
+ // allows for code that will miss the "metadata_received" event
+ // to know that metadata has been received. This is important in
+ // cases when some code will subscribe to the "metadata_received"
+ // event after it has been triggered.
+ self.youtubeMetadataReceived = true;
+ });
+}
+
+// function parseSpeed()
+//
+// Create a separate array of available speeds.
+function parseSpeed() {
+ this.speeds = _.keys(this.videos).sort();
+}
+
+function setSpeed(newSpeed) {
+ // Possible speeds for each player type.
+ // HTML5 = [0.75, 1, 1.25, 1.5, 2]
+ // Youtube Flash = [0.75, 1, 1.25, 1.5]
+ // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2]
+ let map = {
+ 0.25: '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash
+ '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash
+ 0.75: '0.50', // HTML5 or Youtube Flash -> Youtube HTML5
+ 1.25: '1.50', // HTML5 or Youtube Flash -> Youtube HTML5
+ 2.0: '1.50' // HTML5 or Youtube HTML5 -> Youtube Flash
+ };
+
+ if (_.contains(this.speeds, newSpeed)) {
+ this.speed = newSpeed;
+ } else {
+ newSpeed = map[newSpeed];
+ this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0';
+ }
+ this.speed = parseFloat(this.speed);
+}
+
+function setAutoAdvance(enabled) {
+ this.auto_advance = enabled;
+}
+
+function getVideoMetadata(url, callback) {
+ let youTubeEndpoint;
+ if (!(_.isString(url))) {
+ url = this.videos['1.0'] || '';
+ }
+ // Will hit the API URL to get the youtube video metadata.
+ youTubeEndpoint = this.config.ytMetadataEndpoint; // The new runtime supports anonymous users
+ // and uses an XBlock handler to get YouTube metadata
+ if (!youTubeEndpoint) {
+ // The old runtime has a full/separate LMS API for getting YouTube metadata, but it doesn't
+ // support anonymous users nor videos that play in a sandboxed iframe.
+ youTubeEndpoint = [this.config.lmsRootURL, '/courses/yt_video_metadata', '?id=', url].join('');
+ }
+ return $.ajax({
+ url: youTubeEndpoint,
+ success: _.isFunction(callback) ? callback : null,
+ error: function() {
+ console.warn(
+ 'Unable to get youtube video metadata. Some video metadata may be unavailable.'
+ );
+ },
+ notifyOnError: false
+ });
+}
+
+function youtubeId(speed) {
+ let currentSpeed = this.isFlashMode() ? this.speed : '1.0';
+
+ return this.videos[speed]
+ || this.videos[currentSpeed]
+ || this.videos['1.0'];
+}
+
+function getDuration() {
+ try {
+ let safeMoment = typeof moment !== 'undefined' ? moment : window.moment;
+ return safeMoment.duration(this.metadata[this.youtubeId()].duration, safeMoment.ISO_8601).asSeconds();
+ } catch (err) {
+ return _.result(this.metadata[this.youtubeId('1.0')], 'duration') || 0;
+ }
+}
+
+/**
+ * Sets player mode.
+ *
+ * @param {string} mode Mode to set for the video player if it is supported.
+ * Otherwise, `html5` is used by default.
+ */
+function setPlayerMode(mode) {
+ let supportedModes = ['html5', 'flash'];
+
+ mode = _.contains(supportedModes, mode) ? mode : 'html5';
+ this.currentPlayerMode = mode;
+}
+
+/**
+ * Returns current player mode.
+ *
+ * @return {string} Returns string that describes player mode
+ */
+function getPlayerMode() {
+ return this.currentPlayerMode;
+}
+
+/**
+ * Checks if current player mode is Flash.
+ *
+ * @return {boolean} Returns `true` if current mode is `flash`, otherwise
+ * it returns `false`
+ */
+function isFlashMode() {
+ return this.getPlayerMode() === 'flash';
+}
+
+/**
+ * Checks if current player mode is Html5.
+ *
+ * @return {boolean} Returns `true` if current mode is `html5`, otherwise
+ * it returns `false`
+ */
+function isHtml5Mode() {
+ return this.getPlayerMode() === 'html5';
+}
+
+function isYoutubeType() {
+ return this.videoType === 'youtube';
+}
+
+function speedToString(speed) {
+ return parseFloat(speed).toFixed(2).replace(/\.00$/, '.0');
+}
+
+function getCurrentLanguage() {
+ let keys = _.keys(this.config.transcriptLanguages);
+
+ if (keys.length) {
+ if (!_.contains(keys, this.lang)) {
+ if (_.contains(keys, 'en')) {
+ this.lang = 'en';
+ } else {
+ this.lang = keys.pop();
+ }
+ }
+ } else {
+ return null;
+ }
+
+ return this.lang;
+}
+
+/*
+ * The trigger() function will assume that the @objChain is a complete
+ * chain with a method (function) at the end. It will call this function.
+ * So for example, when trigger() is called like so:
+ *
+ * state.trigger('videoPlayer.pause', {'param1': 10});
+ *
+ * Then trigger() will execute:
+ *
+ * state.videoPlayer.pause({'param1': 10});
+ */
+function trigger(objChain) {
+ let extraParameters = Array.prototype.slice.call(arguments, 1),
+ i, tmpObj, chain;
+
+ // Remember that 'this' is the 'state' object.
+ tmpObj = this;
+ chain = objChain.split('.');
+
+ // At the end of the loop the variable 'tmpObj' will either be the
+ // correct object/function to trigger/invoke. If the 'chain' chain of
+ // object is incorrect (one of the link is non-existent), then the loop
+ // will immediately exit.
+ while (chain.length) {
+ i = chain.shift();
+
+ if (tmpObj.hasOwnProperty(i)) {
+ tmpObj = tmpObj[i];
+ } else {
+ // An incorrect object chain was specified.
+
+ return false;
+ }
+ }
+
+ tmpObj.apply(this, extraParameters);
+
+ return true;
+}
diff --git a/xmodule/assets/video/public/js/025_focus_grabber.js b/xmodule/assets/video/public/js/025_focus_grabber.js
new file mode 100644
index 000000000000..48ec5527ad0e
--- /dev/null
+++ b/xmodule/assets/video/public/js/025_focus_grabber.js
@@ -0,0 +1,132 @@
+/*
+ * 025_focus_grabber.js
+ *
+ * Purpose: Provide a way to focus on autohidden Video controls.
+ *
+ *
+ * Because in HTML player mode we have a feature of autohiding controls on
+ * mouse inactivity, sometimes focus is lost from the currently selected
+ * control. What's more, when all controls are autohidden, we can't get to any
+ * of them because by default browser does not place hidden elements on the
+ * focus chain.
+ *
+ * To get around this minor annoyance, this module will manage 2 placeholder
+ * elements that will be invisible to the user's eye, but visible to the
+ * browser. This will allow for a sneaky stealing of focus and placing it where
+ * we need (on hidden controls).
+ *
+ * This code has been moved to a separate module because it provides a concrete
+ * block of functionality that can be turned on (off).
+ */
+
+/*
+ * "If you want to climb a mountain, begin at the top."
+ *
+ * ~ Zen saying
+ */
+
+
+
+// FocusGrabber module.
+let FocusGrabber = function(state) {
+ let dfd = $.Deferred();
+
+ state.focusGrabber = {};
+
+ _makeFunctionsPublic(state);
+ _renderElements(state);
+ _bindHandlers(state);
+
+ dfd.resolve();
+ return dfd.promise();
+};
+
+// Private functions.
+
+function _makeFunctionsPublic(state) {
+ let methodsDict = {
+ disableFocusGrabber: disableFocusGrabber,
+ enableFocusGrabber: enableFocusGrabber,
+ onFocus: onFocus
+ };
+
+ state.bindTo(methodsDict, state.focusGrabber, state);
+}
+
+function _renderElements(state) {
+ state.focusGrabber.elFirst = state.el.find('.focus_grabber.first');
+ state.focusGrabber.elLast = state.el.find('.focus_grabber.last');
+
+ // From the start, the Focus Grabber must be disabled so that
+ // tabbing (switching focus) does not land the user on one of the
+ // placeholder elements (elFirst, elLast).
+ state.focusGrabber.disableFocusGrabber();
+}
+
+function _bindHandlers(state) {
+ state.focusGrabber.elFirst.on('focus', state.focusGrabber.onFocus);
+ state.focusGrabber.elLast.on('focus', state.focusGrabber.onFocus);
+
+ // When the video container element receives programmatic focus, then
+ // on un-focus ('blur' event) we should trigger a 'mousemove' event so
+ // as to reveal autohidden controls.
+ state.el.on('blur', function() {
+ state.el.trigger('mousemove');
+ });
+}
+
+// Public functions.
+
+function enableFocusGrabber() {
+ let tabIndex;
+
+ // When the Focus Grabber is being enabled, there are two different
+ // scenarios:
+ //
+ // 1.) Currently focused element was inside the video player.
+ // 2.) Currently focused element was somewhere else on the page.
+ //
+ // In the first case we must make sure that the video player doesn't
+ // loose focus, even though the controls are autohidden.
+ if ($(document.activeElement).parents().hasClass('video')) {
+ tabIndex = -1;
+ } else {
+ tabIndex = 0;
+ }
+
+ this.focusGrabber.elFirst.attr('tabindex', tabIndex);
+ this.focusGrabber.elLast.attr('tabindex', tabIndex);
+
+ // Don't loose focus. We are inside video player on some control, but
+ // because we can't remain focused on a hidden element, we will shift
+ // focus to the main video element.
+ //
+ // Once the main element will receive the un-focus ('blur') event, a
+ // 'mousemove' event will be triggered, and the video controls will
+ // receive focus once again.
+ if (tabIndex === -1) {
+ this.el.focus();
+
+ this.focusGrabber.elFirst.attr('tabindex', 0);
+ this.focusGrabber.elLast.attr('tabindex', 0);
+ }
+}
+
+function disableFocusGrabber() {
+ // Only programmatic focusing on these elements will be available.
+ // We don't want the user to focus on them (for example with the 'Tab'
+ // key).
+ this.focusGrabber.elFirst.attr('tabindex', -1);
+ this.focusGrabber.elLast.attr('tabindex', -1);
+}
+
+function onFocus(event, params) {
+ // Once the Focus Grabber placeholder elements will gain focus, we will
+ // trigger 'mousemove' event so that the autohidden controls will
+ // become visible.
+ this.el.trigger('mousemove');
+
+ this.focusGrabber.disableFocusGrabber();
+}
+
+export default FocusGrabber;
diff --git a/xmodule/assets/video/public/js/02_html5_hls_video.js b/xmodule/assets/video/public/js/02_html5_hls_video.js
new file mode 100644
index 000000000000..ddc198bc722f
--- /dev/null
+++ b/xmodule/assets/video/public/js/02_html5_hls_video.js
@@ -0,0 +1,145 @@
+/* eslint-disable no-console, no-param-reassign */
+/**
+ * HTML5 video player module to support HLS video playback.
+ *
+ */
+
+'use strict';
+
+import _ from 'underscore';
+import HTML5Video from './02_html5_video.js';
+import HLS from 'hls';
+
+let 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) {
+ let self = this;
+
+ this.config = config;
+
+ // do common initialization independent of player type
+ this.init(el, config);
+
+ _.bindAll(this, 'playVideo', 'pauseVideo', 'onReady');
+
+ // 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;
+ }
+
+ this.config.state.el.on('initialize', _.once(function() {
+ console.log('[HLS Video]: HLS Player initialized');
+ self.showPlayButton();
+ }));
+
+ // Safari has native support to play HLS videos
+ if (config.browserIsSafari) {
+ this.videoEl.attr('src', config.videoSources[0]);
+ } else {
+ // load auto start if auto_advance is enabled
+ if (config.state.auto_advance) {
+ this.hls = new HLS({autoStartLoad: true});
+ } else {
+ this.hls = new HLS({autoStartLoad: false});
+ }
+ 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
+ };
+ })
+ );
+ self.config.onReadyHLS();
+ });
+ this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) {
+ let 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;
+
+ Player.prototype.playVideo = function() {
+ HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['show']);
+ if (!this.config.browserIsSafari) {
+ this.hls.startLoad();
+ }
+ HTML5Video.Player.prototype.playVideo.apply(this);
+ };
+
+ Player.prototype.pauseVideo = function() {
+ HTML5Video.Player.prototype.pauseVideo.apply(this);
+ HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']);
+ };
+
+ Player.prototype.onPlaying = function() {
+ HTML5Video.Player.prototype.onPlaying.apply(this);
+ HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']);
+ };
+
+ Player.prototype.onReady = function() {
+ this.config.events.onReady(null);
+ };
+
+ /**
+ * 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;
+}());
+
+export default HLSVideo;
diff --git a/xmodule/assets/video/public/js/02_html5_video.js b/xmodule/assets/video/public/js/02_html5_video.js
new file mode 100644
index 000000000000..839372054377
--- /dev/null
+++ b/xmodule/assets/video/public/js/02_html5_video.js
@@ -0,0 +1,380 @@
+/* eslint-disable no-console, no-param-reassign */
+/**
+ * @file HTML5 video player module. Provides methods to control the in-browser
+ * HTML5 video player.
+ *
+ * The goal was to write this module so that it closely resembles the YouTube
+ * API. The main reason for this is because initially the edX video player
+ * supported only YouTube videos. When HTML5 support was added, for greater
+ * compatibility, and to reduce the amount of code that needed to be modified,
+ * it was decided to write a similar API as the one provided by YouTube.
+ *
+ * @module HTML5Video
+ */
+
+import _ from 'underscore';
+
+let 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'.
+ * poster: Video poster URL
+ *
+ * browserIsSafari: Flag to tell if current browser is Safari
+ *
+ * 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) {
+ let errorMessage, lastSource, sourceList;
+
+ // Create HTML markup for individual sources of the HTML5